Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b582d44502 | |||
| 36a2ebc94e | |||
| ed52a3188d | |||
| 93cc5c7b06 | |||
| 5689e93665 | |||
| c224028495 | |||
| 4fbe01823b | |||
| 34ba2c9f02 | |||
| 52aed0e96e | |||
| ea2e618990 | |||
| 140637a307 | |||
| 21c80e173d | |||
| e77fe9451e | |||
| 7971bd249e | |||
| 6099563acd | |||
| bf4c181026 | |||
| d9d12427d3 | |||
| 91aa9a7228 | |||
| 877356b247 | |||
| 2325f01cde | |||
| 00fdadb088 | |||
| 2b76e05a40 | |||
| 1b37944aab | |||
| 35a01a6981 | |||
| 3058706d2a | |||
| 0e4d6a3c0c | |||
| 2bc2475878 | |||
| 37eab7c7b1 | |||
| 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 | |||
| f145798f39 | |||
| 55f5465a9a | |||
| 0577f45ced | |||
| 7d23617f15 | |||
| 02415f8c53 | |||
| 73a47e5a97 | |||
| 5e980812b0 | |||
| 76e9735cde | |||
| 8bfc0c2fa2 | |||
| 55699f6618 | |||
| 6344c2deae | |||
| c1452131fa | |||
| 81f8e543e1 | |||
| bb6c26484d | |||
| 193a4bb180 | |||
| 0d9e6a4925 | |||
| ece9e46be9 | |||
| 918390a6a4 | |||
| 4ec0b67a71 | |||
| 356d6eca77 | |||
| 39c77accf8 | |||
| b8fba52cb3 | |||
| f247c77807 | |||
| e88938cf95 | |||
| 4f705a591e | |||
| 29687670e8 | |||
| 95daee1d8f | |||
| 11ca64a1cd | |||
| cfb727b86d | |||
| 1e4b9997f4 | |||
| bb32f23d77 | |||
| 1aa6451dba | |||
| eb0408c036 | |||
| 098a2567fa | |||
| c6534df362 | |||
| 2e4b375ad5 | |||
| 802bcf1c3d | |||
| bad0bd9053 | |||
| ca990781b0 | |||
| 6807aefce8 | |||
| 450ec4816e | |||
| ab4310b775 | |||
| 6efd986406 | |||
| 7370d7f0e7 | |||
| e733067c25 | |||
| bc2ed808f9 | |||
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 | |||
| d53cff6a94 | |||
| eb211348d2 | |||
| 43618abeba | |||
| dd9769b814 |
@@ -1 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.nogit/
|
||||||
|
.git/
|
||||||
|
.playwright-mcp/
|
||||||
|
.vscode/
|
||||||
|
test/
|
||||||
|
test_watch/
|
||||||
|
|||||||
528
changelog.md
528
changelog.md
@@ -1,5 +1,533 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.9.1 - fix(network-ui)
|
||||||
|
enable flashing table updates for network activity, remote ingress, and VPN views
|
||||||
|
|
||||||
|
- adds stable row keys to dees-table instances so existing rows can be diffed correctly
|
||||||
|
- enables flash highlighting for changed rows and cells across network activity, top IPs, backends, remote ingress edges, and VPN clients
|
||||||
|
- updates network activity request data on every refresh so live metrics like duration and byte counts visibly refresh
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.9.0 - feat(dns)
|
||||||
|
add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local
|
||||||
|
|
||||||
|
- Expose a synthetic built-in "DcRouter" provider in provider listings and block create, edit, delete, test, and external domain listing operations for it
|
||||||
|
- Rename domain and record source semantics from "manual" to "dcrouter" and "local" across backend, interfaces, and UI
|
||||||
|
- Add database migrations to convert existing DomainDoc.source and DnsRecordDoc.source values to the new naming
|
||||||
|
- Update domain creation flows and provider UI labels to reflect dcrouter-hosted authoritative domains
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.8.0 - feat(acme)
|
||||||
|
add DB-backed ACME configuration management and OpsServer certificate settings UI
|
||||||
|
|
||||||
|
- introduces a singleton AcmeConfig manager and document persisted in the database, with first-boot seeding from legacy tls.contactEmail and smartProxyConfig.acme options
|
||||||
|
- updates SmartProxy startup to read live ACME settings from the database and only enable DNS-01 challenge wiring when ACME is configured and enabled
|
||||||
|
- adds authenticated OpsServer typed request endpoints and API token scopes for reading and updating ACME configuration
|
||||||
|
- adds web app state and a certificates view card/modal for viewing and editing ACME settings from the Domains certificate UI
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.7.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.7.0 - feat(dns-providers)
|
||||||
|
add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows
|
||||||
|
|
||||||
|
- Introduce shared DNS provider type descriptors and credential field metadata to drive provider forms dynamically.
|
||||||
|
- Add a reusable dns-provider-form component and update provider create/edit dialogs to use typed provider selection and credential handling.
|
||||||
|
- Remove Cloudflare-specific ACME helper exposure and clarify provider-agnostic DNS manager and factory documentation for future provider implementations.
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.6.0 - feat(dns)
|
||||||
|
add db-backed DNS provider, domain, and record management with ops UI support
|
||||||
|
|
||||||
|
- introduce DnsManager-backed persistence for DNS providers, domains, and records with Cloudflare provider support
|
||||||
|
- replace constructor-based ACME DNS challenge configuration with provider records stored in the database
|
||||||
|
- add opsserver typed request handlers and API token scopes for managing DNS providers, domains, and records
|
||||||
|
- add a new Domains section in the ops UI for providers, domains, DNS records, and certificates
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
|
||||||
|
add admin user listing to the access dashboard
|
||||||
|
|
||||||
|
- register a new admin-only typed request endpoint to list users with id, username, and role while excluding passwords
|
||||||
|
- add users state management and a dedicated access dashboard view for browsing OpsServer user accounts
|
||||||
|
- update access routing to include the new users subview and improve related table filtering and section headings
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.4.2 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.4.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.4.0 - feat(web-ui)
|
||||||
|
reorganize dashboard views into grouped navigation with new email, access, and network subviews
|
||||||
|
|
||||||
|
- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security.
|
||||||
|
- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation.
|
||||||
|
- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior.
|
||||||
|
- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling.
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.3.0 - feat(web-ui)
|
||||||
|
reorganize network and security views into tabbed subviews with route-aware navigation
|
||||||
|
|
||||||
|
- add URL-based subview support in app state and router for network and security sections
|
||||||
|
- group routes, source profiles, network targets, and target profiles under the network view with tab navigation
|
||||||
|
- split security into dedicated overview, blocked IPs, authentication, and email security subviews
|
||||||
|
- update configuration navigation to deep-link directly to the network routes subview
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.2.2 - fix(project)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.2.1 - fix(project)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.2.0 - feat(ops-ui)
|
||||||
|
add column filters to operations tables across admin views
|
||||||
|
|
||||||
|
- Enable table column filters for API tokens, certificates, network requests, top IPs, backends, network targets, remote ingress edges, security views, source profiles, target profiles, and VPN clients.
|
||||||
|
- Improves filtering and exploration of operational data throughout the admin interface without changing backend behavior.
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.1.3 - fix(certificate-handler)
|
||||||
|
preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
|
||||||
|
|
||||||
|
- add deriveCertDomainName helper to match shared ACME certificate identities across wildcard and subdomain routes
|
||||||
|
- pass includeWildcard when force-renewing certificates so renewed certs keep wildcard SAN coverage for sibling subdomains
|
||||||
|
- persist renewed certificate data to all sibling route domains that share the same cert identity and clear cached certificate status entries
|
||||||
|
- add regression tests for certificate domain derivation and force-renew wildcard handling
|
||||||
|
|
||||||
|
## 2026-04-07 - 13.1.2 - fix(deps)
|
||||||
|
bump @serve.zone/catalog to ^2.12.3
|
||||||
|
|
||||||
|
- Updates @serve.zone/catalog from ^2.12.0 to ^2.12.3 in package.json
|
||||||
|
|
||||||
|
## 2026-04-07 - 13.1.1 - fix(deps)
|
||||||
|
bump catalog-related dependencies to newer patch and minor releases
|
||||||
|
|
||||||
|
- update @design.estate/dees-catalog from ^3.66.0 to ^3.67.1
|
||||||
|
- update @serve.zone/catalog from ^2.11.2 to ^2.12.0
|
||||||
|
|
||||||
|
## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations)
|
||||||
|
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)
|
||||||
|
support profile and target metadata in route creation and refresh remote ingress routes after config initialization
|
||||||
|
|
||||||
|
- Re-applies routes to the remote ingress manager after config managers finish to avoid missing DB-backed routes during initialization
|
||||||
|
- Fetches profiles and targets when opening or authenticating into the routes view so route creation dropdowns are populated
|
||||||
|
- Includes selected security profile and network target metadata when creating programmatic routes and displays that metadata in route details
|
||||||
|
- Improves security profile forms by switching IP allow/block lists to list inputs instead of comma-separated text fields
|
||||||
|
- Updates UI dependencies including smartdb, dees-catalog, and serve.zone catalog
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.3 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.2 - fix(route-config)
|
||||||
|
sync applied routes to remote ingress manager after route updates
|
||||||
|
|
||||||
|
- add an optional route-applied callback to RouteConfigManager
|
||||||
|
- forward merged SmartProxy routes to RemoteIngressManager whenever routes are updated
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.1 - fix(web-ui)
|
||||||
|
align dees-table props and action handlers in security profile and network target views
|
||||||
|
|
||||||
|
- replace deprecated table heading prop with heading1 and heading2 in both admin views
|
||||||
|
- rename table action callbacks from action to actionFunc for create, refresh, edit, and delete actions
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.0 - feat(config)
|
||||||
|
add reusable security profiles and network targets with route reference resolution
|
||||||
|
|
||||||
|
- introduces persisted security profile and network target models plus typed OpsServer CRUD and usage endpoints
|
||||||
|
- adds route metadata support so routes can reference profiles and targets and be re-resolved after updates
|
||||||
|
- supports optional seeding of default profiles and targets when the database is empty
|
||||||
|
- adds dashboard views and state management for managing security profiles and network targets
|
||||||
|
- includes tests for reference resolver behavior and API fallback/auth handling
|
||||||
|
|
||||||
|
## 2026-04-01 - 12.1.0 - feat(vpn)
|
||||||
|
add per-client routing controls and bridge forwarding support for VPN clients
|
||||||
|
|
||||||
|
- adds persisted per-client VPN settings for SmartProxy enforcement, destination allow/block lists, host IP assignment, DHCP/static IP selection, and VLAN options
|
||||||
|
- passes new VPN routing and bridge configuration through request handlers, app state, and the ops UI for creating, editing, and viewing clients
|
||||||
|
- supports bridge and hybrid forwarding modes in the VPN manager, including auto-upgrading to hybrid when clients request host IP access
|
||||||
|
- updates smartvpn and dees-catalog dependencies to support the new VPN forwarding capabilities
|
||||||
|
|
||||||
|
## 2026-03-31 - 12.0.0 - BREAKING CHANGE(db)
|
||||||
|
replace StorageManager and CacheDb with a unified smartdata-backed database layer
|
||||||
|
|
||||||
|
- introduces DcRouterDb with embedded LocalSmartDb or external MongoDB support via dbConfig
|
||||||
|
- migrates persisted routes, API tokens, VPN data, certificates, remote ingress, VLAN mappings, RADIUS accounting, and cache records to smartdata document classes
|
||||||
|
- removes StorageManager and CacheDb modules and renames configuration from cacheConfig to dbConfig
|
||||||
|
- updates certificate, security, remote ingress, VPN, and RADIUS components to read and write through document models
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.5 - fix(config)
|
||||||
|
correct VPN mandatory flag default handling in route config manager
|
||||||
|
|
||||||
|
- Changes the VPN mandatory check so it only applies when explicitly set to true, matching the updated default behavior of false.
|
||||||
|
- Prevents routes from being treated as VPN-mandatory when the setting is omitted.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.4 - fix(deps)
|
||||||
|
bump @push.rocks/smartvpn to 1.17.1
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartvpn dependency from 1.16.5 to 1.17.1.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.3 - fix(ts_web)
|
||||||
|
update appstate to import interfaces from source TypeScript module path
|
||||||
|
|
||||||
|
- Replaces the appstate interfaces import from ../dist_ts_interfaces/index.js with ../ts_interfaces/index.js.
|
||||||
|
- Aligns the web app state module with the source interface location instead of the built distribution path.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.2 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.0 - feat(vpn)
|
||||||
|
support optional non-mandatory VPN route access and align route config with enabled semantics
|
||||||
|
|
||||||
|
- rename route VPN configuration from `required` to `enabled` across code, docs, and examples
|
||||||
|
- add `vpn.mandatory` to control whether VPN allowlists replace or extend existing `security.ipAllowList` rules
|
||||||
|
- improve VPN client status matching in the ops view by falling back to assigned IP when client IDs differ
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.22.0 - feat(vpn)
|
||||||
|
add VPN client editing and connected client visibility in ops server
|
||||||
|
|
||||||
|
- Adds API support to list currently connected VPN clients and update client metadata without rotating keys
|
||||||
|
- Updates the web VPN view to show live connection status, client detail telemetry, and separate enable/disable actions
|
||||||
|
- Refreshes documentation for smart split tunnel behavior, QR code setup/export, and storage architecture
|
||||||
|
- Bumps @push.rocks/smartvpn from 1.16.4 to 1.16.5
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.5 - fix(routing)
|
||||||
|
apply VPN route allowlists dynamically after VPN clients load
|
||||||
|
|
||||||
|
- Moves VPN security injection for hardcoded and programmatic routes into RouteConfigManager.applyRoutes() so allowlists are generated from current VPN client state.
|
||||||
|
- Re-applies routes after starting the VPN manager to ensure tag-based ipAllowLists are available once VPN clients are loaded.
|
||||||
|
- Avoids caching constructor routes with stale VPN security baked in while preserving HTTP/3 route augmentation.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.4 - fix(deps)
|
||||||
|
bump @push.rocks/smartvpn to 1.16.4
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartvpn dependency from 1.16.3 to 1.16.4 in package.json.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.3 - fix(deps)
|
||||||
|
bump @push.rocks/smartvpn to 1.16.3
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartvpn dependency from 1.16.2 to 1.16.3.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.2 - fix(deps)
|
||||||
|
bump @push.rocks/smartvpn to 1.16.2
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartvpn dependency from 1.16.1 to 1.16.2 in package.json.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.1 - fix(vpn)
|
||||||
|
resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups
|
||||||
|
|
||||||
|
- Derive WireGuard AllowedIPs from DNS A records of matched vpn.required route domains instead of only configured public proxy IPs.
|
||||||
|
- Cache resolved domain IPs for 5 minutes and fall back to stale results on DNS lookup failures.
|
||||||
|
- Make per-client AllowedIPs generation asynchronous throughout VPN config export and regeneration flows.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.0 - feat(vpn)
|
||||||
|
add tag-aware WireGuard AllowedIPs for VPN-gated routes
|
||||||
|
|
||||||
|
- compute per-client WireGuard AllowedIPs from server-defined client tags and VPN-required proxy routes
|
||||||
|
- include the server public IP in AllowedIPs when a client can access VPN-gated domains so routed traffic reaches the proxy
|
||||||
|
- preserve and inject WireGuard private keys in generated and exported client configs for valid exports
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.20.1 - fix(vpn-manager)
|
||||||
|
persist WireGuard private keys for valid client exports and QR codes
|
||||||
|
|
||||||
|
- Store each client's WireGuard private key when creating and rotating keys.
|
||||||
|
- Inject the stored private key into exported WireGuard configs so generated configs are complete and scannable.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.20.0 - feat(vpn-ui)
|
||||||
|
add QR code export for WireGuard client configurations
|
||||||
|
|
||||||
|
- adds a QR code action for newly created WireGuard configs in the VPN operations view
|
||||||
|
- adds a QR code export option for existing VPN clients alongside file downloads
|
||||||
|
- introduces qrcode and @types/qrcode dependencies and exposes the plugin for web UI use
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.19.1 - fix(vpn)
|
||||||
|
configure SmartVPN client exports with explicit server endpoint and split-tunnel allowed IPs
|
||||||
|
|
||||||
|
- Pass the configured WireGuard server endpoint directly to SmartVPN instead of rewriting generated client configs in dcrouter.
|
||||||
|
- Set client allowed IPs to the VPN subnet so generated WireGuard configs default to split-tunnel routing.
|
||||||
|
- Update documentation to reflect SmartVPN startup, dashboard/API coverage, and the new split-tunnel behavior.
|
||||||
|
- Bump @push.rocks/smartvpn from 1.14.0 to 1.16.1 to support the updated VPN configuration flow.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.19.0 - feat(vpn)
|
||||||
|
document tag-based VPN access control, declarative clients, and destination policy options
|
||||||
|
|
||||||
|
- Adds documentation for restricting VPN-protected routes with allowedServerDefinedClientTags.
|
||||||
|
- Documents pre-defined VPN clients in configuration via vpnConfig.clients.
|
||||||
|
- Describes destinationPolicy behavior for forceTarget, allow, and block traffic handling.
|
||||||
|
- Updates interface docs to reflect serverDefinedClientTags and revised VPN server status fields.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.18.0 - feat(vpn-ui)
|
||||||
|
add format selection for VPN client config exports
|
||||||
|
|
||||||
|
- Show an export modal that lets operators choose between WireGuard (.conf) and SmartVPN (.json) client configs.
|
||||||
|
- Update VPN client row actions to read the selected item from actionData for toggle, export, rotate keys, and delete handlers.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.17.0 - feat(vpn)
|
||||||
|
expand VPN operations view with client management and config export actions
|
||||||
|
|
||||||
|
- adds predefined VPN clients to the dev server configuration for local testing
|
||||||
|
- adds table actions to create clients, export WireGuard configs, rotate client keys, toggle access, and delete clients
|
||||||
|
- updates the VPN view layout and stats grid binding to match the current component API
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.16.0 - feat(vpn)
|
||||||
|
add destination-based VPN routing policy and standardize socket proxy forwarding
|
||||||
|
|
||||||
|
- replace configurable VPN forwarding mode with socket-based forwarding and always enable proxy protocol support to SmartProxy from localhost
|
||||||
|
- add destinationPolicy configuration for controlling default VPN traffic handling, including forceTarget, allow, and block rules
|
||||||
|
- remove forwarding mode reporting from VPN status APIs, logs, and ops UI to reflect the simplified VPN runtime model
|
||||||
|
- update @push.rocks/smartvpn to 1.14.0 to support the new VPN routing behavior
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.15.0 - feat(vpn)
|
||||||
|
add tag-based VPN route access control and support configured initial VPN clients
|
||||||
|
|
||||||
|
- allow VPN-protected routes to restrict access to clients with matching server-defined tags instead of always permitting the full VPN subnet
|
||||||
|
- create configured VPN clients automatically on startup and re-apply routes when VPN clients change
|
||||||
|
- rename VPN client tag fields to serverDefinedClientTags across APIs, interfaces, handlers, and UI with legacy tag migration on load
|
||||||
|
- upgrade @push.rocks/smartvpn from 1.12.0 to 1.13.0
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.14.0 - feat(docs)
|
||||||
|
document VPN access control and add OpsServer VPN navigation
|
||||||
|
|
||||||
|
- Adds comprehensive README documentation for VPN access control, configuration, operating modes, and client management
|
||||||
|
- Updates TypeScript interface documentation with VPN-related route, client, status, telemetry, and API request types
|
||||||
|
- Extends web dashboard documentation and router view list to include VPN management
|
||||||
|
|
||||||
## 2026-03-30 - 11.13.0 - feat(vpn)
|
## 2026-03-30 - 11.13.0 - feat(vpn)
|
||||||
add VPN server management and route-based VPN access control
|
add VPN server management and route-based VPN access control
|
||||||
|
|
||||||
|
|||||||
25
package.json
25
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "11.13.0",
|
"version": "13.9.1",
|
||||||
"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,37 +35,40 @@
|
|||||||
"@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.0",
|
"@design.estate/dees-catalog": "^3.70.0",
|
||||||
"@design.estate/dees-element": "^2.2.4",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/lik": "^6.4.0",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
"@push.rocks/projectinfo": "^5.1.0",
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartacme": "^9.3.1",
|
"@push.rocks/smartacme": "^9.5.0",
|
||||||
"@push.rocks/smartdata": "^7.1.3",
|
"@push.rocks/smartdata": "^7.1.7",
|
||||||
"@push.rocks/smartdb": "^2.0.0",
|
"@push.rocks/smartdb": "^2.6.2",
|
||||||
"@push.rocks/smartdns": "^7.9.0",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
"@push.rocks/smartfs": "^1.5.0",
|
"@push.rocks/smartfs": "^1.5.0",
|
||||||
"@push.rocks/smartguard": "^3.1.0",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
"@push.rocks/smartjwt": "^2.2.1",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartlog": "^3.2.1",
|
"@push.rocks/smartlog": "^3.2.2",
|
||||||
"@push.rocks/smartmetrics": "^3.0.3",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
|
"@push.rocks/smartmigration": "1.2.0",
|
||||||
"@push.rocks/smartmta": "^5.3.1",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartnetwork": "^4.5.2",
|
"@push.rocks/smartnetwork": "^4.5.2",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^27.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.12.0",
|
"@push.rocks/smartvpn": "1.19.2",
|
||||||
"@push.rocks/taskbuffer": "^8.0.2",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/catalog": "^2.9.0",
|
"@serve.zone/catalog": "^2.12.3",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^4.15.3",
|
"@serve.zone/remoteingress": "^4.15.3",
|
||||||
"@tsclass/tsclass": "^9.5.0",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
"lru-cache": "^11.2.7",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"lru-cache": "^11.3.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
2711
pnpm-lock.yaml
generated
2711
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
335
readme.md
335
readme.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
||||||
|
|
||||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
|
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -23,8 +23,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- [DNS Server](#dns-server)
|
- [DNS Server](#dns-server)
|
||||||
- [RADIUS Server](#radius-server)
|
- [RADIUS Server](#radius-server)
|
||||||
- [Remote Ingress](#remote-ingress)
|
- [Remote Ingress](#remote-ingress)
|
||||||
|
- [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)
|
||||||
@@ -73,6 +74,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
||||||
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
||||||
|
|
||||||
|
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
|
||||||
|
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
|
||||||
|
- **Route-level VPN gating** — mark any route with `vpn: { enabled: true }` to restrict access to VPN clients only, or `vpn: { enabled: true, mandatory: false }` to add VPN clients alongside existing access rules
|
||||||
|
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
|
||||||
|
- **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup
|
||||||
|
- **Rootless operation** — uses userspace NAT (smoltcp) with no root required
|
||||||
|
- **Destination policy** — configurable `forceTarget`, `block`, or `allow` with allowList/blockList for granular traffic control
|
||||||
|
- **Client management** — create, enable, disable, rotate keys, export WireGuard/SmartVPN configs via OpsServer API and dashboard
|
||||||
|
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
|
||||||
|
- **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
||||||
|
|
||||||
### ⚡ High Performance
|
### ⚡ High Performance
|
||||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||||
@@ -81,18 +93,21 @@ 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
|
||||||
- **JWT authentication** with session persistence
|
- **JWT authentication** with session persistence
|
||||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, 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
|
||||||
@@ -248,11 +263,17 @@ const router = new DcRouter({
|
|||||||
hubDomain: 'hub.example.com',
|
hubDomain: 'hub.example.com',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Persistent storage
|
// VPN — restrict sensitive routes to VPN clients
|
||||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.example.com',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// Cache database
|
// Unified database (embedded LocalSmartDb or external MongoDB)
|
||||||
cacheConfig: { enabled: true, storagePath: '~/.serve.zone/dcrouter/tsmdb' },
|
dbConfig: { enabled: true },
|
||||||
|
|
||||||
// TLS & ACME
|
// TLS & ACME
|
||||||
tls: { contactEmail: 'admin@example.com' },
|
tls: { contactEmail: 'admin@example.com' },
|
||||||
@@ -276,6 +297,7 @@ graph TB
|
|||||||
DNS[DNS Queries]
|
DNS[DNS Queries]
|
||||||
RAD[RADIUS Clients]
|
RAD[RADIUS Clients]
|
||||||
EDGE[Edge Nodes]
|
EDGE[Edge Nodes]
|
||||||
|
VPN[VPN Clients]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "DcRouter Core"
|
subgraph "DcRouter Core"
|
||||||
@@ -285,11 +307,11 @@ graph TB
|
|||||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||||
RS[SmartRadius Server]
|
RS[SmartRadius Server]
|
||||||
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
||||||
|
VS[SmartVPN Server<br/><i>Rust data plane</i>]
|
||||||
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"
|
||||||
@@ -305,17 +327,18 @@ graph TB
|
|||||||
DNS --> DS
|
DNS --> DS
|
||||||
RAD --> RS
|
RAD --> RS
|
||||||
EDGE --> RI
|
EDGE --> RI
|
||||||
|
VPN --> VS
|
||||||
|
|
||||||
DC --> SP
|
DC --> SP
|
||||||
DC --> ES
|
DC --> ES
|
||||||
DC --> DS
|
DC --> DS
|
||||||
DC --> RS
|
DC --> RS
|
||||||
DC --> RI
|
DC --> RI
|
||||||
|
DC --> VS
|
||||||
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
|
||||||
@@ -340,15 +363,14 @@ 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
|
||||||
|
|
||||||
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
||||||
|
|
||||||
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
|
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and SmartVPN based on which configs are provided. Services start in dependency order via `ServiceManager`.
|
||||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartVPN runs a Rust data plane for WireGuard and custom transports. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
||||||
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
||||||
|
|
||||||
### Rust-Powered Architecture
|
### Rust-Powered Architecture
|
||||||
@@ -361,6 +383,7 @@ DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-c
|
|||||||
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
|
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
|
||||||
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
||||||
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
||||||
|
| **SmartVPN** | `smartvpn_daemon` | WireGuard (boringtun), Noise IK handshake, QUIC/WS transports, userspace NAT (smoltcp) |
|
||||||
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
||||||
|
|
||||||
## Configuration Reference
|
## Configuration Reference
|
||||||
@@ -428,6 +451,27 @@ interface IDcRouterOptions {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── VPN ───────────────────────────────────────────────────────
|
||||||
|
/** VPN server for route-level access control */
|
||||||
|
vpnConfig?: {
|
||||||
|
enabled?: boolean; // default: false
|
||||||
|
subnet?: string; // default: '10.8.0.0/24'
|
||||||
|
wgListenPort?: number; // default: 51820
|
||||||
|
dns?: string[]; // DNS servers pushed to VPN clients
|
||||||
|
serverEndpoint?: string; // Hostname in generated client configs
|
||||||
|
clients?: Array<{ // Pre-defined VPN clients
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
destinationPolicy?: { // Traffic routing policy
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
target?: string; // IP for forceTarget (default: '127.0.0.1')
|
||||||
|
allowList?: string[]; // Pass through directly
|
||||||
|
blockList?: string[]; // Always block (overrides allowList)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
||||||
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
||||||
http3?: {
|
http3?: {
|
||||||
@@ -462,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
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -975,6 +1011,129 @@ The OpsServer Remote Ingress view provides:
|
|||||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||||
| **Delete** | Remove the edge registration |
|
| **Delete** | Remove the edge registration |
|
||||||
|
|
||||||
|
## VPN Access Control
|
||||||
|
|
||||||
|
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
|
||||||
|
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
|
||||||
|
3. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.enabled` are resolved at config generation time, so clients route only the necessary traffic through the tunnel
|
||||||
|
4. Routes with `vpn: { enabled: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change). With `mandatory: true` (default), the allowlist is replaced; with `mandatory: false`, VPN IPs are appended to existing rules
|
||||||
|
5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
|
||||||
|
6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
|
||||||
|
7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
|
||||||
|
|
||||||
|
### Destination Policy
|
||||||
|
|
||||||
|
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Default: all traffic → SmartProxy
|
||||||
|
destinationPolicy: { default: 'forceTarget', target: '127.0.0.1' }
|
||||||
|
|
||||||
|
// Allow direct access to a backend subnet
|
||||||
|
destinationPolicy: {
|
||||||
|
default: 'forceTarget',
|
||||||
|
target: '127.0.0.1',
|
||||||
|
allowList: ['192.168.190.*'], // direct access to this subnet
|
||||||
|
blockList: ['192.168.190.1'], // except the gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block everything except specific IPs
|
||||||
|
destinationPolicy: {
|
||||||
|
default: 'block',
|
||||||
|
allowList: ['10.0.0.*', '192.168.1.*'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const router = new DcRouter({
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
||||||
|
wgListenPort: 51820, // WireGuard UDP port (default)
|
||||||
|
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
|
||||||
|
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
|
||||||
|
|
||||||
|
// Pre-define VPN clients with server-defined tags
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'alice-laptop', serverDefinedClientTags: ['engineering'], description: 'Dev laptop' },
|
||||||
|
{ clientId: 'bob-phone', serverDefinedClientTags: ['engineering', 'mobile'] },
|
||||||
|
{ clientId: 'carol-desktop', serverDefinedClientTags: ['finance'] },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Optional: customize destination policy (default: forceTarget → localhost)
|
||||||
|
// destinationPolicy: { default: 'forceTarget', target: '127.0.0.1', allowList: ['192.168.1.*'] },
|
||||||
|
},
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: [
|
||||||
|
// 🔐 VPN-only: any VPN client can access
|
||||||
|
{
|
||||||
|
name: 'internal-app',
|
||||||
|
match: { domains: ['internal.example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.50', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
vpn: { enabled: true },
|
||||||
|
},
|
||||||
|
// 🔐 VPN + tag-restricted: only 'engineering' tagged clients
|
||||||
|
{
|
||||||
|
name: 'eng-dashboard',
|
||||||
|
match: { domains: ['eng.example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.51', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||||
|
// → alice + bob can access, carol cannot
|
||||||
|
},
|
||||||
|
// 🌐 Public: no VPN
|
||||||
|
{
|
||||||
|
name: 'public-site',
|
||||||
|
match: { domains: ['example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.10', port: 80 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Tags
|
||||||
|
|
||||||
|
SmartVPN distinguishes between two types of client tags:
|
||||||
|
|
||||||
|
| Tag Type | Set By | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `serverDefinedClientTags` | Admin (via config or API) | **Trusted** — used for route access control |
|
||||||
|
| `clientDefinedClientTags` | Connecting client | **Informational** — displayed in dashboard, never used for security |
|
||||||
|
|
||||||
|
Routes with `allowedServerDefinedClientTags` only permit VPN clients whose admin-assigned tags match. Clients cannot influence their own server-defined tags.
|
||||||
|
|
||||||
|
### Client Management via OpsServer
|
||||||
|
|
||||||
|
The OpsServer dashboard and API provide full VPN client lifecycle management:
|
||||||
|
|
||||||
|
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
|
||||||
|
- **QR code** — scan with the WireGuard mobile app (iOS/Android) for instant setup
|
||||||
|
- **Enable / Disable** — toggle client access without deleting
|
||||||
|
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
||||||
|
- **Export config** — download in WireGuard (`.conf`), SmartVPN (`.json`), or scan as QR code
|
||||||
|
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
|
||||||
|
- **Delete** — remove a client and revoke access
|
||||||
|
|
||||||
|
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or by scanning the QR code — no custom VPN software needed.
|
||||||
|
|
||||||
## Certificate Management
|
## Certificate Management
|
||||||
|
|
||||||
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
||||||
@@ -1043,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
|
||||||
|
|
||||||
@@ -1149,8 +1314,14 @@ The OpsServer provides a web-based management interface served on port 3000 by d
|
|||||||
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
||||||
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
||||||
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
||||||
|
| 🛣️ **Routes** | Merged route list (hardcoded + programmatic), create/edit/toggle/override routes |
|
||||||
|
| 🔑 **API Tokens** | Token management with scopes, create/revoke/roll/toggle |
|
||||||
| 🔐 **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 |
|
||||||
|
| 🛡️ **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 |
|
||||||
| 📜 **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 |
|
||||||
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
||||||
@@ -1215,6 +1386,17 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
|||||||
'getRecentLogs' // Retrieve system logs with filtering
|
'getRecentLogs' // Retrieve system logs with filtering
|
||||||
'getLogStream' // Stream live logs
|
'getLogStream' // Stream live logs
|
||||||
|
|
||||||
|
// VPN
|
||||||
|
'getVpnClients' // List all registered VPN clients
|
||||||
|
'getVpnStatus' // VPN server status (running, subnet, port, keys)
|
||||||
|
'createVpnClient' // Create client → returns WireGuard config (shown once)
|
||||||
|
'deleteVpnClient' // Remove a VPN client
|
||||||
|
'enableVpnClient' // Enable a disabled client
|
||||||
|
'disableVpnClient' // Disable a client
|
||||||
|
'rotateVpnClientKey' // Generate new keys (invalidates old ones)
|
||||||
|
'exportVpnClientConfig' // Export WireGuard (.conf) or SmartVPN (.json) config
|
||||||
|
'getVpnClientTelemetry' // Per-client bytes sent/received, keepalives
|
||||||
|
|
||||||
// RADIUS
|
// RADIUS
|
||||||
'getRadiusSessions' // Active RADIUS sessions
|
'getRadiusSessions' // Active RADIUS sessions
|
||||||
'getRadiusClients' // List NAS clients
|
'getRadiusClients' // List NAS clients
|
||||||
@@ -1225,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
|
||||||
@@ -1332,12 +1530,13 @@ const router = new DcRouter(options: IDcRouterOptions);
|
|||||||
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
||||||
| `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 |
|
||||||
| `storageManager` | `StorageManager` | Storage backend |
|
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
|
||||||
| `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
|
||||||
|
|
||||||
@@ -1403,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
|
||||||
|
|
||||||
@@ -1458,6 +1658,7 @@ The container exposes all service ports:
|
|||||||
| 1812, 1813 | UDP | RADIUS auth/acct |
|
| 1812, 1813 | UDP | RADIUS auth/acct |
|
||||||
| 3000 | TCP | OpsServer dashboard |
|
| 3000 | TCP | OpsServer dashboard |
|
||||||
| 8443 | TCP | Remote ingress tunnels |
|
| 8443 | TCP | Remote ingress tunnels |
|
||||||
|
| 51820 | UDP | WireGuard VPN |
|
||||||
| 29000–30000 | TCP | Dynamic port range |
|
| 29000–30000 | TCP | Dynamic port range |
|
||||||
|
|
||||||
### Building the Image
|
### Building the Image
|
||||||
|
|||||||
84
readme.storage.md
Normal file
84
readme.storage.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# DCRouter Storage Overview
|
||||||
|
|
||||||
|
DCRouter uses a **unified database layer** backed by `@push.rocks/smartdata` for all persistent data. All data is stored as typed document classes in a single database.
|
||||||
|
|
||||||
|
## Database Modes
|
||||||
|
|
||||||
|
### Embedded Mode (default)
|
||||||
|
When no external MongoDB URL is provided, DCRouter starts an embedded `LocalSmartDb` (Rust-based MongoDB-compatible engine) via `@push.rocks/smartdb`.
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.serve.zone/dcrouter/tsmdb/
|
||||||
|
```
|
||||||
|
|
||||||
|
### External Mode
|
||||||
|
Connect to any MongoDB-compatible database by providing a connection URL.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dbConfig: {
|
||||||
|
mongoDbUrl: 'mongodb://host:27017',
|
||||||
|
dbName: 'dcrouter',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dbConfig: {
|
||||||
|
enabled: true, // default: true
|
||||||
|
mongoDbUrl: undefined, // default: embedded LocalSmartDb
|
||||||
|
storagePath: '~/.serve.zone/dcrouter/tsmdb', // default (embedded mode only)
|
||||||
|
dbName: 'dcrouter', // default
|
||||||
|
cleanupIntervalHours: 1, // TTL cleanup interval
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Document Classes
|
||||||
|
|
||||||
|
All data is stored as smartdata document classes in `ts/db/documents/`.
|
||||||
|
|
||||||
|
| Document Class | Collection | Unique Key | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `StoredRouteDoc` | storedRoutes | `id` | Programmatic routes (created via API) |
|
||||||
|
| `RouteOverrideDoc` | routeOverrides | `routeName` | Hardcoded route enable/disable overrides |
|
||||||
|
| `ApiTokenDoc` | apiTokens | `id` | API tokens (hashed secrets, scopes, expiry) |
|
||||||
|
| `VpnServerKeysDoc` | vpnServerKeys | `configId` (singleton) | VPN server Noise + WireGuard keypairs |
|
||||||
|
| `VpnClientDoc` | vpnClients | `clientId` | VPN client registrations |
|
||||||
|
| `AcmeCertDoc` | acmeCerts | `domainName` | ACME certificates and keys |
|
||||||
|
| `ProxyCertDoc` | proxyCerts | `domain` | SmartProxy TLS certificates |
|
||||||
|
| `CertBackoffDoc` | certBackoff | `domain` | Per-domain cert provision backoff state |
|
||||||
|
| `RemoteIngressEdgeDoc` | remoteIngressEdges | `id` | Edge node registrations |
|
||||||
|
| `VlanMappingsDoc` | vlanMappings | `configId` (singleton) | MAC-to-VLAN mapping table |
|
||||||
|
| `AccountingSessionDoc` | accountingSessions | `sessionId` | RADIUS accounting sessions |
|
||||||
|
| `CachedEmail` | cachedEmails | `id` | Email metadata (TTL: 30 days) |
|
||||||
|
| `CachedIPReputation` | cachedIPReputation | `ipAddress` | IP reputation results (TTL: 24 hours) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
DcRouterDb (singleton)
|
||||||
|
├── LocalSmartDb (embedded, Rust) ─── or ─── External MongoDB
|
||||||
|
└── SmartdataDb (ORM)
|
||||||
|
└── @Collection(() => getDb())
|
||||||
|
├── StoredRouteDoc
|
||||||
|
├── RouteOverrideDoc
|
||||||
|
├── ApiTokenDoc
|
||||||
|
├── VpnServerKeysDoc / VpnClientDoc
|
||||||
|
├── AcmeCertDoc / ProxyCertDoc / CertBackoffDoc
|
||||||
|
├── RemoteIngressEdgeDoc
|
||||||
|
├── VlanMappingsDoc / AccountingSessionDoc
|
||||||
|
├── CachedEmail (TTL)
|
||||||
|
└── CachedIPReputation (TTL)
|
||||||
|
```
|
||||||
|
|
||||||
|
### TTL Cleanup
|
||||||
|
|
||||||
|
`CacheCleaner` runs on a configurable interval (default: 1 hour) and removes expired documents where `expiresAt < now()`.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
For tests or lightweight deployments without persistence:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dbConfig: { enabled: false }
|
||||||
|
```
|
||||||
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();
|
||||||
@@ -130,7 +130,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
contactEmail: 'test@example.com'
|
contactEmail: 'test@example.com'
|
||||||
},
|
},
|
||||||
opsServerPort: 3104,
|
opsServerPort: 3104,
|
||||||
cacheConfig: {
|
dbConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
|
|||||||
routes: []
|
routes: []
|
||||||
},
|
},
|
||||||
opsServerPort: 3100,
|
opsServerPort: 3100,
|
||||||
cacheConfig: { enabled: false }
|
dbConfig: { enabled: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
await dcRouter.start();
|
await dcRouter.start();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
opsServerPort: 3102,
|
opsServerPort: 3102,
|
||||||
cacheConfig: { enabled: false },
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
opsServerPort: 3101,
|
opsServerPort: 3101,
|
||||||
cacheConfig: { enabled: false },
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
opsServerPort: 3103,
|
opsServerPort: 3103,
|
||||||
cacheConfig: { enabled: false },
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
|
|||||||
371
test/test.reference-resolver.ts
Normal file
371
test/test.reference-resolver.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||||
|
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers: access private maps for direct unit testing without DB
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||||
|
(resolver as any).profiles.set(profile.id, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void {
|
||||||
|
(resolver as any).targets.set(target.id, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProfile(overrides: Partial<ISourceProfile> = {}): ISourceProfile {
|
||||||
|
return {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: 'STANDARD',
|
||||||
|
description: 'Test profile',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8'],
|
||||||
|
maxConnections: 1000,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: 'test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTarget(overrides: Partial<INetworkTarget> = {}): INetworkTarget {
|
||||||
|
return {
|
||||||
|
id: 'target-1',
|
||||||
|
name: 'INFRA',
|
||||||
|
description: 'Test target',
|
||||||
|
host: '192.168.5.247',
|
||||||
|
port: 443,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: 'test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
||||||
|
return {
|
||||||
|
name: 'test-route',
|
||||||
|
match: { ports: 443, domains: 'test.example.com' },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'placeholder', port: 80 }] },
|
||||||
|
...overrides,
|
||||||
|
} as IRouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Resolution tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let resolver: ReferenceResolver;
|
||||||
|
|
||||||
|
tap.test('should create ReferenceResolver instance', async () => {
|
||||||
|
resolver = new ReferenceResolver();
|
||||||
|
expect(resolver).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should list empty profiles and targets initially', async () => {
|
||||||
|
expect(resolver.listProfiles()).toBeArray();
|
||||||
|
expect(resolver.listProfiles().length).toEqual(0);
|
||||||
|
expect(resolver.listTargets()).toBeArray();
|
||||||
|
expect(resolver.listTargets().length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Source profile resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve source profile onto a route', async () => {
|
||||||
|
const profile = makeProfile();
|
||||||
|
injectProfile(resolver, profile);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
expect(result.route.security).toBeTruthy();
|
||||||
|
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!.maxConnections).toEqual(1000);
|
||||||
|
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||||
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should merge inline route security with profile security', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['127.0.0.1'],
|
||||||
|
maxConnections: 5000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// IP lists are unioned
|
||||||
|
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('127.0.0.1');
|
||||||
|
|
||||||
|
// Inline maxConnections overrides profile
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should deduplicate IP lists during merge', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
|
||||||
|
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
|
||||||
|
expect(count).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle missing profile gracefully', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route should be unchanged
|
||||||
|
expect(result.route.security).toBeUndefined();
|
||||||
|
expect(result.metadata.sourceProfileName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Profile inheritance ----
|
||||||
|
|
||||||
|
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||||
|
const baseProfile = makeProfile({
|
||||||
|
id: 'base-profile',
|
||||||
|
name: 'BASE',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['10.0.0.0/8'],
|
||||||
|
maxConnections: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
injectProfile(resolver, baseProfile);
|
||||||
|
|
||||||
|
const extendedProfile = makeProfile({
|
||||||
|
id: 'extended-profile',
|
||||||
|
name: 'EXTENDED',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['160.79.104.0/21'],
|
||||||
|
},
|
||||||
|
extendsProfiles: ['base-profile'],
|
||||||
|
});
|
||||||
|
injectProfile(resolver, extendedProfile);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Should have IPs from both base and extended profiles
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||||
|
// maxConnections from base (extended doesn't override)
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(500);
|
||||||
|
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should detect circular profile inheritance', async () => {
|
||||||
|
const profileA = makeProfile({
|
||||||
|
id: 'circular-a',
|
||||||
|
name: 'A',
|
||||||
|
security: { ipAllowList: ['1.1.1.1'] },
|
||||||
|
extendsProfiles: ['circular-b'],
|
||||||
|
});
|
||||||
|
const profileB = makeProfile({
|
||||||
|
id: 'circular-b',
|
||||||
|
name: 'B',
|
||||||
|
security: { ipAllowList: ['2.2.2.2'] },
|
||||||
|
extendsProfiles: ['circular-a'],
|
||||||
|
});
|
||||||
|
injectProfile(resolver, profileA);
|
||||||
|
injectProfile(resolver, profileB);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
|
||||||
|
|
||||||
|
// Should not infinite loop — resolves what it can
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
expect(result.route.security).toBeTruthy();
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Network target resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve network target onto a route', async () => {
|
||||||
|
const target = makeTarget();
|
||||||
|
injectTarget(resolver, target);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { networkTargetRef: 'target-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
expect(result.route.action.targets).toBeTruthy();
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||||
|
expect(result.route.action.targets![0].port).toEqual(443);
|
||||||
|
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||||
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle missing target gracefully', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route targets should be unchanged (still the placeholder)
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||||
|
expect(result.metadata.networkTargetName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Combined resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = {
|
||||||
|
sourceProfileRef: 'profile-1',
|
||||||
|
networkTargetRef: 'target-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Security from profile
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||||
|
|
||||||
|
// Target from network target
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||||
|
expect(result.route.action.targets![0].port).toEqual(443);
|
||||||
|
|
||||||
|
// Both names recorded
|
||||||
|
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||||
|
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should skip resolution when no metadata refs', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: { ipAllowList: ['1.2.3.4'] },
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = {};
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route should be completely unchanged
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
|
||||||
|
expect(result.route.security!.ipAllowList!.length).toEqual(1);
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = {
|
||||||
|
sourceProfileRef: 'profile-1',
|
||||||
|
networkTargetRef: 'target-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = resolver.resolveRoute(route, metadata);
|
||||||
|
const second = resolver.resolveRoute(first.route, first.metadata);
|
||||||
|
|
||||||
|
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
|
||||||
|
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
|
||||||
|
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Lookup helpers ----
|
||||||
|
|
||||||
|
tap.test('should find routes by profile ref (sync)', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-a', {
|
||||||
|
id: 'route-a',
|
||||||
|
route: makeRoute({ name: 'route-a' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { sourceProfileRef: 'profile-1' },
|
||||||
|
});
|
||||||
|
storedRoutes.set('route-b', {
|
||||||
|
id: 'route-b',
|
||||||
|
route: makeRoute({ name: 'route-b' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
storedRoutes.set('route-c', {
|
||||||
|
id: 'route-c',
|
||||||
|
route: makeRoute({ name: 'route-c' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||||
|
expect(profileRefs.length).toEqual(2);
|
||||||
|
expect(profileRefs).toContain('route-a');
|
||||||
|
expect(profileRefs).toContain('route-c');
|
||||||
|
|
||||||
|
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||||
|
expect(targetRefs.length).toEqual(2);
|
||||||
|
expect(targetRefs).toContain('route-b');
|
||||||
|
expect(targetRefs).toContain('route-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-x', {
|
||||||
|
id: 'route-x',
|
||||||
|
route: makeRoute({ name: 'my-route' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { sourceProfileRef: 'profile-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||||
|
expect(usage.length).toEqual(1);
|
||||||
|
expect(usage[0].id).toEqual('route-x');
|
||||||
|
expect(usage[0].routeName).toEqual('my-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get target usage for a specific target ID', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-y', {
|
||||||
|
id: 'route-y',
|
||||||
|
route: makeRoute({ name: 'other-route' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||||
|
expect(usage.length).toEqual(1);
|
||||||
|
expect(usage[0].id).toEqual('route-y');
|
||||||
|
expect(usage[0].routeName).toEqual('other-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Profile/target getters ----
|
||||||
|
|
||||||
|
tap.test('should get profile by name', async () => {
|
||||||
|
const profile = resolver.getProfileByName('STANDARD');
|
||||||
|
expect(profile).toBeTruthy();
|
||||||
|
expect(profile!.id).toEqual('profile-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get target by name', async () => {
|
||||||
|
const target = resolver.getTargetByName('INFRA');
|
||||||
|
expect(target).toBeTruthy();
|
||||||
|
expect(target!.id).toEqual('target-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return undefined for nonexistent profile name', async () => {
|
||||||
|
const profile = resolver.getProfileByName('NONEXISTENT');
|
||||||
|
expect(profile).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return undefined for nonexistent target name', async () => {
|
||||||
|
const target = resolver.getTargetByName('NONEXISTENT');
|
||||||
|
expect(target).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
208
test/test.source-profiles-api.ts
Normal file
208
test/test.source-profiles-api.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
const TEST_PORT = 3200;
|
||||||
|
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Setup — db disabled, handlers return graceful fallbacks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
opsServerPort: TEST_PORT,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
TEST_URL,
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Source Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should return empty profiles list when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSourceProfiles'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.profiles).toBeArray();
|
||||||
|
expect(response.profiles.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return null for single profile when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfile>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSourceProfile'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.profile).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return failure for create profile when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_CreateSourceProfile>(
|
||||||
|
TEST_URL,
|
||||||
|
'createSourceProfile'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
name: 'TEST',
|
||||||
|
security: { ipAllowList: ['*'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toBeFalse();
|
||||||
|
expect(response.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return empty profile usage when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfileUsage>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSourceProfileUsage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.routes).toBeArray();
|
||||||
|
expect(response.routes.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network Target endpoints (graceful fallbacks when resolver unavailable)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should return empty targets list when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargets'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.targets).toBeArray();
|
||||||
|
expect(response.targets.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return null for single target when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTarget>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTarget'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.target).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return failure for create target when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||||
|
TEST_URL,
|
||||||
|
'createNetworkTarget'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
name: 'TEST',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 443,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toBeFalse();
|
||||||
|
expect(response.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return empty target usage when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargetUsage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.routes).toBeArray();
|
||||||
|
expect(response.routes.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth rejection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated profile requests', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSourceProfiles'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.fire({} as any);
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated target requests', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargets'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.fire({} as any);
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
import * as paths from '../ts/paths.js';
|
|
||||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// Test data
|
|
||||||
const testData = {
|
|
||||||
string: 'Hello, World!',
|
|
||||||
json: { name: 'test', value: 42, nested: { data: true } },
|
|
||||||
largeString: 'x'.repeat(10000)
|
|
||||||
};
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Memory Backend', async () => {
|
|
||||||
// Create StorageManager without config (defaults to memory)
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Test basic get/set
|
|
||||||
await storage.set('/test/key', testData.string);
|
|
||||||
const value = await storage.get('/test/key');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Test JSON helpers
|
|
||||||
await storage.setJSON('/test/json', testData.json);
|
|
||||||
const jsonValue = await storage.getJSON('/test/json');
|
|
||||||
expect(jsonValue).toEqual(testData.json);
|
|
||||||
|
|
||||||
// Test exists
|
|
||||||
expect(await storage.exists('/test/key')).toEqual(true);
|
|
||||||
expect(await storage.exists('/nonexistent')).toEqual(false);
|
|
||||||
|
|
||||||
// Test delete
|
|
||||||
await storage.delete('/test/key');
|
|
||||||
expect(await storage.exists('/test/key')).toEqual(false);
|
|
||||||
|
|
||||||
// Test list
|
|
||||||
await storage.set('/items/1', 'one');
|
|
||||||
await storage.set('/items/2', 'two');
|
|
||||||
await storage.set('/other/3', 'three');
|
|
||||||
|
|
||||||
const items = await storage.list('/items');
|
|
||||||
expect(items.length).toEqual(2);
|
|
||||||
expect(items).toContain('/items/1');
|
|
||||||
expect(items).toContain('/items/2');
|
|
||||||
|
|
||||||
// Verify memory backend
|
|
||||||
expect(storage.getBackend()).toEqual('memory');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Filesystem Backend', async () => {
|
|
||||||
const testDir = path.join(paths.dataDir, '.test-storage');
|
|
||||||
|
|
||||||
// Clean up test directory if it exists
|
|
||||||
try {
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Create StorageManager with filesystem path
|
|
||||||
const storage = new StorageManager({ fsPath: testDir });
|
|
||||||
|
|
||||||
// Test basic operations
|
|
||||||
await storage.set('/test/file', testData.string);
|
|
||||||
const value = await storage.get('/test/file');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Verify file exists on disk
|
|
||||||
const filePath = path.join(testDir, 'test', 'file');
|
|
||||||
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
|
||||||
expect(fileExists).toEqual(true);
|
|
||||||
|
|
||||||
// Test atomic writes (temp file should not exist)
|
|
||||||
const tempPath = filePath + '.tmp';
|
|
||||||
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
|
||||||
expect(tempExists).toEqual(false);
|
|
||||||
|
|
||||||
// Test nested paths
|
|
||||||
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
|
||||||
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
|
||||||
expect(nestedValue).toEqual(testData.largeString);
|
|
||||||
|
|
||||||
// Test list with filesystem
|
|
||||||
await storage.set('/fs/items/a', 'alpha');
|
|
||||||
await storage.set('/fs/items/b', 'beta');
|
|
||||||
await storage.set('/fs/other/c', 'gamma');
|
|
||||||
|
|
||||||
// Filesystem backend now properly supports list
|
|
||||||
const fsItems = await storage.list('/fs/items');
|
|
||||||
expect(fsItems.length).toEqual(2); // Should find both items
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Custom Function Backend', async () => {
|
|
||||||
// Create in-memory storage for custom functions
|
|
||||||
const customStore = new Map<string, string>();
|
|
||||||
|
|
||||||
const storage = new StorageManager({
|
|
||||||
readFunction: async (key: string) => {
|
|
||||||
return customStore.get(key) || null;
|
|
||||||
},
|
|
||||||
writeFunction: async (key: string, value: string) => {
|
|
||||||
customStore.set(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test basic operations
|
|
||||||
await storage.set('/custom/key', testData.string);
|
|
||||||
expect(customStore.has('/custom/key')).toEqual(true);
|
|
||||||
|
|
||||||
const value = await storage.get('/custom/key');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Test that delete sets empty value (as per implementation)
|
|
||||||
await storage.delete('/custom/key');
|
|
||||||
expect(customStore.get('/custom/key')).toEqual('');
|
|
||||||
|
|
||||||
// Verify custom backend (filesystem is implemented as custom backend internally)
|
|
||||||
expect(storage.getBackend()).toEqual('custom');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Key Validation', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Test key normalization
|
|
||||||
await storage.set('test/key', 'value1'); // Missing leading slash
|
|
||||||
const value1 = await storage.get('/test/key');
|
|
||||||
expect(value1).toEqual('value1');
|
|
||||||
|
|
||||||
// Test dangerous path elements are removed
|
|
||||||
await storage.set('/test/../danger/key', 'value2');
|
|
||||||
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
|
||||||
expect(value2).toEqual('value2');
|
|
||||||
|
|
||||||
// Test multiple slashes are normalized
|
|
||||||
await storage.set('/test///multiple////slashes', 'value3');
|
|
||||||
const value3 = await storage.get('/test/multiple/slashes');
|
|
||||||
expect(value3).toEqual('value3');
|
|
||||||
|
|
||||||
// Test invalid keys throw errors
|
|
||||||
let emptyKeyError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set('', 'value');
|
|
||||||
} catch (error) {
|
|
||||||
emptyKeyError = error as Error;
|
|
||||||
}
|
|
||||||
expect(emptyKeyError).toBeTruthy();
|
|
||||||
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
|
||||||
|
|
||||||
let nullKeyError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set(null as any, 'value');
|
|
||||||
} catch (error) {
|
|
||||||
nullKeyError = error as Error;
|
|
||||||
}
|
|
||||||
expect(nullKeyError).toBeTruthy();
|
|
||||||
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Concurrent Access', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
const promises: Promise<void>[] = [];
|
|
||||||
|
|
||||||
// Simulate concurrent writes
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
// Verify all writes succeeded
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
const value = await storage.get(`/concurrent/key${i}`);
|
|
||||||
expect(value).toEqual(`value${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test concurrent reads
|
|
||||||
const readPromises: Promise<string | null>[] = [];
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
readPromises.push(storage.get(`/concurrent/key${i}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(readPromises);
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
expect(results[i]).toEqual(`value${i}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Backend Priority', async () => {
|
|
||||||
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
|
||||||
|
|
||||||
// Test that custom functions take priority over fsPath
|
|
||||||
let warningLogged = false;
|
|
||||||
const originalWarn = console.warn;
|
|
||||||
console.warn = (message: string) => {
|
|
||||||
if (message.includes('Using custom read/write functions')) {
|
|
||||||
warningLogged = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const storage = new StorageManager({
|
|
||||||
fsPath: testDir,
|
|
||||||
readFunction: async () => 'custom-value',
|
|
||||||
writeFunction: async () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.warn = originalWarn;
|
|
||||||
|
|
||||||
expect(warningLogged).toEqual(true);
|
|
||||||
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
try {
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Error Handling', async () => {
|
|
||||||
// Test filesystem errors
|
|
||||||
const storage = new StorageManager({
|
|
||||||
readFunction: async () => {
|
|
||||||
throw new Error('Read error');
|
|
||||||
},
|
|
||||||
writeFunction: async () => {
|
|
||||||
throw new Error('Write error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read errors should return null
|
|
||||||
const value = await storage.get('/error/key');
|
|
||||||
expect(value).toEqual(null);
|
|
||||||
|
|
||||||
// Write errors should propagate
|
|
||||||
let writeError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set('/error/key', 'value');
|
|
||||||
} catch (error) {
|
|
||||||
writeError = error as Error;
|
|
||||||
}
|
|
||||||
expect(writeError).toBeTruthy();
|
|
||||||
expect(writeError?.message).toEqual('Write error');
|
|
||||||
|
|
||||||
// Test JSON parse errors
|
|
||||||
const jsonStorage = new StorageManager({
|
|
||||||
readFunction: async () => 'invalid json',
|
|
||||||
writeFunction: async () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test JSON parse errors
|
|
||||||
let jsonError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await jsonStorage.getJSON('/invalid/json');
|
|
||||||
} catch (error) {
|
|
||||||
jsonError = error as Error;
|
|
||||||
}
|
|
||||||
expect(jsonError).toBeTruthy();
|
|
||||||
expect(jsonError?.message).toContain('JSON');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - List Operations', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Populate storage with hierarchical data
|
|
||||||
await storage.set('/app/config/database', 'db-config');
|
|
||||||
await storage.set('/app/config/cache', 'cache-config');
|
|
||||||
await storage.set('/app/data/users/1', 'user1');
|
|
||||||
await storage.set('/app/data/users/2', 'user2');
|
|
||||||
await storage.set('/app/logs/error.log', 'errors');
|
|
||||||
|
|
||||||
// List root
|
|
||||||
const rootItems = await storage.list('/');
|
|
||||||
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
|
||||||
|
|
||||||
// List specific paths
|
|
||||||
const configItems = await storage.list('/app/config');
|
|
||||||
expect(configItems.length).toEqual(2);
|
|
||||||
expect(configItems).toContain('/app/config/database');
|
|
||||||
expect(configItems).toContain('/app/config/cache');
|
|
||||||
|
|
||||||
const userItems = await storage.list('/app/data/users');
|
|
||||||
expect(userItems.length).toEqual(2);
|
|
||||||
|
|
||||||
// List non-existent path
|
|
||||||
const emptyList = await storage.list('/nonexistent/path');
|
|
||||||
expect(emptyList.length).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DcRouter } from '../ts/index.js';
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
const devRouter = new DcRouter({
|
const devRouter = new DcRouter({
|
||||||
|
// Server public IP (used for VPN AllowedIPs)
|
||||||
|
publicIp: '203.0.113.1',
|
||||||
// SmartProxy routes for development/demo
|
// SmartProxy routes for development/demo
|
||||||
smartProxyConfig: {
|
smartProxyConfig: {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -23,10 +25,31 @@ const devRouter = new DcRouter({
|
|||||||
tls: { mode: 'passthrough' },
|
tls: { mode: 'passthrough' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-internal-app',
|
||||||
|
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||||
|
vpnOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-eng-dashboard',
|
||||||
|
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||||
|
vpnOnly: true,
|
||||||
|
},
|
||||||
|
] as any[],
|
||||||
|
},
|
||||||
|
// VPN with pre-defined clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.dev.local',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', description: 'Developer laptop' },
|
||||||
|
{ clientId: 'ci-runner', description: 'CI/CD pipeline' },
|
||||||
|
{ clientId: 'admin-desktop', description: 'Admin workstation' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Disable cache/mongo for dev
|
dbConfig: { enabled: true },
|
||||||
cacheConfig: { 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: '11.13.0',
|
version: '13.9.1',
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
155
ts/cache/classes.cachedb.ts
vendored
155
ts/cache/classes.cachedb.ts
vendored
@@ -1,155 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { defaultTsmDbPath } from '../paths.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for CacheDb
|
|
||||||
*/
|
|
||||||
export interface ICacheDbOptions {
|
|
||||||
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
|
||||||
storagePath?: string;
|
|
||||||
/** Database name (default: dcrouter) */
|
|
||||||
dbName?: string;
|
|
||||||
/** Enable debug logging */
|
|
||||||
debug?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CacheDb - Wrapper around LocalSmartDb and smartdata
|
|
||||||
*
|
|
||||||
* Provides persistent caching using smartdata as the ORM layer
|
|
||||||
* and LocalSmartDb as the embedded database engine.
|
|
||||||
*/
|
|
||||||
export class CacheDb {
|
|
||||||
private static instance: CacheDb | null = null;
|
|
||||||
|
|
||||||
private localSmartDb!: plugins.smartdb.LocalSmartDb;
|
|
||||||
private smartdataDb!: plugins.smartdata.SmartdataDb;
|
|
||||||
private options: Required<ICacheDbOptions>;
|
|
||||||
private isStarted: boolean = false;
|
|
||||||
|
|
||||||
constructor(options: ICacheDbOptions = {}) {
|
|
||||||
this.options = {
|
|
||||||
storagePath: options.storagePath || defaultTsmDbPath,
|
|
||||||
dbName: options.dbName || 'dcrouter',
|
|
||||||
debug: options.debug || false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create the singleton instance
|
|
||||||
*/
|
|
||||||
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
|
||||||
if (!CacheDb.instance) {
|
|
||||||
CacheDb.instance = new CacheDb(options);
|
|
||||||
}
|
|
||||||
return CacheDb.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the singleton instance (useful for testing)
|
|
||||||
*/
|
|
||||||
public static resetInstance(): void {
|
|
||||||
CacheDb.instance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the cache database
|
|
||||||
* - Initializes LocalSmartDb with file persistence
|
|
||||||
* - Connects smartdata to the LocalSmartDb via Unix socket
|
|
||||||
*/
|
|
||||||
public async start(): Promise<void> {
|
|
||||||
if (this.isStarted) {
|
|
||||||
logger.log('warn', 'CacheDb already started');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure storage directory exists
|
|
||||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
|
||||||
|
|
||||||
// Create LocalSmartDb instance
|
|
||||||
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
|
|
||||||
folderPath: this.options.storagePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start LocalSmartDb and get connection info
|
|
||||||
const connectionInfo = await this.localSmartDb.start();
|
|
||||||
|
|
||||||
if (this.options.debug) {
|
|
||||||
logger.log('debug', `LocalSmartDb started with URI: ${connectionInfo.connectionUri}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize smartdata with the connection URI
|
|
||||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
|
||||||
mongoDbUrl: connectionInfo.connectionUri,
|
|
||||||
mongoDbName: this.options.dbName,
|
|
||||||
});
|
|
||||||
await this.smartdataDb.init();
|
|
||||||
|
|
||||||
this.isStarted = true;
|
|
||||||
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Failed to start CacheDb: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the cache database
|
|
||||||
*/
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
if (!this.isStarted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Close smartdata connection
|
|
||||||
if (this.smartdataDb) {
|
|
||||||
await this.smartdataDb.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop LocalSmartDb
|
|
||||||
if (this.localSmartDb) {
|
|
||||||
await this.localSmartDb.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isStarted = false;
|
|
||||||
logger.log('info', 'CacheDb stopped');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Error stopping CacheDb: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the smartdata database instance
|
|
||||||
*/
|
|
||||||
public getDb(): plugins.smartdata.SmartdataDb {
|
|
||||||
if (!this.isStarted) {
|
|
||||||
throw new Error('CacheDb not started. Call start() first.');
|
|
||||||
}
|
|
||||||
return this.smartdataDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the database is ready
|
|
||||||
*/
|
|
||||||
public isReady(): boolean {
|
|
||||||
return this.isStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the storage path
|
|
||||||
*/
|
|
||||||
public getStoragePath(): string {
|
|
||||||
return this.options.storagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the database name
|
|
||||||
*/
|
|
||||||
public getDbName(): string {
|
|
||||||
return this.options.dbName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
ts/cache/documents/index.ts
vendored
2
ts/cache/documents/index.ts
vendored
@@ -1,2 +0,0 @@
|
|||||||
export * from './classes.cached.email.js';
|
|
||||||
export * from './classes.cached.ip.reputation.js';
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { StorageManager } from './storage/index.js';
|
import { CertBackoffDoc } from './db/index.js';
|
||||||
|
|
||||||
interface IBackoffEntry {
|
interface IBackoffEntry {
|
||||||
failures: number;
|
failures: number;
|
||||||
@@ -10,54 +10,68 @@ interface IBackoffEntry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages certificate provisioning scheduling with:
|
* Manages certificate provisioning scheduling with:
|
||||||
* - Per-domain exponential backoff persisted in StorageManager
|
* - Per-domain exponential backoff persisted via CertBackoffDoc
|
||||||
*
|
*
|
||||||
* Note: Serial stagger queue was removed — smartacme v9 handles
|
* Note: Serial stagger queue was removed — smartacme v9 handles
|
||||||
* concurrency, per-domain dedup, and rate limiting internally.
|
* concurrency, per-domain dedup, and rate limiting internally.
|
||||||
*/
|
*/
|
||||||
export class CertProvisionScheduler {
|
export class CertProvisionScheduler {
|
||||||
private storageManager: StorageManager;
|
|
||||||
private maxBackoffHours: number;
|
private maxBackoffHours: number;
|
||||||
|
|
||||||
// In-memory backoff cache (mirrors storage for fast lookups)
|
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||||
private backoffCache = new Map<string, IBackoffEntry>();
|
private backoffCache = new Map<string, IBackoffEntry>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
storageManager: StorageManager,
|
|
||||||
options?: { maxBackoffHours?: number }
|
options?: { maxBackoffHours?: number }
|
||||||
) {
|
) {
|
||||||
this.storageManager = storageManager;
|
|
||||||
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage key for a domain's backoff entry
|
* Sanitized domain key for storage lookups
|
||||||
*/
|
*/
|
||||||
private backoffKey(domain: string): string {
|
private sanitizeDomain(domain: string): string {
|
||||||
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
return `/cert-backoff/${clean}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load backoff entry from storage (with in-memory cache)
|
* Load backoff entry from database (with in-memory cache)
|
||||||
*/
|
*/
|
||||||
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
||||||
const cached = this.backoffCache.get(domain);
|
const cached = this.backoffCache.get(domain);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
const sanitized = this.sanitizeDomain(domain);
|
||||||
if (entry) {
|
const doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||||
|
if (doc) {
|
||||||
|
const entry: IBackoffEntry = {
|
||||||
|
failures: doc.failures,
|
||||||
|
lastFailure: doc.lastFailure,
|
||||||
|
retryAfter: doc.retryAfter,
|
||||||
|
lastError: doc.lastError,
|
||||||
|
};
|
||||||
this.backoffCache.set(domain, entry);
|
this.backoffCache.set(domain, entry);
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
return entry;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save backoff entry to both cache and storage
|
* Save backoff entry to both cache and database
|
||||||
*/
|
*/
|
||||||
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||||
this.backoffCache.set(domain, entry);
|
this.backoffCache.set(domain, entry);
|
||||||
await this.storageManager.setJSON(this.backoffKey(domain), entry);
|
const sanitized = this.sanitizeDomain(domain);
|
||||||
|
let doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||||
|
if (!doc) {
|
||||||
|
doc = new CertBackoffDoc();
|
||||||
|
doc.domain = sanitized;
|
||||||
|
}
|
||||||
|
doc.failures = entry.failures;
|
||||||
|
doc.lastFailure = entry.lastFailure;
|
||||||
|
doc.retryAfter = entry.retryAfter;
|
||||||
|
doc.lastError = entry.lastError || '';
|
||||||
|
await doc.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,9 +121,13 @@ export class CertProvisionScheduler {
|
|||||||
async clearBackoff(domain: string): Promise<void> {
|
async clearBackoff(domain: string): Promise<void> {
|
||||||
this.backoffCache.delete(domain);
|
this.backoffCache.delete(domain);
|
||||||
try {
|
try {
|
||||||
await this.storageManager.delete(this.backoffKey(domain));
|
const sanitized = this.sanitizeDomain(domain);
|
||||||
|
const doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||||
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore delete errors (key may not exist)
|
// Ignore delete errors (doc may not exist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,24 @@ import {
|
|||||||
type IEmailDomainConfig,
|
type IEmailDomainConfig,
|
||||||
} from '@push.rocks/smartmta';
|
} from '@push.rocks/smartmta';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
// Import storage manager
|
|
||||||
import { StorageManager, type IStorageConfig } from './storage/index.js';
|
|
||||||
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
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 cache system
|
// Import unified database
|
||||||
import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/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 } 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 */
|
||||||
@@ -115,44 +118,27 @@ 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 */
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Storage configuration */
|
|
||||||
storage?: IStorageConfig;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache database configuration using smartdata and LocalTsmDb
|
* Unified database configuration.
|
||||||
* Provides persistent caching for emails, IP reputation, bounces, etc.
|
* All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata.
|
||||||
|
* If mongoDbUrl is provided, connects to external MongoDB.
|
||||||
|
* Otherwise, starts an embedded LocalSmartDb automatically.
|
||||||
*/
|
*/
|
||||||
cacheConfig?: {
|
dbConfig?: {
|
||||||
/** Enable cache database (default: true) */
|
/** Enable database (default: true). Set to false in tests to skip DB startup. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
/** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */
|
||||||
|
mongoDbUrl?: string;
|
||||||
|
/** Storage path for embedded database data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||||
storagePath?: string;
|
storagePath?: string;
|
||||||
/** Database name (default: dcrouter) */
|
/** Database name (default: dcrouter) */
|
||||||
dbName?: string;
|
dbName?: string;
|
||||||
/** Default TTL in days for cached items (default: 30) */
|
/** Cache cleanup interval in hours (default: 1) */
|
||||||
defaultTTLDays?: number;
|
|
||||||
/** Cleanup interval in hours (default: 1) */
|
|
||||||
cleanupIntervalHours?: number;
|
cleanupIntervalHours?: number;
|
||||||
/** TTL configuration per data type (in days) */
|
/** Seed default security profiles and network targets when DB is empty on first startup. */
|
||||||
ttlConfig?: {
|
seedOnEmpty?: boolean;
|
||||||
/** Email cache TTL (default: 30 days) */
|
/** Custom seed data for profiles and targets (overrides built-in defaults). */
|
||||||
emails?: number;
|
seedData?: import('./config/classes.db-seeder.js').ISeedData;
|
||||||
/** IP reputation cache TTL (default: 1 day) */
|
|
||||||
ipReputation?: number;
|
|
||||||
/** Bounce records TTL (default: 30 days) */
|
|
||||||
bounces?: number;
|
|
||||||
/** DKIM keys TTL (default: 90 days) */
|
|
||||||
dkimKeys?: number;
|
|
||||||
/** Suppression list TTL (default: 30 days, can be permanent) */
|
|
||||||
suppression?: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,8 +178,8 @@ export interface IDcRouterOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* VPN server configuration.
|
* VPN server configuration.
|
||||||
* Enables VPN-based access control: routes with vpn.required 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) */
|
||||||
@@ -206,8 +192,32 @@ export interface IDcRouterOptions {
|
|||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||||
serverEndpoint?: string;
|
serverEndpoint?: string;
|
||||||
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
/** Pre-defined VPN clients created on startup */
|
||||||
forwardingMode?: 'tun' | 'socket';
|
clients?: Array<{
|
||||||
|
clientId: string;
|
||||||
|
targetProfileIds?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
/** Destination routing policy for VPN client traffic.
|
||||||
|
* Default in socket mode: { default: 'forceTarget', target: '127.0.0.1' } (all traffic → SmartProxy).
|
||||||
|
* Default in tun mode: not set (all traffic passes through). */
|
||||||
|
destinationPolicy?: {
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
target?: string;
|
||||||
|
allowList?: string[];
|
||||||
|
blockList?: string[];
|
||||||
|
};
|
||||||
|
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
|
||||||
|
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
|
||||||
|
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
/** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */
|
||||||
|
bridgeLanSubnet?: string;
|
||||||
|
/** Physical network interface for bridge mode (auto-detected if omitted) */
|
||||||
|
bridgePhysicalInterface?: string;
|
||||||
|
/** Start of VPN client IP range in LAN subnet (host offset, default: 200) */
|
||||||
|
bridgeIpRangeStart?: number;
|
||||||
|
/** End of VPN client IP range in LAN subnet (host offset, default: 250) */
|
||||||
|
bridgeIpRangeEnd?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,12 +245,20 @@ export class DcRouter {
|
|||||||
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
|
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
|
||||||
public emailServer?: UnifiedEmailServer;
|
public emailServer?: UnifiedEmailServer;
|
||||||
public radiusServer?: RadiusServer;
|
public radiusServer?: RadiusServer;
|
||||||
public storageManager: StorageManager;
|
|
||||||
public opsServer!: OpsServer;
|
public opsServer!: OpsServer;
|
||||||
public metricsManager?: MetricsManager;
|
public metricsManager?: MetricsManager;
|
||||||
|
|
||||||
// Cache system (smartdata + LocalTsmDb)
|
// Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set()
|
||||||
public cacheDb?: CacheDb;
|
public storageManager: any = {
|
||||||
|
get: async (_key: string) => null,
|
||||||
|
set: async (_key: string, _value: string) => {
|
||||||
|
// DKIM keys from smartmta — logged but not yet migrated to smartdata
|
||||||
|
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
||||||
|
public dcRouterDb?: DcRouterDb;
|
||||||
public cacheCleaner?: CacheCleaner;
|
public cacheCleaner?: CacheCleaner;
|
||||||
|
|
||||||
// Remote Ingress
|
// Remote Ingress
|
||||||
@@ -253,6 +271,14 @@ export class DcRouter {
|
|||||||
// Programmatic config API
|
// Programmatic config API
|
||||||
public routeConfigManager?: RouteConfigManager;
|
public routeConfigManager?: RouteConfigManager;
|
||||||
public apiTokenManager?: ApiTokenManager;
|
public apiTokenManager?: ApiTokenManager;
|
||||||
|
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;
|
||||||
@@ -299,16 +325,6 @@ export class DcRouter {
|
|||||||
// Resolve all data paths from baseDir
|
// Resolve all data paths from baseDir
|
||||||
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
||||||
|
|
||||||
// Default storage to filesystem if not configured
|
|
||||||
if (!this.options.storage) {
|
|
||||||
this.options.storage = {
|
|
||||||
fsPath: this.resolvedPaths.defaultStoragePath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize storage manager
|
|
||||||
this.storageManager = new StorageManager(this.options.storage);
|
|
||||||
|
|
||||||
// Initialize service manager and register all services
|
// Initialize service manager and register all services
|
||||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||||
name: 'dcrouter',
|
name: 'dcrouter',
|
||||||
@@ -337,23 +353,23 @@ export class DcRouter {
|
|||||||
.withRetry({ maxRetries: 0 }),
|
.withRetry({ maxRetries: 0 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// CacheDb: optional, no dependencies
|
// DcRouterDb: optional, no dependencies — unified database for all persistence
|
||||||
if (this.options.cacheConfig?.enabled !== false) {
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('CacheDb')
|
new plugins.taskbuffer.Service('DcRouterDb')
|
||||||
.optional()
|
.optional()
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
await this.setupCacheDb();
|
await this.setupDcRouterDb();
|
||||||
})
|
})
|
||||||
.withStop(async () => {
|
.withStop(async () => {
|
||||||
if (this.cacheCleaner) {
|
if (this.cacheCleaner) {
|
||||||
this.cacheCleaner.stop();
|
this.cacheCleaner.stop();
|
||||||
this.cacheCleaner = undefined;
|
this.cacheCleaner = undefined;
|
||||||
}
|
}
|
||||||
if (this.cacheDb) {
|
if (this.dcRouterDb) {
|
||||||
await this.cacheDb.stop();
|
await this.dcRouterDb.stop();
|
||||||
CacheDb.resetInstance();
|
DcRouterDb.resetInstance();
|
||||||
this.cacheDb = undefined;
|
this.dcRouterDb = undefined;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.withRetry({ maxRetries: 2, baseDelayMs: 1000, maxDelayMs: 5000 }),
|
.withRetry({ maxRetries: 2, baseDelayMs: 1000, maxDelayMs: 5000 }),
|
||||||
@@ -378,10 +394,57 @@ export class DcRouter {
|
|||||||
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
|
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// SmartProxy: critical, depends on CacheDb (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.cacheConfig?.enabled !== false) {
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
smartProxyDeps.push('CacheDb');
|
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')
|
||||||
@@ -400,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()
|
||||||
@@ -419,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();
|
||||||
}
|
}
|
||||||
@@ -442,29 +515,69 @@ export class DcRouter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigManagers: optional, depends on SmartProxy
|
// ConfigManagers: optional, depends on SmartProxy + DcRouterDb
|
||||||
this.serviceManager.addService(
|
// Requires DcRouterDb to be enabled (document classes need the database)
|
||||||
new plugins.taskbuffer.Service('ConfigManagers')
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
.optional()
|
this.serviceManager.addService(
|
||||||
.dependsOn('SmartProxy')
|
new plugins.taskbuffer.Service('ConfigManagers')
|
||||||
.withStart(async () => {
|
.optional()
|
||||||
this.routeConfigManager = new RouteConfigManager(
|
.dependsOn('SmartProxy', 'DcRouterDb')
|
||||||
this.storageManager,
|
.withStart(async () => {
|
||||||
() => this.getConstructorRoutes(),
|
// Initialize reference resolver first (profiles + targets)
|
||||||
() => this.smartProxy,
|
this.referenceResolver = new ReferenceResolver();
|
||||||
() => this.options.http3,
|
await this.referenceResolver.initialize();
|
||||||
() => this.options.vpnConfig?.enabled ? (this.options.vpnConfig.subnet || '10.8.0.0/24') : undefined,
|
|
||||||
);
|
// Initialize target profile manager
|
||||||
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
this.targetProfileManager = new TargetProfileManager();
|
||||||
await this.apiTokenManager.initialize();
|
await this.targetProfileManager.initialize();
|
||||||
await this.routeConfigManager.initialize();
|
|
||||||
})
|
this.routeConfigManager = new RouteConfigManager(
|
||||||
.withStop(async () => {
|
() => this.getConstructorRoutes(),
|
||||||
this.routeConfigManager = undefined;
|
() => this.smartProxy,
|
||||||
this.apiTokenManager = undefined;
|
() => this.options.http3,
|
||||||
})
|
this.options.vpnConfig?.enabled
|
||||||
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
|
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
|
||||||
);
|
if (!this.vpnManager || !this.targetProfileManager) {
|
||||||
|
// VPN not ready yet — deny all until re-apply after VPN starts
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.targetProfileManager.getMatchingClientIps(
|
||||||
|
route, routeId, this.vpnManager.listClients(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
this.referenceResolver,
|
||||||
|
// Sync merged routes to RemoteIngressManager whenever routes change,
|
||||||
|
// then push updated derived ports to the Rust hub binary
|
||||||
|
(routes) => {
|
||||||
|
if (this.remoteIngressManager) {
|
||||||
|
this.remoteIngressManager.setRoutes(routes as any[]);
|
||||||
|
}
|
||||||
|
if (this.tunnelManager) {
|
||||||
|
this.tunnelManager.syncAllowedEdges();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.apiTokenManager = new ApiTokenManager();
|
||||||
|
await this.apiTokenManager.initialize();
|
||||||
|
await this.routeConfigManager.initialize();
|
||||||
|
|
||||||
|
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||||
|
const seeder = new DbSeeder(this.referenceResolver);
|
||||||
|
await seeder.seedIfEmpty(
|
||||||
|
this.options.dbConfig?.seedOnEmpty,
|
||||||
|
this.options.dbConfig?.seedData,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.withStop(async () => {
|
||||||
|
this.routeConfigManager = undefined;
|
||||||
|
this.apiTokenManager = undefined;
|
||||||
|
this.referenceResolver = undefined;
|
||||||
|
this.targetProfileManager = undefined;
|
||||||
|
})
|
||||||
|
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Email Server: optional, depends on SmartProxy
|
// Email Server: optional, depends on SmartProxy
|
||||||
if (this.options.emailConfig) {
|
if (this.options.emailConfig) {
|
||||||
@@ -664,9 +777,8 @@ export class DcRouter {
|
|||||||
if (this.vpnManager && this.options.vpnConfig?.enabled) {
|
if (this.vpnManager && this.options.vpnConfig?.enabled) {
|
||||||
const subnet = this.vpnManager.getSubnet();
|
const subnet = this.vpnManager.getSubnet();
|
||||||
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
|
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
|
||||||
const mode = this.vpnManager.forwardingMode;
|
|
||||||
const clientCount = this.vpnManager.listClients().length;
|
const clientCount = this.vpnManager.listClients().length;
|
||||||
logger.log('info', `VPN Service: mode=${mode}, subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote Ingress summary
|
// Remote Ingress summary
|
||||||
@@ -676,14 +788,9 @@ export class DcRouter {
|
|||||||
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
|
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage summary
|
// Database summary
|
||||||
if (this.storageManager && this.options.storage) {
|
if (this.dcRouterDb) {
|
||||||
logger.log('info', `Storage: path=${this.options.storage.fsPath || 'default'}`);
|
logger.log('info', `Database: ${this.dcRouterDb.isEmbedded() ? 'embedded' : 'external'}, db=${this.dcRouterDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.dbConfig?.cleanupIntervalHours || 1)}h interval)`);
|
||||||
}
|
|
||||||
|
|
||||||
// Cache database summary
|
|
||||||
if (this.cacheDb) {
|
|
||||||
logger.log('info', `Cache Database: storage=${this.cacheDb.getStoragePath()}, db=${this.cacheDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service status summary from ServiceManager
|
// Service status summary from ServiceManager
|
||||||
@@ -704,31 +811,45 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up the cache database (smartdata + LocalTsmDb)
|
* Set up the unified database (smartdata + LocalSmartDb or external MongoDB)
|
||||||
*/
|
*/
|
||||||
private async setupCacheDb(): Promise<void> {
|
private async setupDcRouterDb(): Promise<void> {
|
||||||
logger.log('info', 'Setting up CacheDb...');
|
logger.log('info', 'Setting up DcRouterDb...');
|
||||||
|
|
||||||
const cacheConfig = this.options.cacheConfig || {};
|
const dbConfig = this.options.dbConfig || {};
|
||||||
|
|
||||||
// Initialize CacheDb singleton
|
// Initialize DcRouterDb singleton
|
||||||
this.cacheDb = CacheDb.getInstance({
|
this.dcRouterDb = DcRouterDb.getInstance({
|
||||||
storagePath: cacheConfig.storagePath || this.resolvedPaths.defaultTsmDbPath,
|
mongoDbUrl: dbConfig.mongoDbUrl,
|
||||||
dbName: cacheConfig.dbName || 'dcrouter',
|
storagePath: dbConfig.storagePath || this.resolvedPaths.defaultTsmDbPath,
|
||||||
|
dbName: dbConfig.dbName || 'dcrouter',
|
||||||
debug: false,
|
debug: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.cacheDb.start();
|
await this.dcRouterDb.start();
|
||||||
|
|
||||||
// Start the cache cleaner
|
// Run any pending data migrations before anything else reads from the DB.
|
||||||
const cleanupIntervalMs = (cacheConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
|
// This must complete before ConfigManagers loads profiles.
|
||||||
this.cacheCleaner = new CacheCleaner(this.cacheDb, {
|
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
|
||||||
|
const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
|
||||||
|
this.cacheCleaner = new CacheCleaner(this.dcRouterDb, {
|
||||||
intervalMs: cleanupIntervalMs,
|
intervalMs: cleanupIntervalMs,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
});
|
});
|
||||||
this.cacheCleaner.start();
|
this.cacheCleaner.start();
|
||||||
|
|
||||||
logger.log('info', `CacheDb initialized at ${this.cacheDb.getStoragePath()}`);
|
logger.log('info', `DcRouterDb ready (${this.dcRouterDb.isEmbedded() ? 'embedded' : 'external'})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -744,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,12 +934,8 @@ export class DcRouter {
|
|||||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
// VPN route security injection: restrict vpn.required routes to VPN subnet
|
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
|
||||||
if (this.options.vpnConfig?.enabled) {
|
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
|
||||||
routes = this.injectVpnSecurity(routes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache constructor routes for RouteConfigManager
|
|
||||||
this.constructorRoutes = [...routes];
|
this.constructorRoutes = [...routes];
|
||||||
|
|
||||||
// If we have routes or need a basic SmartProxy instance, create it
|
// If we have routes or need a basic SmartProxy instance, create it
|
||||||
@@ -835,14 +971,11 @@ export class DcRouter {
|
|||||||
acme: acmeConfig,
|
acme: acmeConfig,
|
||||||
certStore: {
|
certStore: {
|
||||||
loadAll: async () => {
|
loadAll: async () => {
|
||||||
const keys = await this.storageManager.list('/proxy-certs/');
|
const docs = await ProxyCertDoc.findAll();
|
||||||
const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = [];
|
const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = [];
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
const data = await this.storageManager.getJSON(key);
|
certs.push({ domain: doc.domain, publicKey: doc.publicKey, privateKey: doc.privateKey, ca: doc.ca });
|
||||||
if (data) {
|
loadedCertEntries.push({ domain: doc.domain, publicKey: doc.publicKey, validUntil: doc.validUntil, validFrom: doc.validFrom });
|
||||||
certs.push(data);
|
|
||||||
loadedCertEntries.push({ domain: data.domain, publicKey: data.publicKey, validUntil: data.validUntil, validFrom: data.validFrom });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return certs;
|
return certs;
|
||||||
},
|
},
|
||||||
@@ -854,18 +987,29 @@ export class DcRouter {
|
|||||||
validUntil = new Date(x509.validTo).getTime();
|
validUntil = new Date(x509.validTo).getTime();
|
||||||
validFrom = new Date(x509.validFrom).getTime();
|
validFrom = new Date(x509.validFrom).getTime();
|
||||||
} catch { /* PEM parsing failed */ }
|
} catch { /* PEM parsing failed */ }
|
||||||
await this.storageManager.setJSON(`/proxy-certs/${domain}`, {
|
let doc = await ProxyCertDoc.findByDomain(domain);
|
||||||
domain, publicKey, privateKey, ca, validUntil, validFrom,
|
if (!doc) {
|
||||||
});
|
doc = new ProxyCertDoc();
|
||||||
|
doc.domain = domain;
|
||||||
|
}
|
||||||
|
doc.publicKey = publicKey;
|
||||||
|
doc.privateKey = privateKey;
|
||||||
|
doc.ca = ca || '';
|
||||||
|
doc.validUntil = validUntil || 0;
|
||||||
|
doc.validFrom = validFrom || 0;
|
||||||
|
await doc.save();
|
||||||
},
|
},
|
||||||
remove: async (domain: string) => {
|
remove: async (domain: string) => {
|
||||||
await this.storageManager.delete(`/proxy-certs/${domain}`);
|
const doc = await ProxyCertDoc.findByDomain(domain);
|
||||||
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize cert provision scheduler
|
// Initialize cert provision scheduler
|
||||||
this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager);
|
this.certProvisionScheduler = new CertProvisionScheduler();
|
||||||
|
|
||||||
// If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction
|
// If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction
|
||||||
// Note: SmartAcme.start() is NOT called here — it runs as a separate optional service
|
// Note: SmartAcme.start() is NOT called here — it runs as a separate optional service
|
||||||
@@ -878,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(this.storageManager),
|
certManager: new StorageBackedCertManager(),
|
||||||
environment: 'production',
|
environment: dbAcme!.useProduction ? 'production' : 'integration',
|
||||||
challengeHandlers: challengeHandlers,
|
challengeHandlers: challengeHandlers,
|
||||||
challengePriority: ['dns-01'],
|
challengePriority: ['dns-01'],
|
||||||
});
|
});
|
||||||
@@ -950,19 +1096,14 @@ export class DcRouter {
|
|||||||
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// When VPN is in socket mode, the userspace NAT engine sends PP v2 headers
|
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||||
// on outbound connections to SmartProxy to preserve VPN client tunnel IPs.
|
|
||||||
if (this.options.vpnConfig?.enabled) {
|
if (this.options.vpnConfig?.enabled) {
|
||||||
const vpnForwardingMode = this.options.vpnConfig.forwardingMode
|
smartProxyConfig.acceptProxyProtocol = true;
|
||||||
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
|
if (!smartProxyConfig.proxyIPs) {
|
||||||
if (vpnForwardingMode === 'socket') {
|
smartProxyConfig.proxyIPs = [];
|
||||||
smartProxyConfig.acceptProxyProtocol = true;
|
}
|
||||||
if (!smartProxyConfig.proxyIPs) {
|
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||||
smartProxyConfig.proxyIPs = [];
|
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||||
}
|
|
||||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
|
||||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,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}`);
|
||||||
@@ -1027,16 +1162,19 @@ export class DcRouter {
|
|||||||
issuedAt = new Date(entry.validFrom).toISOString();
|
issuedAt = new Date(entry.validFrom).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try SmartAcme /certs/ metadata as secondary source
|
// Try SmartAcme AcmeCertDoc metadata as secondary source
|
||||||
if (!expiryDate) {
|
if (!expiryDate) {
|
||||||
try {
|
try {
|
||||||
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
|
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
|
||||||
const certMeta = await this.storageManager.getJSON(`/certs/${cleanDomain}`);
|
const domParts = cleanDomain.split('.');
|
||||||
if (certMeta?.validUntil) {
|
const baseDomain = domParts.length > 2 ? domParts.slice(-2).join('.') : cleanDomain;
|
||||||
expiryDate = new Date(certMeta.validUntil).toISOString();
|
const certDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||||
|
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||||
|
if (certDoc?.validUntil) {
|
||||||
|
expiryDate = new Date(certDoc.validUntil).toISOString();
|
||||||
}
|
}
|
||||||
if (certMeta?.created && !issuedAt) {
|
if (certDoc?.created && !issuedAt) {
|
||||||
issuedAt = new Date(certMeta.created).toISOString();
|
issuedAt = new Date(certDoc.created).toISOString();
|
||||||
}
|
}
|
||||||
} catch { /* no metadata available */ }
|
} catch { /* no metadata available */ }
|
||||||
}
|
}
|
||||||
@@ -1653,8 +1791,14 @@ export class DcRouter {
|
|||||||
this.registerDnsRecords(allRecords);
|
this.registerDnsRecords(allRecords);
|
||||||
logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`);
|
logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hand the DnsServer to DnsManager so DB-backed local records on
|
||||||
|
// dcrouter-hosted domains get registered too.
|
||||||
|
if (this.dnsManager && this.dnsServer) {
|
||||||
|
await this.dnsManager.attachDnsServer(this.dnsServer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create DNS socket handler for DoH
|
* Create DNS socket handler for DoH
|
||||||
*/
|
*/
|
||||||
@@ -2020,13 +2164,20 @@ export class DcRouter {
|
|||||||
logger.log('info', 'Setting up Remote Ingress hub...');
|
logger.log('info', 'Setting up Remote Ingress hub...');
|
||||||
|
|
||||||
// Initialize the edge registration manager
|
// Initialize the edge registration manager
|
||||||
this.remoteIngressManager = new RemoteIngressManager(this.storageManager);
|
this.remoteIngressManager = new RemoteIngressManager();
|
||||||
await this.remoteIngressManager.initialize();
|
await this.remoteIngressManager.initialize();
|
||||||
|
|
||||||
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
||||||
const currentRoutes = this.constructorRoutes;
|
const currentRoutes = this.constructorRoutes;
|
||||||
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
||||||
|
|
||||||
|
// Race-condition fix: if ConfigManagers finished before us, re-apply routes
|
||||||
|
// so the callback delivers the full merged set (including DB-stored routes)
|
||||||
|
// to our newly-created remoteIngressManager.
|
||||||
|
if (this.routeConfigManager) {
|
||||||
|
await this.routeConfigManager.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
|
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
|
||||||
const riCfg = this.options.remoteIngressConfig;
|
const riCfg = this.options.remoteIngressConfig;
|
||||||
let tlsConfig: { certPem: string; keyPem: string } | undefined;
|
let tlsConfig: { certPem: string; keyPem: string } | undefined;
|
||||||
@@ -2046,7 +2197,7 @@ export class DcRouter {
|
|||||||
// Priority 2: Existing cert from SmartProxy cert store for hubDomain
|
// Priority 2: Existing cert from SmartProxy cert store for hubDomain
|
||||||
if (!tlsConfig && riCfg.hubDomain) {
|
if (!tlsConfig && riCfg.hubDomain) {
|
||||||
try {
|
try {
|
||||||
const stored = await this.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
|
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
|
||||||
if (stored?.publicKey && stored?.privateKey) {
|
if (stored?.publicKey && stored?.privateKey) {
|
||||||
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
|
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
|
||||||
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
|
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
|
||||||
@@ -2080,48 +2231,98 @@ export class DcRouter {
|
|||||||
|
|
||||||
logger.log('info', 'Setting up VPN server...');
|
logger.log('info', 'Setting up VPN server...');
|
||||||
|
|
||||||
this.vpnManager = new VpnManager(this.storageManager, {
|
this.vpnManager = new VpnManager({
|
||||||
subnet: this.options.vpnConfig.subnet,
|
subnet: this.options.vpnConfig.subnet,
|
||||||
wgListenPort: this.options.vpnConfig.wgListenPort,
|
wgListenPort: this.options.vpnConfig.wgListenPort,
|
||||||
dns: this.options.vpnConfig.dns,
|
dns: this.options.vpnConfig.dns,
|
||||||
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
||||||
|
initialClients: this.options.vpnConfig.clients,
|
||||||
|
destinationPolicy: this.options.vpnConfig.destinationPolicy,
|
||||||
forwardingMode: this.options.vpnConfig.forwardingMode,
|
forwardingMode: this.options.vpnConfig.forwardingMode,
|
||||||
|
bridgeLanSubnet: this.options.vpnConfig.bridgeLanSubnet,
|
||||||
|
bridgePhysicalInterface: this.options.vpnConfig.bridgePhysicalInterface,
|
||||||
|
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
|
||||||
|
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||||
|
onClientChanged: () => {
|
||||||
|
// Re-apply routes so profile-based ipAllowLists get updated
|
||||||
|
// (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}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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 ips = new Set<string>([subnet]);
|
||||||
|
|
||||||
|
if (!this.targetProfileManager) return [...ips];
|
||||||
|
|
||||||
|
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[];
|
||||||
|
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
|
||||||
|
|
||||||
|
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||||
|
targetProfileIds, routes, storedRoutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add target IPs directly
|
||||||
|
for (const ip of targetIps) {
|
||||||
|
ips.add(`${ip}/32`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve DNS A records for matched domains (with caching)
|
||||||
|
for (const domain of domains) {
|
||||||
|
const stripped = domain.replace(/^\*\./, '');
|
||||||
|
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
||||||
|
for (const ip of resolvedIps) {
|
||||||
|
ips.add(`${ip}/32`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ips];
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.vpnManager.start();
|
await this.vpnManager.start();
|
||||||
|
|
||||||
|
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
||||||
|
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
|
||||||
|
// VPN server wasn't ready yet)
|
||||||
|
await this.routeConfigManager?.applyRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||||
|
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject VPN security into routes that have vpn.required === true.
|
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
||||||
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
|
|
||||||
*/
|
*/
|
||||||
private injectVpnSecurity(routes: plugins.smartproxy.IRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
|
private async resolveVpnDomainIPs(domain: string): Promise<string[]> {
|
||||||
const vpnSubnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
const cached = this.vpnDomainIpCache.get(domain);
|
||||||
let injectedCount = 0;
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.ips;
|
||||||
const result = routes.map((route) => {
|
}
|
||||||
const dcrouterRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
|
try {
|
||||||
if (dcrouterRoute.vpn?.required) {
|
const { promises: dnsPromises } = await import('dns');
|
||||||
injectedCount++;
|
const ips = await dnsPromises.resolve4(domain);
|
||||||
const existing = route.security?.ipAllowList || [];
|
this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
|
||||||
return {
|
// Evict oldest entries if cache exceeds 1000 entries
|
||||||
...route,
|
if (this.vpnDomainIpCache.size > 1000) {
|
||||||
security: {
|
const firstKey = this.vpnDomainIpCache.keys().next().value;
|
||||||
...route.security,
|
if (firstKey) this.vpnDomainIpCache.delete(firstKey);
|
||||||
ipAllowList: [...existing, vpnSubnet],
|
}
|
||||||
},
|
return ips;
|
||||||
};
|
} catch (err) {
|
||||||
}
|
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
|
||||||
return route;
|
return cached?.ips || []; // Return stale cache on failure, or empty
|
||||||
});
|
|
||||||
|
|
||||||
if (injectedCount > 0) {
|
|
||||||
logger.log('info', `VPN: Injected ipAllowList (${vpnSubnet}) into ${injectedCount} VPN-protected route(s)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
||||||
|
// via the getVpnAllowList callback — no longer a separate method here.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up RADIUS server for network authentication
|
* Set up RADIUS server for network authentication
|
||||||
*/
|
*/
|
||||||
@@ -2132,7 +2333,7 @@ export class DcRouter {
|
|||||||
|
|
||||||
logger.log('info', 'Setting up RADIUS server...');
|
logger.log('info', 'Setting up RADIUS server...');
|
||||||
|
|
||||||
this.radiusServer = new RadiusServer(this.options.radiusConfig, this.storageManager);
|
this.radiusServer = new RadiusServer(this.options.radiusConfig);
|
||||||
await this.radiusServer.start();
|
await this.radiusServer.start();
|
||||||
|
|
||||||
logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`);
|
logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`);
|
||||||
|
|||||||
@@ -1,46 +1,58 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { StorageManager } from './storage/index.js';
|
import { AcmeCertDoc } from './db/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ICertManager implementation backed by StorageManager.
|
* ICertManager implementation backed by smartdata document classes.
|
||||||
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
* Persists SmartAcme certificates via AcmeCertDoc so they
|
||||||
* survive process restarts without re-hitting ACME.
|
* survive process restarts without re-hitting ACME.
|
||||||
*/
|
*/
|
||||||
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||||
private keyPrefix = '/certs/';
|
constructor() {}
|
||||||
|
|
||||||
constructor(private storageManager: StorageManager) {}
|
|
||||||
|
|
||||||
async init(): Promise<void> {}
|
async init(): Promise<void> {}
|
||||||
|
|
||||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||||
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||||
if (!data) return null;
|
if (!doc) return null;
|
||||||
return new plugins.smartacme.Cert(data);
|
return new plugins.smartacme.Cert({
|
||||||
}
|
id: doc.id,
|
||||||
|
domainName: doc.domainName,
|
||||||
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
created: doc.created,
|
||||||
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
privateKey: doc.privateKey,
|
||||||
id: cert.id,
|
publicKey: doc.publicKey,
|
||||||
domainName: cert.domainName,
|
csr: doc.csr,
|
||||||
created: cert.created,
|
validUntil: doc.validUntil,
|
||||||
privateKey: cert.privateKey,
|
|
||||||
publicKey: cert.publicKey,
|
|
||||||
csr: cert.csr,
|
|
||||||
validUntil: cert.validUntil,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||||
|
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
|
||||||
|
if (!doc) {
|
||||||
|
doc = new AcmeCertDoc();
|
||||||
|
doc.id = cert.id;
|
||||||
|
doc.domainName = cert.domainName;
|
||||||
|
}
|
||||||
|
doc.created = cert.created;
|
||||||
|
doc.privateKey = cert.privateKey;
|
||||||
|
doc.publicKey = cert.publicKey;
|
||||||
|
doc.csr = cert.csr;
|
||||||
|
doc.validUntil = cert.validUntil;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteCertificate(domainName: string): Promise<void> {
|
async deleteCertificate(domainName: string): Promise<void> {
|
||||||
await this.storageManager.delete(this.keyPrefix + domainName);
|
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||||
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {}
|
async close(): Promise<void> {}
|
||||||
|
|
||||||
async wipe(): Promise<void> {
|
async wipe(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(this.keyPrefix);
|
const docs = await AcmeCertDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
await this.storageManager.delete(key);
|
await doc.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
import { ApiTokenDoc } from '../db/index.js';
|
||||||
import type {
|
import type {
|
||||||
IStoredApiToken,
|
IStoredApiToken,
|
||||||
IApiTokenInfo,
|
IApiTokenInfo,
|
||||||
TApiTokenScope,
|
TApiTokenScope,
|
||||||
} from '../../ts_interfaces/data/route-management.js';
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
const TOKENS_PREFIX = '/config-api/tokens/';
|
|
||||||
const TOKEN_PREFIX_STR = 'dcr_';
|
const TOKEN_PREFIX_STR = 'dcr_';
|
||||||
|
|
||||||
export class ApiTokenManager {
|
export class ApiTokenManager {
|
||||||
private tokens = new Map<string, IStoredApiToken>();
|
private tokens = new Map<string, IStoredApiToken>();
|
||||||
|
|
||||||
constructor(private storageManager: StorageManager) {}
|
constructor() {}
|
||||||
|
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
await this.loadTokens();
|
await this.loadTokens();
|
||||||
@@ -117,7 +116,8 @@ export class ApiTokenManager {
|
|||||||
if (!this.tokens.has(id)) return false;
|
if (!this.tokens.has(id)) return false;
|
||||||
const token = this.tokens.get(id)!;
|
const token = this.tokens.get(id)!;
|
||||||
this.tokens.delete(id);
|
this.tokens.delete(id);
|
||||||
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
|
const doc = await ApiTokenDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -157,17 +157,48 @@ export class ApiTokenManager {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async loadTokens(): Promise<void> {
|
private async loadTokens(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(TOKENS_PREFIX);
|
const docs = await ApiTokenDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
if (!key.endsWith('.json')) continue;
|
if (doc.id) {
|
||||||
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
|
this.tokens.set(doc.id, {
|
||||||
if (stored?.id) {
|
id: doc.id,
|
||||||
this.tokens.set(stored.id, stored);
|
name: doc.name,
|
||||||
|
tokenHash: doc.tokenHash,
|
||||||
|
scopes: doc.scopes,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
expiresAt: doc.expiresAt,
|
||||||
|
lastUsedAt: doc.lastUsedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||||
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
|
const existing = await ApiTokenDoc.findById(stored.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.name = stored.name;
|
||||||
|
existing.tokenHash = stored.tokenHash;
|
||||||
|
existing.scopes = stored.scopes;
|
||||||
|
existing.createdAt = stored.createdAt;
|
||||||
|
existing.expiresAt = stored.expiresAt;
|
||||||
|
existing.lastUsedAt = stored.lastUsedAt;
|
||||||
|
existing.createdBy = stored.createdBy;
|
||||||
|
existing.enabled = stored.enabled;
|
||||||
|
await existing.save();
|
||||||
|
} else {
|
||||||
|
const doc = new ApiTokenDoc();
|
||||||
|
doc.id = stored.id;
|
||||||
|
doc.name = stored.name;
|
||||||
|
doc.tokenHash = stored.tokenHash;
|
||||||
|
doc.scopes = stored.scopes;
|
||||||
|
doc.createdAt = stored.createdAt;
|
||||||
|
doc.expiresAt = stored.expiresAt;
|
||||||
|
doc.lastUsedAt = stored.lastUsedAt;
|
||||||
|
doc.createdBy = stored.createdBy;
|
||||||
|
doc.enabled = stored.enabled;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
ts/config/classes.db-seeder.ts
Normal file
95
ts/config/classes.db-seeder.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
import type { IRouteSecurity } from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
export interface ISeedData {
|
||||||
|
profiles?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
}>;
|
||||||
|
targets?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DbSeeder {
|
||||||
|
constructor(private referenceResolver: ReferenceResolver) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if DB is empty and seed if configured.
|
||||||
|
* Called once during ConfigManagers service startup, after initialize().
|
||||||
|
*/
|
||||||
|
public async seedIfEmpty(
|
||||||
|
seedOnEmpty?: boolean,
|
||||||
|
seedData?: ISeedData,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!seedOnEmpty) return;
|
||||||
|
|
||||||
|
const existingProfiles = this.referenceResolver.listProfiles();
|
||||||
|
const existingTargets = this.referenceResolver.listTargets();
|
||||||
|
|
||||||
|
if (existingProfiles.length > 0 || existingTargets.length > 0) {
|
||||||
|
logger.log('info', 'DB already contains profiles/targets, skipping seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', 'Seeding database with initial profiles and targets...');
|
||||||
|
|
||||||
|
const profilesToSeed: NonNullable<ISeedData['profiles']> = seedData?.profiles ?? DEFAULT_PROFILES;
|
||||||
|
const targetsToSeed: NonNullable<ISeedData['targets']> = seedData?.targets ?? DEFAULT_TARGETS;
|
||||||
|
|
||||||
|
for (const p of profilesToSeed) {
|
||||||
|
await this.referenceResolver.createProfile({
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
security: p.security,
|
||||||
|
extendsProfiles: p.extendsProfiles,
|
||||||
|
createdBy: 'system-seed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of targetsToSeed) {
|
||||||
|
await this.referenceResolver.createTarget({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
createdBy: 'system-seed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Seeded ${profilesToSeed.length} profile(s) and ${targetsToSeed.length} target(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
|
||||||
|
{
|
||||||
|
name: 'PUBLIC',
|
||||||
|
description: 'Allow all traffic — no IP restrictions',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'STANDARD',
|
||||||
|
description: 'Standard internal access with common private subnets',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8', '127.0.0.1', '::1'],
|
||||||
|
maxConnections: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TARGETS: Array<NonNullable<ISeedData['targets']>[number]> = [
|
||||||
|
{
|
||||||
|
name: 'LOCALHOST',
|
||||||
|
description: 'Local machine on port 443',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 443,
|
||||||
|
},
|
||||||
|
];
|
||||||
577
ts/config/classes.reference-resolver.ts
Normal file
577
ts/config/classes.reference-resolver.ts
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||||
|
import type {
|
||||||
|
ISourceProfile,
|
||||||
|
INetworkTarget,
|
||||||
|
IRouteMetadata,
|
||||||
|
IStoredRoute,
|
||||||
|
IRouteSecurity,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const MAX_INHERITANCE_DEPTH = 5;
|
||||||
|
|
||||||
|
export class ReferenceResolver {
|
||||||
|
private profiles = new Map<string, ISourceProfile>();
|
||||||
|
private targets = new Map<string, INetworkTarget>();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadProfiles();
|
||||||
|
await this.loadTargets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Profile CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createProfile(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const profile: ISourceProfile = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
security: data.security,
|
||||||
|
extendsProfiles: data.extendsProfiles,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.profiles.set(id, profile);
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Created source profile '${profile.name}' (${id})`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProfile(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<ISourceProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||||
|
): Promise<{ affectedRouteIds: string[] }> {
|
||||||
|
const profile = this.profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`Source profile '${id}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined) profile.name = patch.name;
|
||||||
|
if (patch.description !== undefined) profile.description = patch.description;
|
||||||
|
if (patch.security !== undefined) profile.security = patch.security;
|
||||||
|
if (patch.extendsProfiles !== undefined) profile.extendsProfiles = patch.extendsProfiles;
|
||||||
|
profile.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Updated source profile '${profile.name}' (${id})`);
|
||||||
|
|
||||||
|
// Find routes referencing this profile
|
||||||
|
const affectedRouteIds = await this.findRoutesByProfileRef(id);
|
||||||
|
return { affectedRouteIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteProfile(
|
||||||
|
id: string,
|
||||||
|
force: boolean,
|
||||||
|
storedRoutes?: Map<string, IStoredRoute>,
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const profile = this.profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, message: `Source profile '${id}' not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check usage
|
||||||
|
const affectedIds = storedRoutes
|
||||||
|
? this.findRoutesByProfileRefSync(id, storedRoutes)
|
||||||
|
: await this.findRoutesByProfileRef(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Profile '${profile.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from DB
|
||||||
|
const doc = await SourceProfileDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
this.profiles.delete(id);
|
||||||
|
|
||||||
|
// If force-deleting with referencing routes, clear refs but keep resolved values
|
||||||
|
if (affectedIds.length > 0) {
|
||||||
|
await this.clearProfileRefsOnRoutes(affectedIds);
|
||||||
|
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfile(id: string): ISourceProfile | undefined {
|
||||||
|
return this.profiles.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileByName(name: string): ISourceProfile | undefined {
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
if (profile.name === name) return profile;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public listProfiles(): ISourceProfile[] {
|
||||||
|
return [...this.profiles.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||||
|
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
usage.set(profile.id, []);
|
||||||
|
}
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
const ref = stored.metadata?.sourceProfileRef;
|
||||||
|
if (ref && usage.has(ref)) {
|
||||||
|
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileUsageForId(
|
||||||
|
profileId: string,
|
||||||
|
storedRoutes: Map<string, IStoredRoute>,
|
||||||
|
): Array<{ id: string; routeName: string }> {
|
||||||
|
const routes: Array<{ id: string; routeName: string }> = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||||
|
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Target CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createTarget(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const target: INetworkTarget = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
host: data.host,
|
||||||
|
port: data.port,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.targets.set(id, target);
|
||||||
|
await this.persistTarget(target);
|
||||||
|
logger.log('info', `Created network target '${target.name}' (${id})`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateTarget(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<INetworkTarget, 'id' | 'createdAt' | 'createdBy'>>,
|
||||||
|
): Promise<{ affectedRouteIds: string[] }> {
|
||||||
|
const target = this.targets.get(id);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`Network target '${id}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined) target.name = patch.name;
|
||||||
|
if (patch.description !== undefined) target.description = patch.description;
|
||||||
|
if (patch.host !== undefined) target.host = patch.host;
|
||||||
|
if (patch.port !== undefined) target.port = patch.port;
|
||||||
|
target.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistTarget(target);
|
||||||
|
logger.log('info', `Updated network target '${target.name}' (${id})`);
|
||||||
|
|
||||||
|
const affectedRouteIds = await this.findRoutesByTargetRef(id);
|
||||||
|
return { affectedRouteIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteTarget(
|
||||||
|
id: string,
|
||||||
|
force: boolean,
|
||||||
|
storedRoutes?: Map<string, IStoredRoute>,
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const target = this.targets.get(id);
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, message: `Network target '${id}' not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedIds = storedRoutes
|
||||||
|
? this.findRoutesByTargetRefSync(id, storedRoutes)
|
||||||
|
: await this.findRoutesByTargetRef(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Target '${target.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await NetworkTargetDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
this.targets.delete(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0) {
|
||||||
|
await this.clearTargetRefsOnRoutes(affectedIds);
|
||||||
|
logger.log('warn', `Force-deleted target '${target.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `Deleted network target '${target.name}' (${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTarget(id: string): INetworkTarget | undefined {
|
||||||
|
return this.targets.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTargetByName(name: string): INetworkTarget | undefined {
|
||||||
|
for (const target of this.targets.values()) {
|
||||||
|
if (target.name === name) return target;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public listTargets(): INetworkTarget[] {
|
||||||
|
return [...this.targets.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTargetUsageForId(
|
||||||
|
targetId: string,
|
||||||
|
storedRoutes: Map<string, IStoredRoute>,
|
||||||
|
): Array<{ id: string; routeName: string }> {
|
||||||
|
const routes: Array<{ id: string; routeName: string }> = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.networkTargetRef === targetId) {
|
||||||
|
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Resolution
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve references for a single route.
|
||||||
|
* Materializes source profile and/or network target into the route's fields.
|
||||||
|
* Returns the resolved route and updated metadata.
|
||||||
|
*/
|
||||||
|
public resolveRoute(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
metadata?: IRouteMetadata,
|
||||||
|
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||||
|
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||||
|
|
||||||
|
if (resolvedMetadata.sourceProfileRef) {
|
||||||
|
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
|
||||||
|
if (resolvedSecurity) {
|
||||||
|
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
|
||||||
|
// Merge: profile provides base, route's inline values override
|
||||||
|
route = {
|
||||||
|
...route,
|
||||||
|
security: this.mergeSecurityFields(resolvedSecurity, route.security),
|
||||||
|
};
|
||||||
|
resolvedMetadata.sourceProfileName = profile?.name;
|
||||||
|
resolvedMetadata.lastResolvedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedMetadata.networkTargetRef) {
|
||||||
|
const target = this.targets.get(resolvedMetadata.networkTargetRef);
|
||||||
|
if (target) {
|
||||||
|
const hosts = Array.isArray(target.host) ? target.host : [target.host];
|
||||||
|
route = {
|
||||||
|
...route,
|
||||||
|
action: {
|
||||||
|
...route.action,
|
||||||
|
targets: hosts.map((h) => ({
|
||||||
|
host: h,
|
||||||
|
port: target.port,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
resolvedMetadata.networkTargetName = target.name;
|
||||||
|
resolvedMetadata.lastResolvedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
logger.log('warn', `Network target '${resolvedMetadata.networkTargetRef}' not found during resolution`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { route, metadata: resolvedMetadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Reference lookup helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||||
|
const docs = await StoredRouteDoc.findAll();
|
||||||
|
return docs
|
||||||
|
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||||
|
.map((doc) => doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||||
|
const docs = await StoredRouteDoc.findAll();
|
||||||
|
return docs
|
||||||
|
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||||
|
.map((doc) => doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||||
|
ids.push(routeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.networkTargetRef === targetId) {
|
||||||
|
ids.push(routeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: source profile resolution with inheritance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private resolveSourceProfile(
|
||||||
|
profileId: string,
|
||||||
|
visited: Set<string> = new Set(),
|
||||||
|
depth: number = 0,
|
||||||
|
): IRouteSecurity | null {
|
||||||
|
if (depth > MAX_INHERITANCE_DEPTH) {
|
||||||
|
logger.log('warn', `Max inheritance depth (${MAX_INHERITANCE_DEPTH}) exceeded resolving profile '${profileId}'`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.has(profileId)) {
|
||||||
|
logger.log('warn', `Circular inheritance detected for profile '${profileId}'`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = this.profiles.get(profileId);
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
visited.add(profileId);
|
||||||
|
|
||||||
|
// Start with an empty base
|
||||||
|
let baseSecurity: IRouteSecurity = {};
|
||||||
|
|
||||||
|
// Resolve parent profiles first (top-down, later overrides earlier)
|
||||||
|
if (profile.extendsProfiles?.length) {
|
||||||
|
for (const parentId of profile.extendsProfiles) {
|
||||||
|
const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1);
|
||||||
|
if (parentSecurity) {
|
||||||
|
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply this profile's security on top
|
||||||
|
return this.mergeSecurityFields(baseSecurity, profile.security);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two IRouteSecurity objects.
|
||||||
|
* `override` values take precedence over `base` values.
|
||||||
|
* For ipAllowList/ipBlockList: union arrays and deduplicate.
|
||||||
|
* For scalar/object fields: override wins if present.
|
||||||
|
*/
|
||||||
|
private mergeSecurityFields(
|
||||||
|
base: IRouteSecurity | undefined,
|
||||||
|
override: IRouteSecurity | undefined,
|
||||||
|
): IRouteSecurity {
|
||||||
|
if (!base && !override) return {};
|
||||||
|
if (!base) return { ...override };
|
||||||
|
if (!override) return { ...base };
|
||||||
|
|
||||||
|
const merged: IRouteSecurity = { ...base };
|
||||||
|
|
||||||
|
// IP lists: union
|
||||||
|
if (override.ipAllowList || base.ipAllowList) {
|
||||||
|
merged.ipAllowList = [...new Set([
|
||||||
|
...(base.ipAllowList || []),
|
||||||
|
...(override.ipAllowList || []),
|
||||||
|
])];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (override.ipBlockList || base.ipBlockList) {
|
||||||
|
merged.ipBlockList = [...new Set([
|
||||||
|
...(base.ipBlockList || []),
|
||||||
|
...(override.ipBlockList || []),
|
||||||
|
])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar/object fields: override wins
|
||||||
|
if (override.maxConnections !== undefined) merged.maxConnections = override.maxConnections;
|
||||||
|
if (override.rateLimit !== undefined) merged.rateLimit = override.rateLimit;
|
||||||
|
if (override.authentication !== undefined) merged.authentication = override.authentication;
|
||||||
|
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
|
||||||
|
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadProfiles(): Promise<void> {
|
||||||
|
const docs = await SourceProfileDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.profiles.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
security: doc.security,
|
||||||
|
extendsProfiles: doc.extendsProfiles,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.profiles.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadTargets(): Promise<void> {
|
||||||
|
const docs = await NetworkTargetDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.targets.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
host: doc.host,
|
||||||
|
port: doc.port,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.targets.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.targets.size} network target(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistProfile(profile: ISourceProfile): Promise<void> {
|
||||||
|
const existingDoc = await SourceProfileDoc.findById(profile.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.name = profile.name;
|
||||||
|
existingDoc.description = profile.description;
|
||||||
|
existingDoc.security = profile.security;
|
||||||
|
existingDoc.extendsProfiles = profile.extendsProfiles;
|
||||||
|
existingDoc.updatedAt = profile.updatedAt;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new SourceProfileDoc();
|
||||||
|
doc.id = profile.id;
|
||||||
|
doc.name = profile.name;
|
||||||
|
doc.description = profile.description;
|
||||||
|
doc.security = profile.security;
|
||||||
|
doc.extendsProfiles = profile.extendsProfiles;
|
||||||
|
doc.createdAt = profile.createdAt;
|
||||||
|
doc.updatedAt = profile.updatedAt;
|
||||||
|
doc.createdBy = profile.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistTarget(target: INetworkTarget): Promise<void> {
|
||||||
|
const existingDoc = await NetworkTargetDoc.findById(target.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.name = target.name;
|
||||||
|
existingDoc.description = target.description;
|
||||||
|
existingDoc.host = target.host;
|
||||||
|
existingDoc.port = target.port;
|
||||||
|
existingDoc.updatedAt = target.updatedAt;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new NetworkTargetDoc();
|
||||||
|
doc.id = target.id;
|
||||||
|
doc.name = target.name;
|
||||||
|
doc.description = target.description;
|
||||||
|
doc.host = target.host;
|
||||||
|
doc.port = target.port;
|
||||||
|
doc.createdAt = target.createdAt;
|
||||||
|
doc.updatedAt = target.updatedAt;
|
||||||
|
doc.createdBy = target.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: ref cleanup on force-delete
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const doc = await StoredRouteDoc.findById(routeId);
|
||||||
|
if (doc?.metadata) {
|
||||||
|
doc.metadata = {
|
||||||
|
...doc.metadata,
|
||||||
|
sourceProfileRef: undefined,
|
||||||
|
sourceProfileName: undefined,
|
||||||
|
};
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const doc = await StoredRouteDoc.findById(routeId);
|
||||||
|
if (doc?.metadata) {
|
||||||
|
doc.metadata = {
|
||||||
|
...doc.metadata,
|
||||||
|
networkTargetRef: undefined,
|
||||||
|
networkTargetName: undefined,
|
||||||
|
};
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,70 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
|
||||||
import type {
|
import type {
|
||||||
IStoredRoute,
|
IStoredRoute,
|
||||||
IRouteOverride,
|
IRouteOverride,
|
||||||
IMergedRoute,
|
IMergedRoute,
|
||||||
IRouteWarning,
|
IRouteWarning,
|
||||||
|
IRouteMetadata,
|
||||||
} from '../../ts_interfaces/data/route-management.js';
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||||
|
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
|
||||||
const ROUTES_PREFIX = '/config-api/routes/';
|
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
||||||
const OVERRIDES_PREFIX = '/config-api/overrides/';
|
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 storageManager: StorageManager,
|
|
||||||
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 getVpnSubnet?: () => string | undefined,
|
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||||
|
private referenceResolver?: ReferenceResolver,
|
||||||
|
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Expose stored routes map for reference resolution lookups. */
|
||||||
|
public getStoredRoutes(): Map<string, IStoredRoute> {
|
||||||
|
return this.storedRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||||
*/
|
*/
|
||||||
@@ -66,6 +105,7 @@ export class RouteConfigManager {
|
|||||||
storedRouteId: stored.id,
|
storedRouteId: stored.id,
|
||||||
createdAt: stored.createdAt,
|
createdAt: stored.createdAt,
|
||||||
updatedAt: stored.updatedAt,
|
updatedAt: stored.updatedAt,
|
||||||
|
metadata: stored.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +117,10 @@ 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,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -89,6 +130,14 @@ export class RouteConfigManager {
|
|||||||
route.name = `programmatic-${id.slice(0, 8)}`;
|
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve references if metadata has refs and resolver is available
|
||||||
|
let resolvedMetadata = metadata;
|
||||||
|
if (metadata && this.referenceResolver) {
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(route, metadata);
|
||||||
|
route = resolved.route;
|
||||||
|
resolvedMetadata = resolved.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
const stored: IStoredRoute = {
|
const stored: IStoredRoute = {
|
||||||
id,
|
id,
|
||||||
route,
|
route,
|
||||||
@@ -96,6 +145,7 @@ export class RouteConfigManager {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy,
|
createdBy,
|
||||||
|
metadata: resolvedMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.storedRoutes.set(id, stored);
|
this.storedRoutes.set(id, stored);
|
||||||
@@ -106,17 +156,43 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
public async updateRoute(
|
public async updateRoute(
|
||||||
id: string,
|
id: string,
|
||||||
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
|
patch: {
|
||||||
|
route?: Partial<IDcRouterRouteConfig>;
|
||||||
|
enabled?: boolean;
|
||||||
|
metadata?: Partial<IRouteMetadata>;
|
||||||
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const stored = this.storedRoutes.get(id);
|
const stored = this.storedRoutes.get(id);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
if (patch.metadata !== undefined) {
|
||||||
|
stored.metadata = { ...stored.metadata, ...patch.metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-resolve if metadata refs exist and resolver is available
|
||||||
|
if (stored.metadata && this.referenceResolver) {
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
|
stored.route = resolved.route;
|
||||||
|
stored.metadata = resolved.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
stored.updatedAt = Date.now();
|
stored.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.persistRoute(stored);
|
await this.persistRoute(stored);
|
||||||
@@ -127,7 +203,8 @@ export class RouteConfigManager {
|
|||||||
public async deleteRoute(id: string): Promise<boolean> {
|
public async deleteRoute(id: string): Promise<boolean> {
|
||||||
if (!this.storedRoutes.has(id)) return false;
|
if (!this.storedRoutes.has(id)) return false;
|
||||||
this.storedRoutes.delete(id);
|
this.storedRoutes.delete(id);
|
||||||
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
|
const doc = await StoredRouteDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -148,7 +225,20 @@ export class RouteConfigManager {
|
|||||||
updatedBy,
|
updatedBy,
|
||||||
};
|
};
|
||||||
this.overrides.set(routeName, override);
|
this.overrides.set(routeName, override);
|
||||||
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
|
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.enabled = override.enabled;
|
||||||
|
existingDoc.updatedAt = override.updatedAt;
|
||||||
|
existingDoc.updatedBy = override.updatedBy;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new RouteOverrideDoc();
|
||||||
|
doc.routeName = override.routeName;
|
||||||
|
doc.enabled = override.enabled;
|
||||||
|
doc.updatedAt = override.updatedAt;
|
||||||
|
doc.updatedBy = override.updatedBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
this.computeWarnings();
|
this.computeWarnings();
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
}
|
}
|
||||||
@@ -156,7 +246,8 @@ export class RouteConfigManager {
|
|||||||
public async removeOverride(routeName: string): Promise<boolean> {
|
public async removeOverride(routeName: string): Promise<boolean> {
|
||||||
if (!this.overrides.has(routeName)) return false;
|
if (!this.overrides.has(routeName)) return false;
|
||||||
this.overrides.delete(routeName);
|
this.overrides.delete(routeName);
|
||||||
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
|
const doc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||||
|
if (doc) await doc.delete();
|
||||||
this.computeWarnings();
|
this.computeWarnings();
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
return true;
|
return true;
|
||||||
@@ -167,12 +258,18 @@ export class RouteConfigManager {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async loadStoredRoutes(): Promise<void> {
|
private async loadStoredRoutes(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(ROUTES_PREFIX);
|
const docs = await StoredRouteDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
if (!key.endsWith('.json')) continue;
|
if (doc.id) {
|
||||||
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
|
this.storedRoutes.set(doc.id, {
|
||||||
if (stored?.id) {
|
id: doc.id,
|
||||||
this.storedRoutes.set(stored.id, stored);
|
route: doc.route,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
metadata: doc.metadata,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.storedRoutes.size > 0) {
|
if (this.storedRoutes.size > 0) {
|
||||||
@@ -181,12 +278,15 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadOverrides(): Promise<void> {
|
private async loadOverrides(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
|
const docs = await RouteOverrideDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
if (!key.endsWith('.json')) continue;
|
if (doc.routeName) {
|
||||||
const override = await this.storageManager.getJSON<IRouteOverride>(key);
|
this.overrides.set(doc.routeName, {
|
||||||
if (override?.routeName) {
|
routeName: doc.routeName,
|
||||||
this.overrides.set(override.routeName, override);
|
enabled: doc.enabled,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
updatedBy: doc.updatedBy,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.overrides.size > 0) {
|
if (this.overrides.size > 0) {
|
||||||
@@ -195,7 +295,25 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||||
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
|
const existingDoc = await StoredRouteDoc.findById(stored.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.route = stored.route;
|
||||||
|
existingDoc.enabled = stored.enabled;
|
||||||
|
existingDoc.updatedAt = stored.updatedAt;
|
||||||
|
existingDoc.createdBy = stored.createdBy;
|
||||||
|
existingDoc.metadata = stored.metadata;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new StoredRouteDoc();
|
||||||
|
doc.id = stored.id;
|
||||||
|
doc.route = stored.route;
|
||||||
|
doc.enabled = stored.enabled;
|
||||||
|
doc.createdAt = stored.createdAt;
|
||||||
|
doc.updatedAt = stored.updatedAt;
|
||||||
|
doc.createdBy = stored.createdBy;
|
||||||
|
doc.metadata = stored.metadata;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -242,54 +360,91 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Re-resolve routes after profile/target changes
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve specific routes by ID (after a profile or target is updated).
|
||||||
|
* Persists each route and calls applyRoutes() once at the end.
|
||||||
|
*/
|
||||||
|
public async reResolveRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||||
|
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const stored = this.storedRoutes.get(routeId);
|
||||||
|
if (!stored?.metadata) continue;
|
||||||
|
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
|
stored.route = resolved.route;
|
||||||
|
stored.metadata = resolved.metadata;
|
||||||
|
stored.updatedAt = Date.now();
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.applyRoutes();
|
||||||
|
logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: apply merged routes to SmartProxy
|
// Private: apply merged routes to SmartProxy
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private 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[] = [];
|
||||||
|
|
||||||
// Add enabled hardcoded routes (respecting overrides)
|
const http3Config = this.getHttp3Config?.();
|
||||||
for (const route of this.getHardcodedRoutes()) {
|
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||||
const name = route.name || '';
|
|
||||||
const override = this.overrides.get(name);
|
|
||||||
if (override && !override.enabled) {
|
|
||||||
continue; // Skip disabled hardcoded route
|
|
||||||
}
|
|
||||||
enabledRoutes.push(route);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
|
// Helper: inject VPN security into a vpnOnly route
|
||||||
const http3Config = this.getHttp3Config?.();
|
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||||
const vpnSubnet = this.getVpnSubnet?.();
|
if (!vpnCallback) return route;
|
||||||
for (const stored of this.storedRoutes.values()) {
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
if (stored.enabled) {
|
if (!dcRoute.vpnOnly) return route;
|
||||||
let route = stored.route;
|
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||||
if (http3Config && http3Config.enabled !== false) {
|
const existingEntries = route.security?.ipAllowList || [];
|
||||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
return {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
||||||
|
for (const route of this.getHardcodedRoutes()) {
|
||||||
|
const name = route.name || '';
|
||||||
|
const override = this.overrides.get(name);
|
||||||
|
if (override && !override.enabled) {
|
||||||
|
continue; // Skip disabled hardcoded route
|
||||||
}
|
}
|
||||||
// Inject VPN security for programmatic routes with vpn.required
|
enabledRoutes.push(injectVpn(route));
|
||||||
if (vpnSubnet) {
|
}
|
||||||
const dcRoute = route as IDcRouterRouteConfig;
|
|
||||||
if (dcRoute.vpn?.required) {
|
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||||
const existing = route.security?.ipAllowList || [];
|
for (const stored of this.storedRoutes.values()) {
|
||||||
route = {
|
if (stored.enabled) {
|
||||||
...route,
|
let route = stored.route;
|
||||||
security: {
|
if (http3Config?.enabled !== false) {
|
||||||
...route.security,
|
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||||
ipAllowList: [...existing, vpnSubnet],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
enabledRoutes.push(injectVpn(route, stored.id));
|
||||||
}
|
}
|
||||||
enabledRoutes.push(route);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await smartProxy.updateRoutes(enabledRoutes);
|
await smartProxy.updateRoutes(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
// Export validation tools only
|
// Export validation tools only
|
||||||
export * from './validator.js';
|
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 { DbSeeder } from './classes.db-seeder.js';
|
||||||
|
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { CacheDb } from './classes.cachedb.js';
|
import { DcRouterDb } from './classes.dcrouter-db.js';
|
||||||
|
|
||||||
// Import document classes for cleanup
|
// Import document classes for cleanup
|
||||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||||
@@ -26,10 +26,10 @@ export class CacheCleaner {
|
|||||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private isRunning: boolean = false;
|
private isRunning: boolean = false;
|
||||||
private options: Required<ICacheCleanerOptions>;
|
private options: Required<ICacheCleanerOptions>;
|
||||||
private cacheDb: CacheDb;
|
private dcRouterDb: DcRouterDb;
|
||||||
|
|
||||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
constructor(dcRouterDb: DcRouterDb, options: ICacheCleanerOptions = {}) {
|
||||||
this.cacheDb = cacheDb;
|
this.dcRouterDb = dcRouterDb;
|
||||||
this.options = {
|
this.options = {
|
||||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||||
verbose: options.verbose || false,
|
verbose: options.verbose || false,
|
||||||
@@ -86,8 +86,8 @@ export class CacheCleaner {
|
|||||||
* Run a single cleanup cycle
|
* Run a single cleanup cycle
|
||||||
*/
|
*/
|
||||||
public async runCleanup(): Promise<void> {
|
public async runCleanup(): Promise<void> {
|
||||||
if (!this.cacheDb.isReady()) {
|
if (!this.dcRouterDb.isReady()) {
|
||||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
logger.log('warn', 'DcRouterDb not ready, skipping cleanup');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
179
ts/db/classes.dcrouter-db.ts
Normal file
179
ts/db/classes.dcrouter-db.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { defaultTsmDbPath } from '../paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for the unified DCRouter database
|
||||||
|
*/
|
||||||
|
export interface IDcRouterDbConfig {
|
||||||
|
/** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */
|
||||||
|
mongoDbUrl?: string;
|
||||||
|
/** Storage path for embedded LocalSmartDb data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||||
|
storagePath?: string;
|
||||||
|
/** Database name (default: dcrouter) */
|
||||||
|
dbName?: string;
|
||||||
|
/** Enable debug logging */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DcRouterDb - Unified database layer for DCRouter
|
||||||
|
*
|
||||||
|
* Replaces both StorageManager (flat-file key-value) and CacheDb (embedded MongoDB).
|
||||||
|
* All data is stored as smartdata document classes in a single database.
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* - **Embedded** (default): Spawns a LocalSmartDb (Rust-based MongoDB-compatible engine)
|
||||||
|
* - **External**: Connects to a provided MongoDB URL
|
||||||
|
*/
|
||||||
|
export class DcRouterDb {
|
||||||
|
private static instance: DcRouterDb | null = null;
|
||||||
|
|
||||||
|
private localSmartDb: plugins.smartdb.LocalSmartDb | null = null;
|
||||||
|
private smartdataDb!: plugins.smartdata.SmartdataDb;
|
||||||
|
private options: Required<IDcRouterDbConfig>;
|
||||||
|
private isStarted: boolean = false;
|
||||||
|
|
||||||
|
constructor(options: IDcRouterDbConfig = {}) {
|
||||||
|
this.options = {
|
||||||
|
mongoDbUrl: options.mongoDbUrl || '',
|
||||||
|
storagePath: options.storagePath || defaultTsmDbPath,
|
||||||
|
dbName: options.dbName || 'dcrouter',
|
||||||
|
debug: options.debug || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(options?: IDcRouterDbConfig): DcRouterDb {
|
||||||
|
if (!DcRouterDb.instance) {
|
||||||
|
DcRouterDb.instance = new DcRouterDb(options);
|
||||||
|
}
|
||||||
|
return DcRouterDb.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (useful for testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
DcRouterDb.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the database
|
||||||
|
* - If mongoDbUrl is provided, connects directly to external MongoDB
|
||||||
|
* - Otherwise, starts an embedded LocalSmartDb instance
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.isStarted) {
|
||||||
|
logger.log('warn', 'DcRouterDb already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let connectionUri: string;
|
||||||
|
|
||||||
|
if (this.options.mongoDbUrl) {
|
||||||
|
// External MongoDB mode
|
||||||
|
connectionUri = this.options.mongoDbUrl;
|
||||||
|
logger.log('info', `DcRouterDb connecting to external MongoDB`);
|
||||||
|
} else {
|
||||||
|
// Embedded LocalSmartDb mode
|
||||||
|
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||||
|
|
||||||
|
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
|
||||||
|
folderPath: this.options.storagePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionInfo = await this.localSmartDb.start();
|
||||||
|
connectionUri = connectionInfo.connectionUri;
|
||||||
|
|
||||||
|
if (this.options.debug) {
|
||||||
|
logger.log('debug', `LocalSmartDb started with URI: ${connectionUri}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `DcRouterDb started embedded instance at ${this.options.storagePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize smartdata ORM
|
||||||
|
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||||
|
mongoDbUrl: connectionUri,
|
||||||
|
mongoDbName: this.options.dbName,
|
||||||
|
});
|
||||||
|
await this.smartdataDb.init();
|
||||||
|
|
||||||
|
this.isStarted = true;
|
||||||
|
logger.log('info', `DcRouterDb ready (db: ${this.options.dbName})`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Failed to start DcRouterDb: ${(error as Error).message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the database
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Close smartdata connection
|
||||||
|
if (this.smartdataDb) {
|
||||||
|
await this.smartdataDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop embedded LocalSmartDb if running
|
||||||
|
if (this.localSmartDb) {
|
||||||
|
await this.localSmartDb.stop();
|
||||||
|
this.localSmartDb = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarted = false;
|
||||||
|
logger.log('info', 'DcRouterDb stopped');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Error stopping DcRouterDb: ${(error as Error).message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the smartdata database instance for @Collection decorators
|
||||||
|
*/
|
||||||
|
public getDb(): plugins.smartdata.SmartdataDb {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
throw new Error('DcRouterDb not started. Call start() first.');
|
||||||
|
}
|
||||||
|
return this.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the database is ready
|
||||||
|
*/
|
||||||
|
public isReady(): boolean {
|
||||||
|
return this.isStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether running in embedded mode (LocalSmartDb) vs external MongoDB
|
||||||
|
*/
|
||||||
|
public isEmbedded(): boolean {
|
||||||
|
return !this.options.mongoDbUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage path (only relevant for embedded mode)
|
||||||
|
*/
|
||||||
|
public getStoragePath(): string {
|
||||||
|
return this.options.storagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the database name
|
||||||
|
*/
|
||||||
|
public getDbName(): string {
|
||||||
|
return this.options.dbName;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ts/db/documents/classes.accounting-session.doc.ts
Normal file
106
ts/db/documents/classes.accounting-session.doc.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class AccountingSessionDoc extends plugins.smartdata.SmartDataDbDoc<AccountingSessionDoc, AccountingSessionDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public sessionId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public username!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public macAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasIpAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasPort!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasPortType!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasIdentifier!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public vlanId!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public framedIpAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public calledStationId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public callingStationId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public startTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public endTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastUpdateTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.index()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public status!: 'active' | 'stopped' | 'terminated';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public terminateCause!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public inputOctets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public outputOctets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public inputPackets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public outputPackets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public sessionTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public serviceType!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findBySessionId(sessionId: string): Promise<AccountingSessionDoc | null> {
|
||||||
|
return await AccountingSessionDoc.getInstance({ sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findActive(): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ status: 'active' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByUsername(username: string): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ username });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByNas(nasIpAddress: string): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ nasIpAddress });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByVlan(vlanId: number): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ vlanId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findStoppedBefore(cutoffTime: number): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({
|
||||||
|
status: { $in: ['stopped', 'terminated'] } as any,
|
||||||
|
endTime: { $lt: cutoffTime, $gt: 0 } as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ts/db/documents/classes.acme-cert.doc.ts
Normal file
41
ts/db/documents/classes.acme-cert.doc.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class AcmeCertDoc extends plugins.smartdata.SmartDataDbDoc<AcmeCertDoc, AcmeCertDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domainName!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public created!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public privateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public publicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public csr!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validUntil!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domainName: string): Promise<AcmeCertDoc | null> {
|
||||||
|
return await AcmeCertDoc.getInstance({ domainName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<AcmeCertDoc[]> {
|
||||||
|
return await AcmeCertDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
56
ts/db/documents/classes.api-token.doc.ts
Normal file
56
ts/db/documents/classes.api-token.doc.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, ApiTokenDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public tokenHash!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public scopes!: TApiTokenScope[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt!: number | null;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastUsedAt!: number | null;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<ApiTokenDoc | null> {
|
||||||
|
return await ApiTokenDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByTokenHash(tokenHash: string): Promise<ApiTokenDoc | null> {
|
||||||
|
return await ApiTokenDoc.getInstance({ tokenHash });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<ApiTokenDoc[]> {
|
||||||
|
return await ApiTokenDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findEnabled(): Promise<ApiTokenDoc[]> {
|
||||||
|
return await ApiTokenDoc.getInstances({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||||
import { CacheDb } from '../classes.cachedb.js';
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email status in the cache
|
* Email status in the cache
|
||||||
@@ -10,7 +10,7 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'faile
|
|||||||
/**
|
/**
|
||||||
* Helper to get the smartdata database instance
|
* Helper to get the smartdata database instance
|
||||||
*/
|
*/
|
||||||
const getDb = () => CacheDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CachedEmail - Stores email queue items in the cache
|
* CachedEmail - Stores email queue items in the cache
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||||
import { CacheDb } from '../classes.cachedb.js';
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get the smartdata database instance
|
* Helper to get the smartdata database instance
|
||||||
*/
|
*/
|
||||||
const getDb = () => CacheDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IP reputation result data
|
* IP reputation result data
|
||||||
35
ts/db/documents/classes.cert-backoff.doc.ts
Normal file
35
ts/db/documents/classes.cert-backoff.doc.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class CertBackoffDoc extends plugins.smartdata.SmartDataDbDoc<CertBackoffDoc, CertBackoffDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domain!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public failures!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastFailure!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public retryAfter!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastError!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domain: string): Promise<CertBackoffDoc | null> {
|
||||||
|
return await CertBackoffDoc.getInstance({ domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<CertBackoffDoc[]> {
|
||||||
|
return await CertBackoffDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
48
ts/db/documents/classes.network-target.doc.ts
Normal file
48
ts/db/documents/classes.network-target.doc.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class NetworkTargetDoc extends plugins.smartdata.SmartDataDbDoc<NetworkTargetDoc, NetworkTargetDoc> {
|
||||||
|
@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 host!: string | string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public port!: number;
|
||||||
|
|
||||||
|
@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<NetworkTargetDoc | null> {
|
||||||
|
return await NetworkTargetDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<NetworkTargetDoc | null> {
|
||||||
|
return await NetworkTargetDoc.getInstance({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<NetworkTargetDoc[]> {
|
||||||
|
return await NetworkTargetDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
38
ts/db/documents/classes.proxy-cert.doc.ts
Normal file
38
ts/db/documents/classes.proxy-cert.doc.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class ProxyCertDoc extends plugins.smartdata.SmartDataDbDoc<ProxyCertDoc, ProxyCertDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domain!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public publicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public privateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public ca!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validUntil!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validFrom!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domain: string): Promise<ProxyCertDoc | null> {
|
||||||
|
return await ProxyCertDoc.getInstance({ domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<ProxyCertDoc[]> {
|
||||||
|
return await ProxyCertDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
ts/db/documents/classes.remote-ingress-edge.doc.ts
Normal file
54
ts/db/documents/classes.remote-ingress-edge.doc.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressEdgeDoc, RemoteIngressEdgeDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public secret!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public listenPorts!: number[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public listenPortsUdp!: number[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public autoDerivePorts!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public tags!: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<RemoteIngressEdgeDoc | null> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<RemoteIngressEdgeDoc[]> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findEnabled(): Promise<RemoteIngressEdgeDoc[]> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstances({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ts/db/documents/classes.route-override.doc.ts
Normal file
32
ts/db/documents/classes.route-override.doc.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public routeName!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
|
||||||
|
return await RouteOverrideDoc.getInstance({ routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<RouteOverrideDoc[]> {
|
||||||
|
return await RouteOverrideDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
45
ts/db/documents/classes.source-profile.doc.ts
Normal file
45
ts/db/documents/classes.source-profile.doc.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteSecurity } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourceProfileDoc, SourceProfileDoc> {
|
||||||
|
@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 security!: IRouteSecurity;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public extendsProfiles?: 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<SourceProfileDoc | null> {
|
||||||
|
return await SourceProfileDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<SourceProfileDoc[]> {
|
||||||
|
return await SourceProfileDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ts/db/documents/classes.stored-route.doc.ts
Normal file
43
ts/db/documents/classes.stored-route.doc.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public route!: IDcRouterRouteConfig;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public metadata?: IRouteMetadata;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<StoredRouteDoc | null> {
|
||||||
|
return await StoredRouteDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<StoredRouteDoc[]> {
|
||||||
|
return await StoredRouteDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
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({});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ts/db/documents/classes.vlan-mappings.doc.ts
Normal file
32
ts/db/documents/classes.vlan-mappings.doc.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
export interface IMacVlanMapping {
|
||||||
|
mac: string;
|
||||||
|
vlan: number;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VlanMappingsDoc extends plugins.smartdata.SmartDataDbDoc<VlanMappingsDoc, VlanMappingsDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public configId: string = 'vlan-mappings';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public mappings!: IMacVlanMapping[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.mappings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async load(): Promise<VlanMappingsDoc | null> {
|
||||||
|
return await VlanMappingsDoc.getInstance({ configId: 'vlan-mappings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
70
ts/db/documents/classes.vpn-client.doc.ts
Normal file
70
ts/db/documents/classes.vpn-client.doc.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc, VpnClientDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public clientId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public targetProfileIds?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public assignedIp?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPrivateKey?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public destinationAllowList?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public destinationBlockList?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public useHostIp?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public useDhcp?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public staticIp?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public forceVlan?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public vlanId?: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<VpnClientDoc[]> {
|
||||||
|
return await VpnClientDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ts/db/documents/classes.vpn-server-keys.doc.ts
Normal file
31
ts/db/documents/classes.vpn-server-keys.doc.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VpnServerKeysDoc extends plugins.smartdata.SmartDataDbDoc<VpnServerKeysDoc, VpnServerKeysDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public configId: string = 'vpn-server-keys';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePrivateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPrivateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPublicKey!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async load(): Promise<VpnServerKeysDoc | null> {
|
||||||
|
return await VpnServerKeysDoc.getInstance({ configId: 'vpn-server-keys' });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
ts/db/documents/index.ts
Normal file
35
ts/db/documents/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Cached/TTL document classes
|
||||||
|
export * from './classes.cached.email.js';
|
||||||
|
export * from './classes.cached.ip.reputation.js';
|
||||||
|
|
||||||
|
// Config document classes
|
||||||
|
export * from './classes.stored-route.doc.js';
|
||||||
|
export * from './classes.route-override.doc.js';
|
||||||
|
export * from './classes.api-token.doc.js';
|
||||||
|
export * from './classes.source-profile.doc.js';
|
||||||
|
export * from './classes.target-profile.doc.js';
|
||||||
|
export * from './classes.network-target.doc.js';
|
||||||
|
|
||||||
|
// VPN document classes
|
||||||
|
export * from './classes.vpn-server-keys.doc.js';
|
||||||
|
export * from './classes.vpn-client.doc.js';
|
||||||
|
|
||||||
|
// Certificate document classes
|
||||||
|
export * from './classes.acme-cert.doc.js';
|
||||||
|
export * from './classes.proxy-cert.doc.js';
|
||||||
|
export * from './classes.cert-backoff.doc.js';
|
||||||
|
|
||||||
|
// Remote ingress document classes
|
||||||
|
export * from './classes.remote-ingress-edge.doc.js';
|
||||||
|
|
||||||
|
// RADIUS document classes
|
||||||
|
export * from './classes.vlan-mappings.doc.js';
|
||||||
|
export * from './classes.accounting-session.doc.js';
|
||||||
|
|
||||||
|
// DNS / Domain management document classes
|
||||||
|
export * from './classes.dns-provider.doc.js';
|
||||||
|
export * from './classes.domain.doc.js';
|
||||||
|
export * from './classes.dns-record.doc.js';
|
||||||
|
|
||||||
|
// ACME configuration (singleton)
|
||||||
|
export * from './classes.acme-config.doc.js';
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
// Core cache infrastructure
|
// Unified database manager
|
||||||
export * from './classes.cachedb.js';
|
export * from './classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
// TTL base class and constants
|
||||||
export * from './classes.cached.document.js';
|
export * from './classes.cached.document.js';
|
||||||
|
|
||||||
|
// Cache cleaner
|
||||||
export * from './classes.cache.cleaner.js';
|
export * from './classes.cache.cleaner.js';
|
||||||
|
|
||||||
// Document classes
|
// Document classes
|
||||||
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';
|
||||||
880
ts/dns/manager.dns.ts
Normal file
880
ts/dns/manager.dns.ts
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import {
|
||||||
|
DnsProviderDoc,
|
||||||
|
DomainDoc,
|
||||||
|
DnsRecordDoc,
|
||||||
|
} from '../db/documents/index.js';
|
||||||
|
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||||
|
import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js';
|
||||||
|
import { createDnsProvider } from './providers/factory.js';
|
||||||
|
import type {
|
||||||
|
TDnsRecordType,
|
||||||
|
TDnsRecordSource,
|
||||||
|
} from '../../ts_interfaces/data/dns-record.js';
|
||||||
|
import type {
|
||||||
|
TDnsProviderType,
|
||||||
|
TDnsProviderCredentials,
|
||||||
|
IDnsProviderPublic,
|
||||||
|
IProviderDomainListing,
|
||||||
|
} from '../../ts_interfaces/data/dns-provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DnsManager — owns runtime DNS state on top of the embedded DnsServer.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Load Domain/DnsRecord docs from the DB on start
|
||||||
|
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
|
||||||
|
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
|
||||||
|
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
|
||||||
|
* smartdns, provider domains hit the provider API)
|
||||||
|
* - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy()
|
||||||
|
*
|
||||||
|
* Provider-managed domains are NEVER served from the embedded DnsServer — the
|
||||||
|
* provider stays authoritative. We only mirror their records locally for the UI
|
||||||
|
* and to track providerRecordIds for updates / deletes.
|
||||||
|
*/
|
||||||
|
export class DnsManager {
|
||||||
|
/**
|
||||||
|
* Reference to the active smartdns DnsServer (set by DcRouter once it exists).
|
||||||
|
* May be undefined if dnsScopes/dnsNsDomains aren't configured.
|
||||||
|
*/
|
||||||
|
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached provider clients, keyed by DnsProviderDoc.id.
|
||||||
|
* Created lazily when a provider is first needed.
|
||||||
|
*/
|
||||||
|
private providerClients = new Map<string, IDnsProviderClient>();
|
||||||
|
|
||||||
|
constructor(private options: IDcRouterOptions) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Lifecycle
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from DcRouter after DcRouterDb is up. Performs first-boot seeding
|
||||||
|
* from legacy constructor config if (and only if) the DB is empty.
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
logger.log('info', 'DnsManager: starting');
|
||||||
|
await this.seedFromConstructorConfigIfEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
this.providerClients.clear();
|
||||||
|
this.dnsServer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire the embedded DnsServer instance after it has been created by
|
||||||
|
* DcRouter.setupDnsWithSocketHandler(). After this, local records on
|
||||||
|
* dcrouter-hosted domains loaded from the DB are registered with the server.
|
||||||
|
*/
|
||||||
|
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
|
||||||
|
this.dnsServer = dnsServer;
|
||||||
|
await this.applyDcrouterDomainsToDnsServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// First-boot seeding
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
|
||||||
|
* seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with
|
||||||
|
* local (`record.source: 'local'`) records. On subsequent boots (DB has
|
||||||
|
* entries), constructor config is ignored with a warning.
|
||||||
|
*/
|
||||||
|
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
|
||||||
|
const existingDomains = await DomainDoc.findAll();
|
||||||
|
const hasLegacyConfig =
|
||||||
|
(this.options.dnsScopes && this.options.dnsScopes.length > 0) ||
|
||||||
|
(this.options.dnsRecords && this.options.dnsRecords.length > 0);
|
||||||
|
|
||||||
|
if (existingDomains.length > 0) {
|
||||||
|
if (hasLegacyConfig) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
|
||||||
|
'Manage DNS via the Domains UI instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasLegacyConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const seededDomains = new Map<string, DomainDoc>();
|
||||||
|
|
||||||
|
// Create one DomainDoc per dnsScope (these are the authoritative zones)
|
||||||
|
for (const scope of this.options.dnsScopes ?? []) {
|
||||||
|
const domain = new DomainDoc();
|
||||||
|
domain.id = plugins.uuid.v4();
|
||||||
|
domain.name = scope.toLowerCase();
|
||||||
|
domain.source = 'dcrouter';
|
||||||
|
domain.authoritative = true;
|
||||||
|
domain.createdAt = now;
|
||||||
|
domain.updatedAt = now;
|
||||||
|
domain.createdBy = 'seed';
|
||||||
|
await domain.save();
|
||||||
|
seededDomains.set(domain.name, domain);
|
||||||
|
logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map each legacy dnsRecord to its parent DomainDoc
|
||||||
|
for (const rec of this.options.dnsRecords ?? []) {
|
||||||
|
const parent = this.findParentDomain(rec.name, seededDomains);
|
||||||
|
if (!parent) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = new DnsRecordDoc();
|
||||||
|
record.id = plugins.uuid.v4();
|
||||||
|
record.domainId = parent.id;
|
||||||
|
record.name = rec.name.toLowerCase();
|
||||||
|
record.type = rec.type as TDnsRecordType;
|
||||||
|
record.value = rec.value;
|
||||||
|
record.ttl = rec.ttl ?? 300;
|
||||||
|
record.source = 'local';
|
||||||
|
record.createdAt = now;
|
||||||
|
record.updatedAt = now;
|
||||||
|
record.createdBy = 'seed';
|
||||||
|
await record.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findParentDomain(
|
||||||
|
recordName: string,
|
||||||
|
domains: Map<string, DomainDoc>,
|
||||||
|
): DomainDoc | null {
|
||||||
|
const lower = recordName.toLowerCase().replace(/^\*\./, '');
|
||||||
|
let candidate: DomainDoc | null = null;
|
||||||
|
for (const [name, doc] of domains) {
|
||||||
|
if (lower === name || lower.endsWith(`.${name}`)) {
|
||||||
|
if (!candidate || name.length > candidate.name.length) {
|
||||||
|
candidate = doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// DcRouter-hosted domain DnsServer wiring
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all records from dcrouter-hosted domains in the DB with the
|
||||||
|
* embedded DnsServer. Called once after attachDnsServer().
|
||||||
|
*/
|
||||||
|
private async applyDcrouterDomainsToDnsServer(): Promise<void> {
|
||||||
|
if (!this.dnsServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allDomains = await DomainDoc.findAll();
|
||||||
|
const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter');
|
||||||
|
let registered = 0;
|
||||||
|
for (const domain of dcrouterDomains) {
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(domain.id);
|
||||||
|
for (const rec of records) {
|
||||||
|
this.registerRecordWithDnsServer(rec);
|
||||||
|
registered++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`DnsManager: registered ${registered} dcrouter-hosted DNS record(s) from DB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a single record with the embedded DnsServer. The handler closure
|
||||||
|
* captures the record fields, so updates require a re-register cycle.
|
||||||
|
*/
|
||||||
|
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
|
||||||
|
if (!this.dnsServer) return;
|
||||||
|
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
|
||||||
|
if (question.name === rec.name && question.type === rec.type) {
|
||||||
|
return {
|
||||||
|
name: rec.name,
|
||||||
|
type: rec.type,
|
||||||
|
class: 'IN',
|
||||||
|
ttl: rec.ttl,
|
||||||
|
data: this.parseRecordData(rec.type, rec.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRecordData(type: TDnsRecordType, value: string): any {
|
||||||
|
switch (type) {
|
||||||
|
case 'A':
|
||||||
|
case 'AAAA':
|
||||||
|
case 'CNAME':
|
||||||
|
case 'TXT':
|
||||||
|
case 'NS':
|
||||||
|
case 'CAA':
|
||||||
|
return value;
|
||||||
|
case 'MX': {
|
||||||
|
const [priorityStr, exchange] = value.split(' ');
|
||||||
|
return { priority: parseInt(priorityStr, 10), exchange };
|
||||||
|
}
|
||||||
|
case 'SOA': {
|
||||||
|
const parts = value.split(' ');
|
||||||
|
return {
|
||||||
|
mname: parts[0],
|
||||||
|
rname: parts[1],
|
||||||
|
serial: parseInt(parts[2], 10),
|
||||||
|
refresh: parseInt(parts[3], 10),
|
||||||
|
retry: parseInt(parts[4], 10),
|
||||||
|
expire: parseInt(parts[5], 10),
|
||||||
|
minimum: parseInt(parts[6], 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Provider lookup (used by ACME DNS-01 + record CRUD)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the provider client for a given DnsProviderDoc id, instantiating
|
||||||
|
* (and caching) it on first use.
|
||||||
|
*/
|
||||||
|
public async getProviderClientById(providerId: string): Promise<IDnsProviderClient | null> {
|
||||||
|
const cached = this.providerClients.get(providerId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const doc = await DnsProviderDoc.findById(providerId);
|
||||||
|
if (!doc) return null;
|
||||||
|
const client = createDnsProvider(doc.type, doc.credentials);
|
||||||
|
this.providerClients.set(providerId, client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the IDnsProviderClient that owns the given FQDN (by walking up its
|
||||||
|
* labels to find a matching DomainDoc with `source === 'provider'`).
|
||||||
|
* Returns null if no provider claims this FQDN.
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - SmartAcme DNS-01 wiring in setupSmartProxy()
|
||||||
|
* - DnsRecordHandler when creating provider records
|
||||||
|
*/
|
||||||
|
public async getProviderClientForDomain(fqdn: string): Promise<IDnsProviderClient | null> {
|
||||||
|
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
|
||||||
|
const allDomains = await DomainDoc.findAll();
|
||||||
|
const providerDomains = allDomains
|
||||||
|
.filter((d) => d.source === 'provider' && d.providerId)
|
||||||
|
// longest-match wins
|
||||||
|
.sort((a, b) => b.name.length - a.name.length);
|
||||||
|
|
||||||
|
for (const domain of providerDomains) {
|
||||||
|
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
|
||||||
|
return this.getProviderClientById(domain.providerId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
|
||||||
|
* to decide whether to wire SmartAcme with a DNS-01 handler.
|
||||||
|
*/
|
||||||
|
public async hasAcmeCapableProvider(): Promise<boolean> {
|
||||||
|
const providers = await DnsProviderDoc.findAll();
|
||||||
|
return providers.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an IConvenientDnsProvider that dispatches each ACME challenge to
|
||||||
|
* the right provider client (whichever provider type owns the parent zone),
|
||||||
|
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
|
||||||
|
* interface, so any registered provider implementation works.
|
||||||
|
* Returned object plugs directly into smartacme's Dns01Handler.
|
||||||
|
*/
|
||||||
|
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
|
||||||
|
const self = this;
|
||||||
|
const adapter = {
|
||||||
|
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||||
|
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(
|
||||||
|
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
|
||||||
|
'Add one in the Domains > Providers UI before issuing certificates.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Clean any leftover challenge records first to avoid duplicates.
|
||||||
|
try {
|
||||||
|
const existing = await client.listRecords(dnsChallenge.hostName);
|
||||||
|
for (const r of existing) {
|
||||||
|
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
||||||
|
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
await client.createRecord(dnsChallenge.hostName, {
|
||||||
|
name: dnsChallenge.hostName,
|
||||||
|
type: 'TXT',
|
||||||
|
value: dnsChallenge.challenge,
|
||||||
|
ttl: 120,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||||
|
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
||||||
|
if (!client) {
|
||||||
|
// The domain may have been removed; nothing to clean up.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const existing = await client.listRecords(dnsChallenge.hostName);
|
||||||
|
for (const r of existing) {
|
||||||
|
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
||||||
|
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async isDomainSupported(domain: string): Promise<boolean> {
|
||||||
|
const client = await self.getProviderClientForDomain(domain);
|
||||||
|
return !!client;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Provider CRUD (used by DnsProviderHandler)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
public async listProviders(): Promise<IDnsProviderPublic[]> {
|
||||||
|
const docs = await DnsProviderDoc.findAll();
|
||||||
|
return docs.map((d) => this.toPublicProvider(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProvider(id: string): Promise<IDnsProviderPublic | null> {
|
||||||
|
const doc = await DnsProviderDoc.findById(id);
|
||||||
|
return doc ? this.toPublicProvider(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createProvider(args: {
|
||||||
|
name: string;
|
||||||
|
type: TDnsProviderType;
|
||||||
|
credentials: TDnsProviderCredentials;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
if (args.type === 'dcrouter') {
|
||||||
|
throw new Error(
|
||||||
|
'createProvider: cannot create a DnsProviderDoc with type "dcrouter" — ' +
|
||||||
|
'that type is reserved for the built-in pseudo-provider surfaced at read time.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const doc = new DnsProviderDoc();
|
||||||
|
doc.id = plugins.uuid.v4();
|
||||||
|
doc.name = args.name;
|
||||||
|
doc.type = args.type;
|
||||||
|
doc.credentials = args.credentials;
|
||||||
|
doc.status = 'untested';
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.createdBy = args.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
return doc.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProvider(
|
||||||
|
id: string,
|
||||||
|
args: { name?: string; credentials?: TDnsProviderCredentials },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const doc = await DnsProviderDoc.findById(id);
|
||||||
|
if (!doc) return false;
|
||||||
|
if (args.name !== undefined) doc.name = args.name;
|
||||||
|
if (args.credentials !== undefined) {
|
||||||
|
doc.credentials = args.credentials;
|
||||||
|
doc.status = 'untested';
|
||||||
|
doc.lastError = undefined;
|
||||||
|
// Invalidate cached client so the next use re-instantiates with the new credentials.
|
||||||
|
this.providerClients.delete(id);
|
||||||
|
}
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteProvider(id: string, force: boolean): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const doc = await DnsProviderDoc.findById(id);
|
||||||
|
if (!doc) return { success: false, message: 'Provider not found' };
|
||||||
|
const linkedDomains = await DomainDoc.findByProviderId(id);
|
||||||
|
if (linkedDomains.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Provider is referenced by ${linkedDomains.length} domain(s). Pass force: true to delete anyway.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// If forcing, also delete the linked domains and their records.
|
||||||
|
if (force) {
|
||||||
|
for (const domain of linkedDomains) {
|
||||||
|
await this.deleteDomain(domain.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await doc.delete();
|
||||||
|
this.providerClients.delete(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testProvider(id: string): Promise<{ ok: boolean; error?: string; testedAt: number }> {
|
||||||
|
const doc = await DnsProviderDoc.findById(id);
|
||||||
|
if (!doc) {
|
||||||
|
return { ok: false, error: 'Provider not found', testedAt: Date.now() };
|
||||||
|
}
|
||||||
|
const client = createDnsProvider(doc.type, doc.credentials);
|
||||||
|
const result = await client.testConnection();
|
||||||
|
doc.status = result.ok ? 'ok' : 'error';
|
||||||
|
doc.lastTestedAt = Date.now();
|
||||||
|
doc.lastError = result.ok ? undefined : result.error;
|
||||||
|
await doc.save();
|
||||||
|
if (result.ok) {
|
||||||
|
this.providerClients.set(id, client); // cache the working client
|
||||||
|
}
|
||||||
|
return { ok: result.ok, error: result.error, testedAt: doc.lastTestedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listProviderDomains(providerId: string): Promise<IProviderDomainListing[]> {
|
||||||
|
const client = await this.getProviderClientById(providerId);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Provider not found');
|
||||||
|
}
|
||||||
|
return await client.listDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Domain CRUD (used by DomainHandler)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
public async listDomains(): Promise<DomainDoc[]> {
|
||||||
|
return await DomainDoc.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDomain(id: string): Promise<DomainDoc | null> {
|
||||||
|
return await DomainDoc.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
|
||||||
|
* DNS records for this domain via the embedded smartdns.DnsServer.
|
||||||
|
*/
|
||||||
|
public async createDcrouterDomain(args: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
const doc = new DomainDoc();
|
||||||
|
doc.id = plugins.uuid.v4();
|
||||||
|
doc.name = args.name.toLowerCase();
|
||||||
|
doc.source = 'dcrouter';
|
||||||
|
doc.authoritative = true;
|
||||||
|
doc.description = args.description;
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.createdBy = args.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
return doc.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import one or more domains from a provider, pulling all of their DNS
|
||||||
|
* records into local DnsRecordDocs.
|
||||||
|
*/
|
||||||
|
public async importDomainsFromProvider(args: {
|
||||||
|
providerId: string;
|
||||||
|
domainNames: string[];
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const provider = await DnsProviderDoc.findById(args.providerId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Provider not found');
|
||||||
|
}
|
||||||
|
const client = await this.getProviderClientById(args.providerId);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Failed to instantiate provider client');
|
||||||
|
}
|
||||||
|
const allProviderDomains = await client.listDomains();
|
||||||
|
const importedIds: string[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const wantedName of args.domainNames) {
|
||||||
|
const lower = wantedName.toLowerCase();
|
||||||
|
const listing = allProviderDomains.find((d) => d.name.toLowerCase() === lower);
|
||||||
|
if (!listing) {
|
||||||
|
logger.log('warn', `DnsManager: import skipped — provider does not list domain ${wantedName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip if already imported
|
||||||
|
const existing = await DomainDoc.findByName(lower);
|
||||||
|
if (existing) {
|
||||||
|
logger.log('warn', `DnsManager: domain ${wantedName} already imported — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = new DomainDoc();
|
||||||
|
domain.id = plugins.uuid.v4();
|
||||||
|
domain.name = lower;
|
||||||
|
domain.source = 'provider';
|
||||||
|
domain.providerId = args.providerId;
|
||||||
|
domain.authoritative = false;
|
||||||
|
domain.nameservers = listing.nameservers;
|
||||||
|
domain.externalZoneId = listing.externalId;
|
||||||
|
domain.lastSyncedAt = now;
|
||||||
|
domain.createdAt = now;
|
||||||
|
domain.updatedAt = now;
|
||||||
|
domain.createdBy = args.createdBy;
|
||||||
|
await domain.save();
|
||||||
|
importedIds.push(domain.id);
|
||||||
|
|
||||||
|
// Pull records for the imported domain
|
||||||
|
try {
|
||||||
|
const providerRecords = await client.listRecords(lower);
|
||||||
|
for (const pr of providerRecords) {
|
||||||
|
await this.createSyncedRecord(domain.id, pr, args.createdBy);
|
||||||
|
}
|
||||||
|
logger.log('info', `DnsManager: imported ${providerRecords.length} record(s) for ${lower}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `DnsManager: failed to import records for ${lower}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return importedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateDomain(id: string, args: { description?: string }): Promise<boolean> {
|
||||||
|
const doc = await DomainDoc.findById(id);
|
||||||
|
if (!doc) return false;
|
||||||
|
if (args.description !== undefined) doc.description = args.description;
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a domain and all of its DNS records. For provider domains, only
|
||||||
|
* removes the local mirror — does NOT touch the provider.
|
||||||
|
* For dcrouter-hosted domains, also unregisters records from the embedded
|
||||||
|
* DnsServer.
|
||||||
|
*
|
||||||
|
* Note: smartdns has no public unregister-by-name API in the version pinned
|
||||||
|
* here, so local record deletes only take effect after a restart. The DB
|
||||||
|
* is the source of truth and the next start will not register the deleted
|
||||||
|
* record.
|
||||||
|
*/
|
||||||
|
public async deleteDomain(id: string): Promise<boolean> {
|
||||||
|
const doc = await DomainDoc.findById(id);
|
||||||
|
if (!doc) return false;
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(id);
|
||||||
|
for (const r of records) {
|
||||||
|
await r.delete();
|
||||||
|
}
|
||||||
|
await doc.delete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-resync a provider-managed domain: re-pull all records from the
|
||||||
|
* provider API, replacing the cached DnsRecordDocs.
|
||||||
|
*/
|
||||||
|
public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; message?: string }> {
|
||||||
|
const doc = await DomainDoc.findById(id);
|
||||||
|
if (!doc) return { success: false, message: 'Domain not found' };
|
||||||
|
if (doc.source !== 'provider' || !doc.providerId) {
|
||||||
|
return { success: false, message: 'Domain is not provider-managed' };
|
||||||
|
}
|
||||||
|
const client = await this.getProviderClientById(doc.providerId);
|
||||||
|
if (!client) {
|
||||||
|
return { success: false, message: 'Provider client unavailable' };
|
||||||
|
}
|
||||||
|
const providerRecords = await client.listRecords(doc.name);
|
||||||
|
|
||||||
|
// Drop existing records and replace
|
||||||
|
const existing = await DnsRecordDoc.findByDomainId(id);
|
||||||
|
for (const r of existing) {
|
||||||
|
await r.delete();
|
||||||
|
}
|
||||||
|
for (const pr of providerRecords) {
|
||||||
|
await this.createSyncedRecord(id, pr, doc.createdBy);
|
||||||
|
}
|
||||||
|
doc.lastSyncedAt = Date.now();
|
||||||
|
doc.updatedAt = doc.lastSyncedAt;
|
||||||
|
await doc.save();
|
||||||
|
return { success: true, recordCount: providerRecords.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Record CRUD (used by DnsRecordHandler)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
public async listRecordsForDomain(domainId: string): Promise<DnsRecordDoc[]> {
|
||||||
|
return await DnsRecordDoc.findByDomainId(domainId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecord(id: string): Promise<DnsRecordDoc | null> {
|
||||||
|
return await DnsRecordDoc.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createRecord(args: {
|
||||||
|
domainId: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<{ success: boolean; id?: string; message?: string }> {
|
||||||
|
const domain = await DomainDoc.findById(args.domainId);
|
||||||
|
if (!domain) return { success: false, message: 'Domain not found' };
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const doc = new DnsRecordDoc();
|
||||||
|
doc.id = plugins.uuid.v4();
|
||||||
|
doc.domainId = args.domainId;
|
||||||
|
doc.name = args.name.toLowerCase();
|
||||||
|
doc.type = args.type;
|
||||||
|
doc.value = args.value;
|
||||||
|
doc.ttl = args.ttl ?? 300;
|
||||||
|
if (args.proxied !== undefined) doc.proxied = args.proxied;
|
||||||
|
doc.source = 'local';
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.createdBy = args.createdBy;
|
||||||
|
|
||||||
|
if (domain.source === 'provider') {
|
||||||
|
// Push to provider first; only persist locally on success
|
||||||
|
if (!domain.providerId) {
|
||||||
|
return { success: false, message: 'Provider domain has no providerId' };
|
||||||
|
}
|
||||||
|
const client = await this.getProviderClientById(domain.providerId);
|
||||||
|
if (!client) return { success: false, message: 'Provider client unavailable' };
|
||||||
|
try {
|
||||||
|
const created = await client.createRecord(domain.name, {
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
value: doc.value,
|
||||||
|
ttl: doc.ttl,
|
||||||
|
proxied: doc.proxied,
|
||||||
|
});
|
||||||
|
doc.providerRecordId = created.providerRecordId;
|
||||||
|
doc.source = 'synced';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// dcrouter-hosted / authoritative — register with embedded DnsServer immediately
|
||||||
|
this.registerRecordWithDnsServer(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
return { success: true, id: doc.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRecord(args: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const doc = await DnsRecordDoc.findById(args.id);
|
||||||
|
if (!doc) return { success: false, message: 'Record not found' };
|
||||||
|
const domain = await DomainDoc.findById(doc.domainId);
|
||||||
|
if (!domain) return { success: false, message: 'Parent domain not found' };
|
||||||
|
|
||||||
|
if (args.name !== undefined) doc.name = args.name.toLowerCase();
|
||||||
|
if (args.value !== undefined) doc.value = args.value;
|
||||||
|
if (args.ttl !== undefined) doc.ttl = args.ttl;
|
||||||
|
if (args.proxied !== undefined) doc.proxied = args.proxied;
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
|
||||||
|
if (domain.source === 'provider') {
|
||||||
|
if (!domain.providerId || !doc.providerRecordId) {
|
||||||
|
return { success: false, message: 'Provider record metadata missing' };
|
||||||
|
}
|
||||||
|
const client = await this.getProviderClientById(domain.providerId);
|
||||||
|
if (!client) return { success: false, message: 'Provider client unavailable' };
|
||||||
|
try {
|
||||||
|
await client.updateRecord(domain.name, doc.providerRecordId, {
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
value: doc.value,
|
||||||
|
ttl: doc.ttl,
|
||||||
|
proxied: doc.proxied,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Re-register the local record so the new closure picks up the updated fields
|
||||||
|
this.registerRecordWithDnsServer(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const doc = await DnsRecordDoc.findById(id);
|
||||||
|
if (!doc) return { success: false, message: 'Record not found' };
|
||||||
|
const domain = await DomainDoc.findById(doc.domainId);
|
||||||
|
if (!domain) return { success: false, message: 'Parent domain not found' };
|
||||||
|
|
||||||
|
if (domain.source === 'provider') {
|
||||||
|
if (domain.providerId && doc.providerRecordId) {
|
||||||
|
const client = await this.getProviderClientById(domain.providerId);
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.deleteRecord(domain.name, doc.providerRecordId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: `Provider rejected delete: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For local records: smartdns has no unregister API in the pinned version,
|
||||||
|
// so the record stays served until the next restart. The DB delete still
|
||||||
|
// takes effect — on restart, the record will not be re-registered.
|
||||||
|
|
||||||
|
await doc.delete();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Internal helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
private async createSyncedRecord(
|
||||||
|
domainId: string,
|
||||||
|
pr: IProviderRecord,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const doc = new DnsRecordDoc();
|
||||||
|
doc.id = plugins.uuid.v4();
|
||||||
|
doc.domainId = domainId;
|
||||||
|
doc.name = pr.name.toLowerCase();
|
||||||
|
doc.type = pr.type;
|
||||||
|
doc.value = pr.value;
|
||||||
|
doc.ttl = pr.ttl;
|
||||||
|
if (pr.proxied !== undefined) doc.proxied = pr.proxied;
|
||||||
|
doc.source = 'synced';
|
||||||
|
doc.providerRecordId = pr.providerRecordId;
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.createdBy = createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DnsProviderDoc to its public, secret-stripped representation
|
||||||
|
* for the OpsServer API.
|
||||||
|
*/
|
||||||
|
public toPublicProvider(doc: DnsProviderDoc): IDnsProviderPublic {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
status: doc.status,
|
||||||
|
lastTestedAt: doc.lastTestedAt,
|
||||||
|
lastError: doc.lastError,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
hasCredentials: !!doc.credentials,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DomainDoc to its plain interface representation.
|
||||||
|
*/
|
||||||
|
public toPublicDomain(doc: DomainDoc): {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source: 'dcrouter' | 'provider';
|
||||||
|
providerId?: string;
|
||||||
|
authoritative: boolean;
|
||||||
|
nameservers?: string[];
|
||||||
|
externalZoneId?: string;
|
||||||
|
lastSyncedAt?: number;
|
||||||
|
description?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
source: doc.source,
|
||||||
|
providerId: doc.providerId,
|
||||||
|
authoritative: doc.authoritative,
|
||||||
|
nameservers: doc.nameservers,
|
||||||
|
externalZoneId: doc.externalZoneId,
|
||||||
|
lastSyncedAt: doc.lastSyncedAt,
|
||||||
|
description: doc.description,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DnsRecordDoc to its plain interface representation.
|
||||||
|
*/
|
||||||
|
public toPublicRecord(doc: DnsRecordDoc): {
|
||||||
|
id: string;
|
||||||
|
domainId: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
source: TDnsRecordSource;
|
||||||
|
providerRecordId?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
domainId: doc.domainId,
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
value: doc.value,
|
||||||
|
ttl: doc.ttl,
|
||||||
|
proxied: doc.proxied,
|
||||||
|
source: doc.source,
|
||||||
|
providerRecordId: doc.providerRecordId,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
131
ts/dns/providers/cloudflare.provider.ts
Normal file
131
ts/dns/providers/cloudflare.provider.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import type {
|
||||||
|
IDnsProviderClient,
|
||||||
|
IConnectionTestResult,
|
||||||
|
IProviderRecord,
|
||||||
|
IProviderRecordInput,
|
||||||
|
} from './interfaces.js';
|
||||||
|
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare implementation of IDnsProviderClient.
|
||||||
|
*
|
||||||
|
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
|
||||||
|
* an internal record id, which we surface as `providerRecordId` so the rest
|
||||||
|
* of the system can issue updates and deletes without ambiguity (Cloudflare
|
||||||
|
* can have multiple records of the same name+type).
|
||||||
|
*/
|
||||||
|
export class CloudflareDnsProvider implements IDnsProviderClient {
|
||||||
|
private cfAccount: plugins.cloudflare.CloudflareAccount;
|
||||||
|
|
||||||
|
constructor(apiToken: string) {
|
||||||
|
if (!apiToken) {
|
||||||
|
throw new Error('CloudflareDnsProvider: apiToken is required');
|
||||||
|
}
|
||||||
|
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testConnection(): Promise<IConnectionTestResult> {
|
||||||
|
try {
|
||||||
|
// Listing zones is the lightest-weight call that proves the token works.
|
||||||
|
await this.cfAccount.zoneManager.listZones();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listDomains(): Promise<IProviderDomainListing[]> {
|
||||||
|
const zones = await this.cfAccount.zoneManager.listZones();
|
||||||
|
return zones.map((zone) => ({
|
||||||
|
name: zone.name,
|
||||||
|
externalId: zone.id,
|
||||||
|
nameservers: zone.name_servers ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listRecords(domain: string): Promise<IProviderRecord[]> {
|
||||||
|
const records = await this.cfAccount.recordManager.listRecords(domain);
|
||||||
|
return records
|
||||||
|
.filter((r) => this.isSupportedType(r.type))
|
||||||
|
.map((r) => ({
|
||||||
|
providerRecordId: r.id,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type as TDnsRecordType,
|
||||||
|
value: r.content,
|
||||||
|
ttl: r.ttl,
|
||||||
|
proxied: r.proxied,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createRecord(
|
||||||
|
domain: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
const apiRecord: any = {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.value,
|
||||||
|
ttl: record.ttl ?? 1, // 1 = automatic
|
||||||
|
};
|
||||||
|
if (record.proxied !== undefined) {
|
||||||
|
apiRecord.proxied = record.proxied;
|
||||||
|
}
|
||||||
|
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
|
||||||
|
return {
|
||||||
|
providerRecordId: created.id,
|
||||||
|
name: created.name,
|
||||||
|
type: created.type as TDnsRecordType,
|
||||||
|
value: created.content,
|
||||||
|
ttl: created.ttl,
|
||||||
|
proxied: created.proxied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRecord(
|
||||||
|
domain: string,
|
||||||
|
providerRecordId: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
const apiRecord: any = {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.value,
|
||||||
|
ttl: record.ttl ?? 1,
|
||||||
|
};
|
||||||
|
if (record.proxied !== undefined) {
|
||||||
|
apiRecord.proxied = record.proxied;
|
||||||
|
}
|
||||||
|
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
|
||||||
|
providerRecordId,
|
||||||
|
apiRecord,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
providerRecordId: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
type: updated.type as TDnsRecordType,
|
||||||
|
value: updated.content,
|
||||||
|
ttl: updated.ttl,
|
||||||
|
proxied: updated.proxied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
|
||||||
|
zone_id: zoneId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSupportedType(type: string): boolean {
|
||||||
|
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
ts/dns/providers/factory.ts
Normal file
59
ts/dns/providers/factory.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { IDnsProviderClient } from './interfaces.js';
|
||||||
|
import type {
|
||||||
|
TDnsProviderType,
|
||||||
|
TDnsProviderCredentials,
|
||||||
|
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
import { CloudflareDnsProvider } from './cloudflare.provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
|
||||||
|
*
|
||||||
|
* @throws if the provider type is not supported.
|
||||||
|
*
|
||||||
|
* ## Adding a new provider (e.g. Route53)
|
||||||
|
*
|
||||||
|
* 1. **Type union** — extend `TDnsProviderType` in
|
||||||
|
* `ts_interfaces/data/dns-provider.ts` (e.g. `'cloudflare' | 'route53'`).
|
||||||
|
* 2. **Credentials interface** — add `IRoute53Credentials` and append it to
|
||||||
|
* the `TDnsProviderCredentials` discriminated union.
|
||||||
|
* 3. **Descriptor** — append a new entry to `dnsProviderTypeDescriptors` so
|
||||||
|
* the OpsServer UI picks up the new type and renders the right credential
|
||||||
|
* form fields automatically.
|
||||||
|
* 4. **Provider class** — create `ts/dns/providers/route53.provider.ts`
|
||||||
|
* implementing `IDnsProviderClient`.
|
||||||
|
* 5. **Factory case** — add a new `case 'route53':` below. The
|
||||||
|
* `_exhaustive: never` line will fail to compile until you do.
|
||||||
|
* 6. **Index** — re-export the new class from `ts/dns/providers/index.ts`.
|
||||||
|
*/
|
||||||
|
export function createDnsProvider(
|
||||||
|
type: TDnsProviderType,
|
||||||
|
credentials: TDnsProviderCredentials,
|
||||||
|
): IDnsProviderClient {
|
||||||
|
switch (type) {
|
||||||
|
case 'cloudflare': {
|
||||||
|
if (credentials.type !== 'cloudflare') {
|
||||||
|
throw new Error(
|
||||||
|
`createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new CloudflareDnsProvider(credentials.apiToken);
|
||||||
|
}
|
||||||
|
case 'dcrouter': {
|
||||||
|
// The built-in DcRouter pseudo-provider has no runtime client — dcrouter
|
||||||
|
// itself serves the records via the embedded smartdns.DnsServer. This
|
||||||
|
// case exists only to satisfy the exhaustive switch; it should never
|
||||||
|
// actually run because the handler layer rejects any CRUD that would
|
||||||
|
// result in a DnsProviderDoc with type: 'dcrouter'.
|
||||||
|
throw new Error(
|
||||||
|
`createDnsProvider: 'dcrouter' is a built-in pseudo-provider — no runtime client exists. ` +
|
||||||
|
`This call indicates a DnsProviderDoc with type: 'dcrouter' was persisted, which should never happen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// If you see a TypeScript error here after extending TDnsProviderType,
|
||||||
|
// add a `case` for the new type above. The `never` enforces exhaustiveness.
|
||||||
|
const _exhaustive: never = type;
|
||||||
|
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
ts/dns/providers/index.ts
Normal file
3
ts/dns/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './interfaces.js';
|
||||||
|
export * from './cloudflare.provider.js';
|
||||||
|
export * from './factory.js';
|
||||||
67
ts/dns/providers/interfaces.ts
Normal file
67
ts/dns/providers/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DNS record as seen at a provider's API. The `providerRecordId` field
|
||||||
|
* is the provider's internal identifier, used for subsequent updates and
|
||||||
|
* deletes (since providers can have multiple records of the same name+type).
|
||||||
|
*/
|
||||||
|
export interface IProviderRecord {
|
||||||
|
providerRecordId: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input shape for creating / updating a DNS record at a provider.
|
||||||
|
*/
|
||||||
|
export interface IProviderRecordInput {
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a connection test against a provider's API.
|
||||||
|
*/
|
||||||
|
export interface IConnectionTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluggable DNS provider client interface. One implementation per provider type
|
||||||
|
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
|
||||||
|
* instantiated by `createDnsProvider()` in factory.ts.
|
||||||
|
*
|
||||||
|
* NOT a smartdata interface — this is the *runtime* client. The persisted
|
||||||
|
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
|
||||||
|
*/
|
||||||
|
export interface IDnsProviderClient {
|
||||||
|
/** Lightweight check that credentials are valid and the API is reachable. */
|
||||||
|
testConnection(): Promise<IConnectionTestResult>;
|
||||||
|
|
||||||
|
/** List all DNS zones visible to this provider account. */
|
||||||
|
listDomains(): Promise<IProviderDomainListing[]>;
|
||||||
|
|
||||||
|
/** List all DNS records for a zone (FQDN). */
|
||||||
|
listRecords(domain: string): Promise<IProviderRecord[]>;
|
||||||
|
|
||||||
|
/** Create a new DNS record at the provider; returns the created record (with id). */
|
||||||
|
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
|
||||||
|
|
||||||
|
/** Update an existing record by provider id; returns the updated record. */
|
||||||
|
updateRecord(
|
||||||
|
domain: string,
|
||||||
|
providerRecordId: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord>;
|
||||||
|
|
||||||
|
/** Delete a record by provider id. */
|
||||||
|
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -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,6 +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 sourceProfileHandler!: handlers.SourceProfileHandler;
|
||||||
|
private targetProfileHandler!: handlers.TargetProfileHandler;
|
||||||
|
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||||
|
private usersHandler!: handlers.UsersHandler;
|
||||||
|
private dnsProviderHandler!: handlers.DnsProviderHandler;
|
||||||
|
private domainHandler!: handlers.DomainHandler;
|
||||||
|
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||||
|
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -88,6 +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.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
||||||
|
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
||||||
|
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||||
|
this.usersHandler = new handlers.UsersHandler(this);
|
||||||
|
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
|
||||||
|
this.domainHandler = new handlers.DomainHandler(this);
|
||||||
|
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||||
|
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
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 { 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) {
|
||||||
@@ -42,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);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -187,30 +210,32 @@ export class CertificateHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check persisted cert data from StorageManager
|
// Check persisted cert data from smartdata document classes
|
||||||
if (status === 'unknown') {
|
if (status === 'unknown') {
|
||||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
// SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
|
||||||
if (!certData) {
|
const parts = cleanDomain.split('.');
|
||||||
// Also check certStore path (proxy-certs)
|
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
|
||||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||||
}
|
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||||
if (certData?.validUntil) {
|
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
|
||||||
expiryDate = new Date(certData.validUntil).toISOString();
|
|
||||||
if (certData.created) {
|
if (acmeDoc?.validUntil) {
|
||||||
issuedAt = new Date(certData.created).toISOString();
|
expiryDate = new Date(acmeDoc.validUntil).toISOString();
|
||||||
|
if (acmeDoc.created) {
|
||||||
|
issuedAt = new Date(acmeDoc.created).toISOString();
|
||||||
}
|
}
|
||||||
issuer = 'smartacme-dns-01';
|
issuer = 'smartacme-dns-01';
|
||||||
} else if (certData?.publicKey) {
|
} else if (proxyDoc?.publicKey) {
|
||||||
// certStore has the cert — parse PEM for expiry
|
// certStore has the cert — parse PEM for expiry
|
||||||
try {
|
try {
|
||||||
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
|
const x509 = new plugins.crypto.X509Certificate(proxyDoc.publicKey);
|
||||||
expiryDate = new Date(x509.validTo).toISOString();
|
expiryDate = new Date(x509.validTo).toISOString();
|
||||||
issuedAt = new Date(x509.validFrom).toISOString();
|
issuedAt = new Date(x509.validFrom).toISOString();
|
||||||
} catch { /* PEM parsing failed */ }
|
} catch { /* PEM parsing failed */ }
|
||||||
status = 'valid';
|
status = 'valid';
|
||||||
issuer = 'cert-store';
|
issuer = 'cert-store';
|
||||||
} else if (certData) {
|
} else if (acmeDoc || proxyDoc) {
|
||||||
status = 'valid';
|
status = 'valid';
|
||||||
issuer = 'cert-store';
|
issuer = 'cert-store';
|
||||||
}
|
}
|
||||||
@@ -292,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;
|
||||||
@@ -302,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) {
|
||||||
@@ -317,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;
|
||||||
|
|
||||||
@@ -332,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(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -365,19 +522,21 @@ 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 all known storage paths
|
// Delete from smartdata document classes (try base domain first, then exact)
|
||||||
const paths = [
|
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||||
`/proxy-certs/${domain}`,
|
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||||
`/proxy-certs/${cleanDomain}`,
|
if (acmeDoc) {
|
||||||
`/certs/${cleanDomain}`,
|
await acmeDoc.delete();
|
||||||
];
|
}
|
||||||
|
|
||||||
for (const path of paths) {
|
// Try both original domain and clean domain for proxy certs
|
||||||
try {
|
for (const d of [domain, cleanDomain]) {
|
||||||
await dcRouter.storageManager.delete(path);
|
const proxyDoc = await ProxyCertDoc.findByDomain(d);
|
||||||
} catch {
|
if (proxyDoc) {
|
||||||
// Path may not exist — ignore
|
await proxyDoc.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,43 +567,41 @@ export class CertificateHandler {
|
|||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
}> {
|
}> {
|
||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
||||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
// Try SmartAcme /certs/ path first (has full ICert fields)
|
// Try AcmeCertDoc first (has full ICert fields)
|
||||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||||
if (certData && certData.publicKey && certData.privateKey) {
|
if (acmeDoc && acmeDoc.publicKey && acmeDoc.privateKey) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
cert: {
|
cert: {
|
||||||
id: certData.id || plugins.crypto.randomUUID(),
|
id: acmeDoc.id || plugins.crypto.randomUUID(),
|
||||||
domainName: certData.domainName || domain,
|
domainName: acmeDoc.domainName || domain,
|
||||||
created: certData.created || Date.now(),
|
created: acmeDoc.created || Date.now(),
|
||||||
validUntil: certData.validUntil || 0,
|
validUntil: acmeDoc.validUntil || 0,
|
||||||
privateKey: certData.privateKey,
|
privateKey: acmeDoc.privateKey,
|
||||||
publicKey: certData.publicKey,
|
publicKey: acmeDoc.publicKey,
|
||||||
csr: certData.csr || '',
|
csr: acmeDoc.csr || '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try /proxy-certs/ with original domain
|
// Fallback: try ProxyCertDoc with original domain, then clean domain
|
||||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
let proxyDoc = await ProxyCertDoc.findByDomain(domain);
|
||||||
if (!certData || !certData.publicKey) {
|
if (!proxyDoc || !proxyDoc.publicKey) {
|
||||||
// Try with clean domain
|
proxyDoc = await ProxyCertDoc.findByDomain(cleanDomain);
|
||||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (certData && certData.publicKey && certData.privateKey) {
|
if (proxyDoc && proxyDoc.publicKey && proxyDoc.privateKey) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
cert: {
|
cert: {
|
||||||
id: plugins.crypto.randomUUID(),
|
id: plugins.crypto.randomUUID(),
|
||||||
domainName: domain,
|
domainName: domain,
|
||||||
created: certData.validFrom || Date.now(),
|
created: proxyDoc.validFrom || Date.now(),
|
||||||
validUntil: certData.validUntil || 0,
|
validUntil: proxyDoc.validUntil || 0,
|
||||||
privateKey: certData.privateKey,
|
privateKey: proxyDoc.privateKey,
|
||||||
publicKey: certData.publicKey,
|
publicKey: proxyDoc.publicKey,
|
||||||
csr: '',
|
csr: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -476,26 +633,32 @@ export class CertificateHandler {
|
|||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
// Save to /certs/ (SmartAcme-compatible path)
|
// Save to AcmeCertDoc (SmartAcme-compatible)
|
||||||
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
|
let acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||||
id: cert.id,
|
if (!acmeDoc) {
|
||||||
domainName: cert.domainName,
|
acmeDoc = new AcmeCertDoc();
|
||||||
created: cert.created,
|
acmeDoc.domainName = cleanDomain;
|
||||||
validUntil: cert.validUntil,
|
}
|
||||||
privateKey: cert.privateKey,
|
acmeDoc.id = cert.id;
|
||||||
publicKey: cert.publicKey,
|
acmeDoc.created = cert.created;
|
||||||
csr: cert.csr || '',
|
acmeDoc.validUntil = cert.validUntil;
|
||||||
});
|
acmeDoc.privateKey = cert.privateKey;
|
||||||
|
acmeDoc.publicKey = cert.publicKey;
|
||||||
|
acmeDoc.csr = cert.csr || '';
|
||||||
|
await acmeDoc.save();
|
||||||
|
|
||||||
// Also save to /proxy-certs/ (proxy-cert format)
|
// Also save to ProxyCertDoc (proxy-cert format)
|
||||||
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
|
let proxyDoc = await ProxyCertDoc.findByDomain(cert.domainName);
|
||||||
domain: cert.domainName,
|
if (!proxyDoc) {
|
||||||
publicKey: cert.publicKey,
|
proxyDoc = new ProxyCertDoc();
|
||||||
privateKey: cert.privateKey,
|
proxyDoc.domain = cert.domainName;
|
||||||
ca: undefined,
|
}
|
||||||
validUntil: cert.validUntil,
|
proxyDoc.publicKey = cert.publicKey;
|
||||||
validFrom: cert.created,
|
proxyDoc.privateKey = cert.privateKey;
|
||||||
});
|
proxyDoc.ca = '';
|
||||||
|
proxyDoc.validUntil = cert.validUntil;
|
||||||
|
proxyDoc.validFrom = cert.created;
|
||||||
|
await proxyDoc.save();
|
||||||
|
|
||||||
// Update in-memory status map
|
// Update in-memory status map
|
||||||
dcRouter.certificateStatusMap.set(cert.domainName, {
|
dcRouter.certificateStatusMap.set(cert.domainName, {
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ export class ConfigHandler {
|
|||||||
const resolvedPaths = dcRouter.resolvedPaths;
|
const resolvedPaths = dcRouter.resolvedPaths;
|
||||||
|
|
||||||
// --- System ---
|
// --- System ---
|
||||||
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction
|
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.dbConfig?.mongoDbUrl
|
||||||
? 'custom'
|
? 'custom'
|
||||||
: opts.storage?.fsPath
|
: 'filesystem';
|
||||||
? 'filesystem'
|
|
||||||
: 'memory';
|
|
||||||
|
|
||||||
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
||||||
let proxyIps = opts.proxyIps || [];
|
let proxyIps = opts.proxyIps || [];
|
||||||
@@ -55,7 +53,7 @@ export class ConfigHandler {
|
|||||||
proxyIps,
|
proxyIps,
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
storageBackend,
|
storageBackend,
|
||||||
storagePath: opts.storage?.fsPath || null,
|
storagePath: opts.dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- SmartProxy ---
|
// --- SmartProxy ---
|
||||||
@@ -125,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,
|
||||||
@@ -132,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 ---
|
||||||
@@ -151,15 +158,15 @@ export class ConfigHandler {
|
|||||||
keyPath: opts.tls?.keyPath || null,
|
keyPath: opts.tls?.keyPath || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Cache ---
|
// --- Database ---
|
||||||
const cacheConfig = opts.cacheConfig;
|
const dbConfig = opts.dbConfig;
|
||||||
const cache: interfaces.requests.IConfigData['cache'] = {
|
const cache: interfaces.requests.IConfigData['cache'] = {
|
||||||
enabled: cacheConfig?.enabled !== false,
|
enabled: dbConfig?.enabled !== false,
|
||||||
storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
storagePath: dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||||
dbName: cacheConfig?.dbName || 'dcrouter',
|
dbName: dbConfig?.dbName || 'dcrouter',
|
||||||
defaultTTLDays: cacheConfig?.defaultTTLDays || 30,
|
defaultTTLDays: 30,
|
||||||
cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1,
|
cleanupIntervalHours: dbConfig?.cleanupIntervalHours || 1,
|
||||||
ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record<string, number> : {},
|
ttlConfig: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- RADIUS ---
|
// --- RADIUS ---
|
||||||
@@ -185,7 +192,8 @@ export class ConfigHandler {
|
|||||||
tlsMode = 'custom';
|
tlsMode = 'custom';
|
||||||
} else if (riCfg?.hubDomain) {
|
} else if (riCfg?.hubDomain) {
|
||||||
try {
|
try {
|
||||||
const stored = await dcRouter.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
|
const { ProxyCertDoc } = await import('../../db/index.js');
|
||||||
|
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
|
||||||
if (stored?.publicKey && stored?.privateKey) {
|
if (stored?.publicKey && stored?.privateKey) {
|
||||||
tlsMode = 'acme';
|
tlsMode = 'acme';
|
||||||
}
|
}
|
||||||
|
|||||||
197
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
197
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD + connection-test handlers for DnsProviderDoc.
|
||||||
|
*
|
||||||
|
* Auth: same dual-mode pattern as TargetProfileHandler — admin JWT or
|
||||||
|
* API token with the appropriate `dns-providers:read|write` scope.
|
||||||
|
*/
|
||||||
|
export class DnsProviderHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all providers — prepends the built-in DcRouter pseudo-provider
|
||||||
|
// so operators see a uniform "who serves this?" list that includes the
|
||||||
|
// authoritative dcrouter alongside external accounts.
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProviders>(
|
||||||
|
'getDnsProviders',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
const synthetic: interfaces.data.IDnsProviderPublic = {
|
||||||
|
id: interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID,
|
||||||
|
name: 'DcRouter',
|
||||||
|
type: 'dcrouter',
|
||||||
|
status: 'ok',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
createdBy: 'system',
|
||||||
|
hasCredentials: false,
|
||||||
|
builtIn: true,
|
||||||
|
};
|
||||||
|
const real = dnsManager ? await dnsManager.listProviders() : [];
|
||||||
|
return { providers: [synthetic, ...real] };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProvider>(
|
||||||
|
'getDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { provider: null };
|
||||||
|
return { provider: await dnsManager.getProvider(dataArg.id) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsProvider>(
|
||||||
|
'createDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'dns-providers:write');
|
||||||
|
if (dataArg.type === 'dcrouter') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'cannot create built-in provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) {
|
||||||
|
return { success: false, message: 'DnsManager not initialized (DB disabled?)' };
|
||||||
|
}
|
||||||
|
const id = await dnsManager.createProvider({
|
||||||
|
name: dataArg.name,
|
||||||
|
type: dataArg.type,
|
||||||
|
credentials: dataArg.credentials,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsProvider>(
|
||||||
|
'updateDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:write');
|
||||||
|
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||||
|
return { success: false, message: 'cannot edit built-in provider' };
|
||||||
|
}
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
const ok = await dnsManager.updateProvider(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
credentials: dataArg.credentials,
|
||||||
|
});
|
||||||
|
return ok ? { success: true } : { success: false, message: 'Provider not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsProvider>(
|
||||||
|
'deleteDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:write');
|
||||||
|
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||||
|
return { success: false, message: 'cannot delete built-in provider' };
|
||||||
|
}
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test provider connection
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestDnsProvider>(
|
||||||
|
'testDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||||
|
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'built-in provider has no external connection to test',
|
||||||
|
testedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) {
|
||||||
|
return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() };
|
||||||
|
}
|
||||||
|
return await dnsManager.testProvider(dataArg.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// List domains visible to a provider's account (without importing them)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListProviderDomains>(
|
||||||
|
'listProviderDomains',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||||
|
if (dataArg.providerId === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'built-in provider has no external domain listing — use "Add DcRouter Domain" instead',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
try {
|
||||||
|
const domains = await dnsManager.listProviderDomains(dataArg.providerId);
|
||||||
|
return { success: true, domains };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD handlers for DnsRecordDoc.
|
||||||
|
*/
|
||||||
|
export class DnsRecordHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get records by domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
|
||||||
|
'getDnsRecords',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { records: [] };
|
||||||
|
const docs = await dnsManager.listRecordsForDomain(dataArg.domainId);
|
||||||
|
return { records: docs.map((d) => dnsManager.toPublicRecord(d)) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecord>(
|
||||||
|
'getDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { record: null };
|
||||||
|
const doc = await dnsManager.getRecord(dataArg.id);
|
||||||
|
return { record: doc ? dnsManager.toPublicRecord(doc) : null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
|
||||||
|
'createDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.createRecord({
|
||||||
|
domainId: dataArg.domainId,
|
||||||
|
name: dataArg.name,
|
||||||
|
type: dataArg.type,
|
||||||
|
value: dataArg.value,
|
||||||
|
ttl: dataArg.ttl,
|
||||||
|
proxied: dataArg.proxied,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
|
||||||
|
'updateDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.updateRecord({
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
value: dataArg.value,
|
||||||
|
ttl: dataArg.ttl,
|
||||||
|
proxied: dataArg.proxied,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
|
||||||
|
'deleteDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.deleteRecord(dataArg.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 dcrouter-hosted domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDomain>(
|
||||||
|
'createDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
try {
|
||||||
|
const id = await dnsManager.createDcrouterDomain({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import domains from a provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportDomain>(
|
||||||
|
'importDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
try {
|
||||||
|
const importedIds = await dnsManager.importDomainsFromProvider({
|
||||||
|
providerId: dataArg.providerId,
|
||||||
|
domainNames: dataArg.domainNames,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, importedIds };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update domain metadata
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDomain>(
|
||||||
|
'updateDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
const ok = await dnsManager.updateDomain(dataArg.id, {
|
||||||
|
description: dataArg.description,
|
||||||
|
});
|
||||||
|
return ok ? { success: true } : { success: false, message: 'Domain not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDomain>(
|
||||||
|
'deleteDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
const ok = await dnsManager.deleteDomain(dataArg.id);
|
||||||
|
return ok ? { success: true } : { success: false, message: 'Domain not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Force-resync provider domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomain>(
|
||||||
|
'syncDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.syncDomain(dataArg.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,12 @@ export * from './certificate.handler.js';
|
|||||||
export * from './remoteingress.handler.js';
|
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 './source-profile.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);
|
||||||
};
|
};
|
||||||
|
|||||||
167
ts/opsserver/handlers/network-target.handler.ts
Normal file
167
ts/opsserver/handlers/network-target.handler.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class NetworkTargetHandler {
|
||||||
|
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 network targets
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
'getNetworkTargets',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { targets: [] };
|
||||||
|
}
|
||||||
|
return { targets: resolver.listTargets() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTarget>(
|
||||||
|
'getNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { target: null };
|
||||||
|
}
|
||||||
|
return { target: resolver.getTarget(dataArg.id) || null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||||
|
'createNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { success: false, message: 'Reference resolver not initialized' };
|
||||||
|
}
|
||||||
|
const id = await resolver.createTarget({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateNetworkTarget>(
|
||||||
|
'updateNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { affectedRouteIds } = await resolver.updateTarget(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (affectedRouteIds.length > 0) {
|
||||||
|
await manager.reResolveRoutes(affectedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, affectedRouteCount: affectedRouteIds.length };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteNetworkTarget>(
|
||||||
|
'deleteNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolver.deleteTarget(
|
||||||
|
dataArg.id,
|
||||||
|
dataArg.force ?? false,
|
||||||
|
manager.getStoredRoutes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && dataArg.force) {
|
||||||
|
await manager.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get routes using a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||||
|
'getNetworkTargetUsage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { routes: [] };
|
||||||
|
}
|
||||||
|
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||||
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export class RouteManagementHandler {
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'Route management not initialized' };
|
return { success: false, message: 'Route management not initialized' };
|
||||||
}
|
}
|
||||||
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true);
|
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
|
||||||
return { success: true, storedRouteId: id };
|
return { success: true, storedRouteId: id };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -90,6 +90,7 @@ export class RouteManagementHandler {
|
|||||||
const ok = await manager.updateRoute(dataArg.id, {
|
const ok = await manager.updateRoute(dataArg.id, {
|
||||||
route: dataArg.route as any,
|
route: dataArg.route as any,
|
||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
|
metadata: dataArg.metadata,
|
||||||
});
|
});
|
||||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||||
},
|
},
|
||||||
|
|||||||
169
ts/opsserver/handlers/source-profile.handler.ts
Normal file
169
ts/opsserver/handlers/source-profile.handler.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class SourceProfileHandler {
|
||||||
|
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 source profiles
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfiles>(
|
||||||
|
'getSourceProfiles',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'source-profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { profiles: [] };
|
||||||
|
}
|
||||||
|
return { profiles: resolver.listProfiles() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single source profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfile>(
|
||||||
|
'getSourceProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'source-profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { profile: null };
|
||||||
|
}
|
||||||
|
return { profile: resolver.getProfile(dataArg.id) || null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a source profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSourceProfile>(
|
||||||
|
'createSourceProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'source-profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { success: false, message: 'Reference resolver not initialized' };
|
||||||
|
}
|
||||||
|
const id = await resolver.createProfile({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a source profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSourceProfile>(
|
||||||
|
'updateSourceProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'source-profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { affectedRouteIds } = await resolver.updateProfile(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Propagate to affected routes
|
||||||
|
if (affectedRouteIds.length > 0) {
|
||||||
|
await manager.reResolveRoutes(affectedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, affectedRouteCount: affectedRouteIds.length };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a source profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSourceProfile>(
|
||||||
|
'deleteSourceProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'source-profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolver.deleteProfile(
|
||||||
|
dataArg.id,
|
||||||
|
dataArg.force ?? false,
|
||||||
|
manager.getStoredRoutes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If force-deleted with affected routes, re-apply
|
||||||
|
if (result.success && dataArg.force) {
|
||||||
|
await manager.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get routes using a source profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfileUsage>(
|
||||||
|
'getSourceProfileUsage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'source-profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { routes: [] };
|
||||||
|
}
|
||||||
|
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||||
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,12 +25,19 @@ 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,
|
||||||
tags: c.tags,
|
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,
|
||||||
|
destinationAllowList: c.destinationAllowList,
|
||||||
|
destinationBlockList: c.destinationBlockList,
|
||||||
|
useHostIp: c.useHostIp,
|
||||||
|
useDhcp: c.useDhcp,
|
||||||
|
staticIp: c.staticIp,
|
||||||
|
forceVlan: c.forceVlan,
|
||||||
|
vlanId: c.vlanId,
|
||||||
}));
|
}));
|
||||||
return { clients };
|
return { clients };
|
||||||
},
|
},
|
||||||
@@ -48,7 +55,6 @@ export class VpnHandler {
|
|||||||
return {
|
return {
|
||||||
status: {
|
status: {
|
||||||
running: false,
|
running: false,
|
||||||
forwardingMode: 'socket' as const,
|
|
||||||
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
||||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
serverPublicKeys: null,
|
serverPublicKeys: null,
|
||||||
@@ -62,7 +68,6 @@ export class VpnHandler {
|
|||||||
return {
|
return {
|
||||||
status: {
|
status: {
|
||||||
running: manager.running,
|
running: manager.running,
|
||||||
forwardingMode: manager.forwardingMode,
|
|
||||||
subnet: manager.getSubnet(),
|
subnet: manager.getSubnet(),
|
||||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
serverPublicKeys: manager.getServerPublicKeys(),
|
serverPublicKeys: manager.getServerPublicKeys(),
|
||||||
@@ -74,6 +79,31 @@ export class VpnHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get currently connected VPN clients
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnConnectedClients>(
|
||||||
|
'getVpnConnectedClients',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { connectedClients: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await manager.getConnectedClients();
|
||||||
|
return {
|
||||||
|
connectedClients: connected.map((c) => ({
|
||||||
|
clientId: c.registeredClientId || c.clientId,
|
||||||
|
assignedIp: c.assignedIp,
|
||||||
|
connectedSince: c.connectedSince,
|
||||||
|
bytesSent: c.bytesSent,
|
||||||
|
bytesReceived: c.bytesReceived,
|
||||||
|
transport: c.transportType,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||||
|
|
||||||
// Create a new VPN client
|
// Create a new VPN client
|
||||||
@@ -89,21 +119,40 @@ export class VpnHandler {
|
|||||||
try {
|
try {
|
||||||
const bundle = await manager.createClient({
|
const bundle = await manager.createClient({
|
||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
tags: dataArg.tags,
|
targetProfileIds: dataArg.targetProfileIds,
|
||||||
description: dataArg.description,
|
description: dataArg.description,
|
||||||
|
destinationAllowList: dataArg.destinationAllowList,
|
||||||
|
destinationBlockList: dataArg.destinationBlockList,
|
||||||
|
useHostIp: dataArg.useHostIp,
|
||||||
|
useDhcp: dataArg.useDhcp,
|
||||||
|
staticIp: dataArg.staticIp,
|
||||||
|
forceVlan: dataArg.forceVlan,
|
||||||
|
vlanId: dataArg.vlanId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retrieve the persisted doc to get dcrouter-level fields
|
||||||
|
const persistedClient = manager.listClients().find(
|
||||||
|
(c) => c.clientId === bundle.entry.clientId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
client: {
|
client: {
|
||||||
clientId: bundle.entry.clientId,
|
clientId: bundle.entry.clientId,
|
||||||
enabled: bundle.entry.enabled ?? true,
|
enabled: bundle.entry.enabled ?? true,
|
||||||
tags: bundle.entry.tags,
|
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,
|
||||||
|
destinationAllowList: persistedClient?.destinationAllowList,
|
||||||
|
destinationBlockList: persistedClient?.destinationBlockList,
|
||||||
|
useHostIp: persistedClient?.useHostIp,
|
||||||
|
useDhcp: persistedClient?.useDhcp,
|
||||||
|
staticIp: persistedClient?.staticIp,
|
||||||
|
forceVlan: persistedClient?.forceVlan,
|
||||||
|
vlanId: persistedClient?.vlanId,
|
||||||
},
|
},
|
||||||
wireguardConfig: bundle.wireguardConfig,
|
wireguardConfig: bundle.wireguardConfig,
|
||||||
};
|
};
|
||||||
@@ -114,6 +163,36 @@ export class VpnHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update a VPN client's metadata
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVpnClient>(
|
||||||
|
'updateVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.updateClient(dataArg.clientId, {
|
||||||
|
description: dataArg.description,
|
||||||
|
targetProfileIds: dataArg.targetProfileIds,
|
||||||
|
destinationAllowList: dataArg.destinationAllowList,
|
||||||
|
destinationBlockList: dataArg.destinationBlockList,
|
||||||
|
useHostIp: dataArg.useHostIp,
|
||||||
|
useDhcp: dataArg.useDhcp,
|
||||||
|
staticIp: dataArg.staticIp,
|
||||||
|
forceVlan: dataArg.forceVlan,
|
||||||
|
vlanId: dataArg.vlanId,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Delete a VPN client
|
// Delete a VPN client
|
||||||
adminRouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export function resolvePaths(baseDir?: string) {
|
|||||||
dcrouterHomeDir: root,
|
dcrouterHomeDir: root,
|
||||||
dataDir: resolvedDataDir,
|
dataDir: resolvedDataDir,
|
||||||
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
|
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
|
||||||
defaultStoragePath: plugins.path.join(root, 'storage'),
|
|
||||||
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
|
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
import { AccountingSessionDoc } from '../db/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RADIUS accounting session
|
* RADIUS accounting session
|
||||||
@@ -84,8 +84,6 @@ export interface IAccountingSummary {
|
|||||||
* Accounting manager configuration
|
* Accounting manager configuration
|
||||||
*/
|
*/
|
||||||
export interface IAccountingManagerConfig {
|
export interface IAccountingManagerConfig {
|
||||||
/** Storage key prefix */
|
|
||||||
storagePrefix?: string;
|
|
||||||
/** Session retention period in days (default: 30) */
|
/** Session retention period in days (default: 30) */
|
||||||
retentionDays?: number;
|
retentionDays?: number;
|
||||||
/** Enable detailed session logging */
|
/** Enable detailed session logging */
|
||||||
@@ -106,7 +104,6 @@ export interface IAccountingManagerConfig {
|
|||||||
export class AccountingManager {
|
export class AccountingManager {
|
||||||
private activeSessions: Map<string, IAccountingSession> = new Map();
|
private activeSessions: Map<string, IAccountingSession> = new Map();
|
||||||
private config: Required<IAccountingManagerConfig>;
|
private config: Required<IAccountingManagerConfig>;
|
||||||
private storageManager?: StorageManager;
|
|
||||||
private staleSessionSweepTimer?: ReturnType<typeof setInterval>;
|
private staleSessionSweepTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
// Counters for statistics
|
// Counters for statistics
|
||||||
@@ -118,24 +115,20 @@ export class AccountingManager {
|
|||||||
interimUpdatesReceived: 0,
|
interimUpdatesReceived: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) {
|
constructor(config?: IAccountingManagerConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
storagePrefix: config?.storagePrefix ?? '/radius/accounting',
|
|
||||||
retentionDays: config?.retentionDays ?? 30,
|
retentionDays: config?.retentionDays ?? 30,
|
||||||
detailedLogging: config?.detailedLogging ?? false,
|
detailedLogging: config?.detailedLogging ?? false,
|
||||||
maxActiveSessions: config?.maxActiveSessions ?? 10000,
|
maxActiveSessions: config?.maxActiveSessions ?? 10000,
|
||||||
staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24,
|
staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24,
|
||||||
};
|
};
|
||||||
this.storageManager = storageManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the accounting manager
|
* Initialize the accounting manager
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.storageManager) {
|
await this.loadActiveSessions();
|
||||||
await this.loadActiveSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start periodic sweep to evict stale sessions (every 15 minutes)
|
// Start periodic sweep to evict stale sessions (every 15 minutes)
|
||||||
this.staleSessionSweepTimer = setInterval(() => {
|
this.staleSessionSweepTimer = setInterval(() => {
|
||||||
@@ -176,9 +169,7 @@ export class AccountingManager {
|
|||||||
session.endTime = Date.now();
|
session.endTime = Date.now();
|
||||||
session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000);
|
session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000);
|
||||||
|
|
||||||
if (this.storageManager) {
|
this.persistSession(session).catch(() => {});
|
||||||
this.archiveSession(session).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeSessions.delete(sessionId);
|
this.activeSessions.delete(sessionId);
|
||||||
swept++;
|
swept++;
|
||||||
@@ -250,9 +241,7 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist session
|
// Persist session
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.persistSession(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,9 +287,7 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update persisted session
|
// Update persisted session
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.persistSession(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -353,10 +340,8 @@ export class AccountingManager {
|
|||||||
logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`);
|
logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive the session
|
// Update status in the database (single collection, no active->archive move needed)
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.archiveSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from active sessions
|
// Remove from active sessions
|
||||||
this.activeSessions.delete(data.sessionId);
|
this.activeSessions.delete(data.sessionId);
|
||||||
@@ -493,23 +478,16 @@ export class AccountingManager {
|
|||||||
* Clean up old archived sessions based on retention policy
|
* Clean up old archived sessions based on retention policy
|
||||||
*/
|
*/
|
||||||
async cleanupOldSessions(): Promise<number> {
|
async cleanupOldSessions(): Promise<number> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
|
const oldDocs = await AccountingSessionDoc.findStoppedBefore(cutoffTime);
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const doc of oldDocs) {
|
||||||
try {
|
try {
|
||||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
await doc.delete();
|
||||||
if (session && session.endTime > 0 && session.endTime < cutoffTime) {
|
deletedCount++;
|
||||||
await this.storageManager.delete(key);
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore individual errors
|
// Ignore individual errors
|
||||||
}
|
}
|
||||||
@@ -552,9 +530,7 @@ export class AccountingManager {
|
|||||||
session.terminateCause = 'SessionEvicted';
|
session.terminateCause = 'SessionEvicted';
|
||||||
session.endTime = Date.now();
|
session.endTime = Date.now();
|
||||||
|
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.archiveSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeSessions.delete(sessionId);
|
this.activeSessions.delete(sessionId);
|
||||||
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
|
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
|
||||||
@@ -562,25 +538,38 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load active sessions from storage
|
* Load active sessions from database
|
||||||
*/
|
*/
|
||||||
private async loadActiveSessions(): Promise<void> {
|
private async loadActiveSessions(): Promise<void> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`);
|
const docs = await AccountingSessionDoc.findActive();
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
try {
|
const session: IAccountingSession = {
|
||||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
sessionId: doc.sessionId,
|
||||||
if (session && session.status === 'active') {
|
username: doc.username,
|
||||||
this.activeSessions.set(session.sessionId, session);
|
macAddress: doc.macAddress,
|
||||||
}
|
nasIpAddress: doc.nasIpAddress,
|
||||||
} catch (error) {
|
nasPort: doc.nasPort,
|
||||||
// Ignore individual errors
|
nasPortType: doc.nasPortType,
|
||||||
}
|
nasIdentifier: doc.nasIdentifier,
|
||||||
|
vlanId: doc.vlanId,
|
||||||
|
framedIpAddress: doc.framedIpAddress,
|
||||||
|
calledStationId: doc.calledStationId,
|
||||||
|
callingStationId: doc.callingStationId,
|
||||||
|
startTime: doc.startTime,
|
||||||
|
endTime: doc.endTime,
|
||||||
|
lastUpdateTime: doc.lastUpdateTime,
|
||||||
|
status: doc.status,
|
||||||
|
terminateCause: doc.terminateCause,
|
||||||
|
inputOctets: doc.inputOctets,
|
||||||
|
outputOctets: doc.outputOctets,
|
||||||
|
inputPackets: doc.inputPackets,
|
||||||
|
outputPackets: doc.outputPackets,
|
||||||
|
sessionTime: doc.sessionTime,
|
||||||
|
serviceType: doc.serviceType,
|
||||||
|
};
|
||||||
|
this.activeSessions.set(session.sessionId, session);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`);
|
logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`);
|
||||||
@@ -588,70 +577,59 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a session to storage
|
* Persist a session to the database (create or update)
|
||||||
*/
|
*/
|
||||||
private async persistSession(session: IAccountingSession): Promise<void> {
|
private async persistSession(session: IAccountingSession): Promise<void> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
|
||||||
try {
|
try {
|
||||||
await this.storageManager.setJSON(key, session);
|
let doc = await AccountingSessionDoc.findBySessionId(session.sessionId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = new AccountingSessionDoc();
|
||||||
|
}
|
||||||
|
Object.assign(doc, session);
|
||||||
|
await doc.save();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to persist session ${session.sessionId}: ${(error as Error).message}`);
|
logger.log('error', `Failed to persist session ${session.sessionId}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive a completed session
|
* Get archived (stopped/terminated) sessions for a time period
|
||||||
*/
|
|
||||||
private async archiveSession(session: IAccountingSession): Promise<void> {
|
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Remove from active
|
|
||||||
const activeKey = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
|
||||||
await this.storageManager.delete(activeKey);
|
|
||||||
|
|
||||||
// Add to archive with date-based path
|
|
||||||
const date = new Date(session.endTime);
|
|
||||||
const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`;
|
|
||||||
await this.storageManager.setJSON(archiveKey, session);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Failed to archive session ${session.sessionId}: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get archived sessions for a time period
|
|
||||||
*/
|
*/
|
||||||
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
|
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessions: IAccountingSession[] = [];
|
const sessions: IAccountingSession[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
|
const docs = await AccountingSessionDoc.getInstances({
|
||||||
|
status: { $in: ['stopped', 'terminated'] } as any,
|
||||||
|
endTime: { $gt: 0, $gte: startTime } as any,
|
||||||
|
startTime: { $lte: endTime } as any,
|
||||||
|
});
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
try {
|
sessions.push({
|
||||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
sessionId: doc.sessionId,
|
||||||
if (
|
username: doc.username,
|
||||||
session &&
|
macAddress: doc.macAddress,
|
||||||
session.endTime > 0 &&
|
nasIpAddress: doc.nasIpAddress,
|
||||||
session.startTime <= endTime &&
|
nasPort: doc.nasPort,
|
||||||
session.endTime >= startTime
|
nasPortType: doc.nasPortType,
|
||||||
) {
|
nasIdentifier: doc.nasIdentifier,
|
||||||
sessions.push(session);
|
vlanId: doc.vlanId,
|
||||||
}
|
framedIpAddress: doc.framedIpAddress,
|
||||||
} catch (error) {
|
calledStationId: doc.calledStationId,
|
||||||
// Ignore individual errors
|
callingStationId: doc.callingStationId,
|
||||||
}
|
startTime: doc.startTime,
|
||||||
|
endTime: doc.endTime,
|
||||||
|
lastUpdateTime: doc.lastUpdateTime,
|
||||||
|
status: doc.status,
|
||||||
|
terminateCause: doc.terminateCause,
|
||||||
|
inputOctets: doc.inputOctets,
|
||||||
|
outputOctets: doc.outputOctets,
|
||||||
|
inputPackets: doc.inputPackets,
|
||||||
|
outputPackets: doc.outputPackets,
|
||||||
|
sessionTime: doc.sessionTime,
|
||||||
|
serviceType: doc.serviceType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
|
logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
|
||||||
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
|
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
|
||||||
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
|
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
|
||||||
|
|
||||||
@@ -92,7 +91,6 @@ export class RadiusServer {
|
|||||||
private vlanManager: VlanManager;
|
private vlanManager: VlanManager;
|
||||||
private accountingManager: AccountingManager;
|
private accountingManager: AccountingManager;
|
||||||
private config: IRadiusServerConfig;
|
private config: IRadiusServerConfig;
|
||||||
private storageManager?: StorageManager;
|
|
||||||
private clientSecrets: Map<string, string> = new Map();
|
private clientSecrets: Map<string, string> = new Map();
|
||||||
private running: boolean = false;
|
private running: boolean = false;
|
||||||
|
|
||||||
@@ -105,20 +103,19 @@ export class RadiusServer {
|
|||||||
startTime: 0,
|
startTime: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(config: IRadiusServerConfig, storageManager?: StorageManager) {
|
constructor(config: IRadiusServerConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
authPort: config.authPort ?? 1812,
|
authPort: config.authPort ?? 1812,
|
||||||
acctPort: config.acctPort ?? 1813,
|
acctPort: config.acctPort ?? 1813,
|
||||||
bindAddress: config.bindAddress ?? '0.0.0.0',
|
bindAddress: config.bindAddress ?? '0.0.0.0',
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
this.storageManager = storageManager;
|
|
||||||
|
|
||||||
// Initialize VLAN manager
|
// Initialize VLAN manager
|
||||||
this.vlanManager = new VlanManager(config.vlanAssignment, storageManager);
|
this.vlanManager = new VlanManager(config.vlanAssignment);
|
||||||
|
|
||||||
// Initialize accounting manager
|
// Initialize accounting manager
|
||||||
this.accountingManager = new AccountingManager(config.accounting, storageManager);
|
this.accountingManager = new AccountingManager(config.accounting);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/index.js';
|
import { VlanMappingsDoc } from '../db/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MAC address to VLAN mapping
|
* MAC address to VLAN mapping
|
||||||
@@ -42,8 +42,6 @@ export interface IVlanManagerConfig {
|
|||||||
defaultVlan?: number;
|
defaultVlan?: number;
|
||||||
/** Whether to allow unknown MACs (assign default VLAN) or reject */
|
/** Whether to allow unknown MACs (assign default VLAN) or reject */
|
||||||
allowUnknownMacs?: boolean;
|
allowUnknownMacs?: boolean;
|
||||||
/** Storage key prefix for persistence */
|
|
||||||
storagePrefix?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,27 +54,22 @@ export interface IVlanManagerConfig {
|
|||||||
export class VlanManager {
|
export class VlanManager {
|
||||||
private mappings: Map<string, IMacVlanMapping> = new Map();
|
private mappings: Map<string, IMacVlanMapping> = new Map();
|
||||||
private config: Required<IVlanManagerConfig>;
|
private config: Required<IVlanManagerConfig>;
|
||||||
private storageManager?: StorageManager;
|
|
||||||
|
|
||||||
// Cache for normalized MAC lookups
|
// Cache for normalized MAC lookups
|
||||||
private normalizedMacCache: Map<string, string> = new Map();
|
private normalizedMacCache: Map<string, string> = new Map();
|
||||||
|
|
||||||
constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) {
|
constructor(config?: IVlanManagerConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
defaultVlan: config?.defaultVlan ?? 1,
|
defaultVlan: config?.defaultVlan ?? 1,
|
||||||
allowUnknownMacs: config?.allowUnknownMacs ?? true,
|
allowUnknownMacs: config?.allowUnknownMacs ?? true,
|
||||||
storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings',
|
|
||||||
};
|
};
|
||||||
this.storageManager = storageManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the VLAN manager and load persisted mappings
|
* Initialize the VLAN manager and load persisted mappings
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.storageManager) {
|
await this.loadMappings();
|
||||||
await this.loadMappings();
|
|
||||||
}
|
|
||||||
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
|
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,10 +150,8 @@ export class VlanManager {
|
|||||||
|
|
||||||
this.mappings.set(normalizedMac, fullMapping);
|
this.mappings.set(normalizedMac, fullMapping);
|
||||||
|
|
||||||
// Persist to storage
|
// Persist to database
|
||||||
if (this.storageManager) {
|
await this.saveMappings();
|
||||||
await this.saveMappings();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
|
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
|
||||||
return fullMapping;
|
return fullMapping;
|
||||||
@@ -173,7 +164,7 @@ export class VlanManager {
|
|||||||
const normalizedMac = this.normalizeMac(mac);
|
const normalizedMac = this.normalizeMac(mac);
|
||||||
const removed = this.mappings.delete(normalizedMac);
|
const removed = this.mappings.delete(normalizedMac);
|
||||||
|
|
||||||
if (removed && this.storageManager) {
|
if (removed) {
|
||||||
await this.saveMappings();
|
await this.saveMappings();
|
||||||
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
|
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
|
||||||
}
|
}
|
||||||
@@ -333,39 +324,36 @@ export class VlanManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load mappings from storage
|
* Load mappings from database
|
||||||
*/
|
*/
|
||||||
private async loadMappings(): Promise<void> {
|
private async loadMappings(): Promise<void> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(this.config.storagePrefix);
|
const doc = await VlanMappingsDoc.load();
|
||||||
if (data && Array.isArray(data)) {
|
if (doc && Array.isArray(doc.mappings)) {
|
||||||
for (const mapping of data) {
|
for (const mapping of doc.mappings) {
|
||||||
this.mappings.set(this.normalizeMac(mapping.mac), mapping);
|
this.mappings.set(this.normalizeMac(mapping.mac), mapping);
|
||||||
}
|
}
|
||||||
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`);
|
logger.log('info', `Loaded ${doc.mappings.length} VLAN mappings from database`);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${(error as Error).message}`);
|
logger.log('warn', `Failed to load VLAN mappings from database: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save mappings to storage
|
* Save mappings to database
|
||||||
*/
|
*/
|
||||||
private async saveMappings(): Promise<void> {
|
private async saveMappings(): Promise<void> {
|
||||||
if (!this.storageManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mappings = Array.from(this.mappings.values());
|
const mappings = Array.from(this.mappings.values());
|
||||||
await this.storageManager.setJSON(this.config.storagePrefix, mappings);
|
let doc = await VlanMappingsDoc.load();
|
||||||
|
if (!doc) {
|
||||||
|
doc = new VlanMappingsDoc();
|
||||||
|
}
|
||||||
|
doc.mappings = mappings;
|
||||||
|
await doc.save();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to save VLAN mappings to storage: ${(error as Error).message}`);
|
logger.log('error', `Failed to save VLAN mappings to database: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* - VLAN assignment based on MAC addresses
|
* - VLAN assignment based on MAC addresses
|
||||||
* - OUI (vendor prefix) pattern matching for device categorization
|
* - OUI (vendor prefix) pattern matching for device categorization
|
||||||
* - RADIUS accounting for session tracking and billing
|
* - RADIUS accounting for session tracking and billing
|
||||||
* - Integration with StorageManager for persistence
|
* - Integration with smartdata document classes for persistence
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './classes.radius.server.js';
|
export * from './classes.radius.server.js';
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
|
||||||
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
|
import { RemoteIngressEdgeDoc } from '../db/index.js';
|
||||||
const STORAGE_PREFIX = '/remote-ingress/';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
||||||
@@ -27,33 +25,40 @@ function extractPorts(portRange: number | Array<number | { from: number; to: num
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages CRUD for remote ingress edge registrations.
|
* Manages CRUD for remote ingress edge registrations.
|
||||||
* Persists edge configs via StorageManager and provides
|
* Persists edge configs via smartdata document classes and provides
|
||||||
* the allowed edges list for the Rust hub.
|
* the allowed edges list for the Rust hub.
|
||||||
*/
|
*/
|
||||||
export class RemoteIngressManager {
|
export class RemoteIngressManager {
|
||||||
private storageManager: StorageManager;
|
|
||||||
private edges: Map<string, IRemoteIngress> = new Map();
|
private edges: Map<string, IRemoteIngress> = new Map();
|
||||||
private routes: IDcRouterRouteConfig[] = [];
|
private routes: IDcRouterRouteConfig[] = [];
|
||||||
|
|
||||||
constructor(storageManager: StorageManager) {
|
constructor() {
|
||||||
this.storageManager = storageManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all edge registrations from storage into memory.
|
* Load all edge registrations from the database into memory.
|
||||||
*/
|
*/
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(STORAGE_PREFIX);
|
const docs = await RemoteIngressEdgeDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
|
// Migration: old edges without autoDerivePorts default to true
|
||||||
if (edge) {
|
if ((doc as any).autoDerivePorts === undefined) {
|
||||||
// Migration: old edges without autoDerivePorts default to true
|
doc.autoDerivePorts = true;
|
||||||
if ((edge as any).autoDerivePorts === undefined) {
|
await doc.save();
|
||||||
edge.autoDerivePorts = true;
|
|
||||||
await this.storageManager.setJSON(key, edge);
|
|
||||||
}
|
|
||||||
this.edges.set(edge.id, edge);
|
|
||||||
}
|
}
|
||||||
|
const edge: IRemoteIngress = {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
secret: doc.secret,
|
||||||
|
listenPorts: doc.listenPorts,
|
||||||
|
listenPortsUdp: doc.listenPortsUdp,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
autoDerivePorts: doc.autoDerivePorts,
|
||||||
|
tags: doc.tags,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
};
|
||||||
|
this.edges.set(edge.id, edge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +194,9 @@ export class RemoteIngressManager {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
const doc = new RemoteIngressEdgeDoc();
|
||||||
|
Object.assign(doc, edge);
|
||||||
|
await doc.save();
|
||||||
this.edges.set(id, edge);
|
this.edges.set(id, edge);
|
||||||
return edge;
|
return edge;
|
||||||
}
|
}
|
||||||
@@ -233,7 +240,11 @@ export class RemoteIngressManager {
|
|||||||
if (updates.tags !== undefined) edge.tags = updates.tags;
|
if (updates.tags !== undefined) edge.tags = updates.tags;
|
||||||
edge.updatedAt = Date.now();
|
edge.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||||
|
if (doc) {
|
||||||
|
Object.assign(doc, edge);
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
this.edges.set(id, edge);
|
this.edges.set(id, edge);
|
||||||
return edge;
|
return edge;
|
||||||
}
|
}
|
||||||
@@ -245,7 +256,10 @@ export class RemoteIngressManager {
|
|||||||
if (!this.edges.has(id)) {
|
if (!this.edges.has(id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await this.storageManager.delete(`${STORAGE_PREFIX}${id}`);
|
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||||
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
this.edges.delete(id);
|
this.edges.delete(id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -262,7 +276,11 @@ export class RemoteIngressManager {
|
|||||||
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
|
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
|
||||||
edge.updatedAt = Date.now();
|
edge.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
|
const doc = await RemoteIngressEdgeDoc.findById(id);
|
||||||
|
if (doc) {
|
||||||
|
Object.assign(doc, edge);
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
this.edges.set(id, edge);
|
this.edges.set(id, edge);
|
||||||
return edge.secret;
|
return edge.secret;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import { CachedIPReputation } from '../db/documents/classes.cached.ip.reputation.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reputation check result information
|
* Reputation check result information
|
||||||
@@ -52,7 +52,7 @@ export interface IIPReputationOptions {
|
|||||||
highRiskThreshold?: number; // Score below this is high risk
|
highRiskThreshold?: number; // Score below this is high risk
|
||||||
mediumRiskThreshold?: number; // Score below this is medium risk
|
mediumRiskThreshold?: number; // Score below this is medium risk
|
||||||
lowRiskThreshold?: number; // Score below this is low risk
|
lowRiskThreshold?: number; // Score below this is low risk
|
||||||
enableLocalCache?: boolean; // Whether to persist cache to disk (default: true)
|
enableLocalCache?: boolean; // Whether to persist cache to database (default: true)
|
||||||
enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true)
|
enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true)
|
||||||
enableIPInfo?: boolean; // Whether to use IP info service (default: true)
|
enableIPInfo?: boolean; // Whether to use IP info service (default: true)
|
||||||
}
|
}
|
||||||
@@ -64,10 +64,7 @@ export class IPReputationChecker {
|
|||||||
private static instance: IPReputationChecker | undefined;
|
private static instance: IPReputationChecker | undefined;
|
||||||
private reputationCache: LRUCache<string, IReputationResult>;
|
private reputationCache: LRUCache<string, IReputationResult>;
|
||||||
private options: Required<IIPReputationOptions>;
|
private options: Required<IIPReputationOptions>;
|
||||||
private storageManager?: any; // StorageManager instance
|
|
||||||
private saveCacheTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private static readonly SAVE_CACHE_DEBOUNCE_MS = 30_000;
|
|
||||||
|
|
||||||
// Default DNSBL servers
|
// Default DNSBL servers
|
||||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||||||
'zen.spamhaus.org', // Spamhaus
|
'zen.spamhaus.org', // Spamhaus
|
||||||
@@ -75,13 +72,13 @@ export class IPReputationChecker {
|
|||||||
'b.barracudacentral.org', // Barracuda
|
'b.barracudacentral.org', // Barracuda
|
||||||
'spam.dnsbl.sorbs.net', // SORBS
|
'spam.dnsbl.sorbs.net', // SORBS
|
||||||
'dnsbl.sorbs.net', // SORBS (expanded)
|
'dnsbl.sorbs.net', // SORBS (expanded)
|
||||||
'cbl.abuseat.org', // Composite Blocking List
|
'cbl.abuseat.org', // Composite Blocking List
|
||||||
'xbl.spamhaus.org', // Spamhaus XBL
|
'xbl.spamhaus.org', // Spamhaus XBL
|
||||||
'pbl.spamhaus.org', // Spamhaus PBL
|
'pbl.spamhaus.org', // Spamhaus PBL
|
||||||
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
||||||
'psbl.surriel.com' // PSBL
|
'psbl.surriel.com' // PSBL
|
||||||
];
|
];
|
||||||
|
|
||||||
// Default options
|
// Default options
|
||||||
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
||||||
maxCacheSize: 10000,
|
maxCacheSize: 10000,
|
||||||
@@ -94,54 +91,40 @@ export class IPReputationChecker {
|
|||||||
enableDNSBL: true,
|
enableDNSBL: true,
|
||||||
enableIPInfo: true
|
enableIPInfo: true
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for IPReputationChecker
|
* Constructor for IPReputationChecker
|
||||||
* @param options Configuration options
|
* @param options Configuration options
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
*/
|
*/
|
||||||
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
|
constructor(options: IIPReputationOptions = {}) {
|
||||||
// Merge with default options
|
// Merge with default options
|
||||||
this.options = {
|
this.options = {
|
||||||
...IPReputationChecker.DEFAULT_OPTIONS,
|
...IPReputationChecker.DEFAULT_OPTIONS,
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
this.storageManager = storageManager;
|
|
||||||
|
|
||||||
// If no storage manager provided, log warning
|
|
||||||
if (!storageManager && this.options.enableLocalCache) {
|
|
||||||
logger.log('warn',
|
|
||||||
'⚠️ WARNING: IPReputationChecker initialized without StorageManager.\n' +
|
|
||||||
' IP reputation cache will only be stored to filesystem.\n' +
|
|
||||||
' Consider passing a StorageManager instance for better storage flexibility.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize reputation cache
|
// Initialize reputation cache
|
||||||
this.reputationCache = new LRUCache<string, IReputationResult>({
|
this.reputationCache = new LRUCache<string, IReputationResult>({
|
||||||
max: this.options.maxCacheSize,
|
max: this.options.maxCacheSize,
|
||||||
ttl: this.options.cacheTTL, // Cache TTL
|
ttl: this.options.cacheTTL, // Cache TTL
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load cache from disk if enabled
|
// Load persisted reputations into in-memory cache
|
||||||
if (this.options.enableLocalCache) {
|
if (this.options.enableLocalCache) {
|
||||||
// Fire and forget the load operation
|
this.loadCacheFromDb().catch((error: unknown) => {
|
||||||
this.loadCache().catch((error: unknown) => {
|
|
||||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${(error as Error).message}`);
|
logger.log('error', `Failed to load IP reputation cache during initialization: ${(error as Error).message}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the singleton instance of the checker
|
* Get the singleton instance of the checker
|
||||||
* @param options Configuration options
|
* @param options Configuration options
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
* @returns Singleton instance
|
* @returns Singleton instance
|
||||||
*/
|
*/
|
||||||
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
|
public static getInstance(options: IIPReputationOptions = {}): IPReputationChecker {
|
||||||
if (!IPReputationChecker.instance) {
|
if (!IPReputationChecker.instance) {
|
||||||
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
|
IPReputationChecker.instance = new IPReputationChecker(options);
|
||||||
}
|
}
|
||||||
return IPReputationChecker.instance;
|
return IPReputationChecker.instance;
|
||||||
}
|
}
|
||||||
@@ -150,12 +133,6 @@ export class IPReputationChecker {
|
|||||||
* Reset the singleton instance (for shutdown/testing)
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
*/
|
*/
|
||||||
public static resetInstance(): void {
|
public static resetInstance(): void {
|
||||||
if (IPReputationChecker.instance) {
|
|
||||||
if (IPReputationChecker.instance.saveCacheTimer) {
|
|
||||||
clearTimeout(IPReputationChecker.instance.saveCacheTimer);
|
|
||||||
IPReputationChecker.instance.saveCacheTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IPReputationChecker.instance = undefined;
|
IPReputationChecker.instance = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +148,8 @@ export class IPReputationChecker {
|
|||||||
logger.log('warn', `Invalid IP address format: ${ip}`);
|
logger.log('warn', `Invalid IP address format: ${ip}`);
|
||||||
return this.createErrorResult(ip, 'Invalid IP address format');
|
return this.createErrorResult(ip, 'Invalid IP address format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache first
|
// Check in-memory LRU cache first (fast path)
|
||||||
const cachedResult = this.reputationCache.get(ip);
|
const cachedResult = this.reputationCache.get(ip);
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
logger.log('info', `Using cached reputation data for IP ${ip}`, {
|
logger.log('info', `Using cached reputation data for IP ${ip}`, {
|
||||||
@@ -181,7 +158,7 @@ export class IPReputationChecker {
|
|||||||
});
|
});
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize empty result
|
// Initialize empty result
|
||||||
const result: IReputationResult = {
|
const result: IReputationResult = {
|
||||||
score: 100, // Start with perfect score
|
score: 100, // Start with perfect score
|
||||||
@@ -191,51 +168,53 @@ export class IPReputationChecker {
|
|||||||
isVPN: false,
|
isVPN: false,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check IP against DNS blacklists if enabled
|
// Check IP against DNS blacklists if enabled
|
||||||
if (this.options.enableDNSBL) {
|
if (this.options.enableDNSBL) {
|
||||||
const dnsblResult = await this.checkDNSBL(ip);
|
const dnsblResult = await this.checkDNSBL(ip);
|
||||||
|
|
||||||
// Update result with DNSBL information
|
// Update result with DNSBL information
|
||||||
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
||||||
result.isSpam = dnsblResult.listCount > 0;
|
result.isSpam = dnsblResult.listCount > 0;
|
||||||
result.blacklists = dnsblResult.lists;
|
result.blacklists = dnsblResult.lists;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get additional IP information if enabled
|
// Get additional IP information if enabled
|
||||||
if (this.options.enableIPInfo) {
|
if (this.options.enableIPInfo) {
|
||||||
const ipInfo = await this.getIPInfo(ip);
|
const ipInfo = await this.getIPInfo(ip);
|
||||||
|
|
||||||
// Update result with IP info
|
// Update result with IP info
|
||||||
result.country = ipInfo.country;
|
result.country = ipInfo.country;
|
||||||
result.asn = ipInfo.asn;
|
result.asn = ipInfo.asn;
|
||||||
result.org = ipInfo.org;
|
result.org = ipInfo.org;
|
||||||
|
|
||||||
// Adjust score based on IP type
|
// Adjust score based on IP type
|
||||||
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
||||||
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
||||||
|
|
||||||
// Set proxy flags
|
// Set proxy flags
|
||||||
result.isProxy = ipInfo.type === IPType.PROXY;
|
result.isProxy = ipInfo.type === IPType.PROXY;
|
||||||
result.isTor = ipInfo.type === IPType.TOR;
|
result.isTor = ipInfo.type === IPType.TOR;
|
||||||
result.isVPN = ipInfo.type === IPType.VPN;
|
result.isVPN = ipInfo.type === IPType.VPN;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure score is between 0 and 100
|
// Ensure score is between 0 and 100
|
||||||
result.score = Math.max(0, Math.min(100, result.score));
|
result.score = Math.max(0, Math.min(100, result.score));
|
||||||
|
|
||||||
// Update cache with result
|
// Update in-memory LRU cache
|
||||||
this.reputationCache.set(ip, result);
|
this.reputationCache.set(ip, result);
|
||||||
|
|
||||||
// Schedule debounced cache save if enabled
|
// Persist to database if enabled (fire and forget)
|
||||||
if (this.options.enableLocalCache) {
|
if (this.options.enableLocalCache) {
|
||||||
this.debouncedSaveCache();
|
this.persistReputationToDb(ip, result).catch((error: unknown) => {
|
||||||
|
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the reputation check
|
// Log the reputation check
|
||||||
this.logReputationCheck(ip, result);
|
this.logReputationCheck(ip, result);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
|
logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
|
||||||
@@ -246,7 +225,7 @@ export class IPReputationChecker {
|
|||||||
return this.createErrorResult(ip, (error as Error).message);
|
return this.createErrorResult(ip, (error as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check an IP against DNS blacklists
|
* Check an IP against DNS blacklists
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
@@ -259,7 +238,7 @@ export class IPReputationChecker {
|
|||||||
try {
|
try {
|
||||||
// Reverse the IP for DNSBL queries
|
// Reverse the IP for DNSBL queries
|
||||||
const reversedIP = this.reverseIP(ip);
|
const reversedIP = this.reverseIP(ip);
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
this.options.dnsblServers.map(async (server) => {
|
this.options.dnsblServers.map(async (server) => {
|
||||||
try {
|
try {
|
||||||
@@ -274,14 +253,14 @@ export class IPReputationChecker {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract successful lookups (listed in DNSBL)
|
// Extract successful lookups (listed in DNSBL)
|
||||||
const lists = results
|
const lists = results
|
||||||
.filter((result): result is PromiseFulfilledResult<string> =>
|
.filter((result): result is PromiseFulfilledResult<string> =>
|
||||||
result.status === 'fulfilled' && result.value !== null
|
result.status === 'fulfilled' && result.value !== null
|
||||||
)
|
)
|
||||||
.map(result => result.value);
|
.map(result => result.value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listCount: lists.length,
|
listCount: lists.length,
|
||||||
lists
|
lists
|
||||||
@@ -294,7 +273,7 @@ export class IPReputationChecker {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get information about an IP address
|
* Get information about an IP address
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
@@ -309,16 +288,16 @@ export class IPReputationChecker {
|
|||||||
try {
|
try {
|
||||||
// In a real implementation, this would use an IP data service API
|
// In a real implementation, this would use an IP data service API
|
||||||
// For this implementation, we'll use a simplified approach
|
// For this implementation, we'll use a simplified approach
|
||||||
|
|
||||||
// Check if it's a known Tor exit node (simplified)
|
// Check if it's a known Tor exit node (simplified)
|
||||||
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
||||||
|
|
||||||
// Check if it's a known VPN (simplified)
|
// Check if it's a known VPN (simplified)
|
||||||
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
||||||
|
|
||||||
// Check if it's a known proxy (simplified)
|
// Check if it's a known proxy (simplified)
|
||||||
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
||||||
|
|
||||||
// Determine IP type
|
// Determine IP type
|
||||||
let type = IPType.UNKNOWN;
|
let type = IPType.UNKNOWN;
|
||||||
if (isTor) {
|
if (isTor) {
|
||||||
@@ -341,7 +320,7 @@ export class IPReputationChecker {
|
|||||||
type = IPType.RESIDENTIAL;
|
type = IPType.RESIDENTIAL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the information
|
// Return the information
|
||||||
return {
|
return {
|
||||||
country: this.determineCountry(ip), // Simplified, would use geolocation service
|
country: this.determineCountry(ip), // Simplified, would use geolocation service
|
||||||
@@ -356,7 +335,7 @@ export class IPReputationChecker {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified method to determine country from IP
|
* Simplified method to determine country from IP
|
||||||
* In a real implementation, this would use a geolocation database or service
|
* In a real implementation, this would use a geolocation database or service
|
||||||
@@ -371,7 +350,7 @@ export class IPReputationChecker {
|
|||||||
if (ip.startsWith('171.')) return 'DE';
|
if (ip.startsWith('171.')) return 'DE';
|
||||||
return 'XX'; // Unknown
|
return 'XX'; // Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified method to determine organization from IP
|
* Simplified method to determine organization from IP
|
||||||
* In a real implementation, this would use an IP-to-org database or service
|
* In a real implementation, this would use an IP-to-org database or service
|
||||||
@@ -387,7 +366,7 @@ export class IPReputationChecker {
|
|||||||
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
|
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
||||||
* @param ip IP address to reverse
|
* @param ip IP address to reverse
|
||||||
@@ -396,7 +375,7 @@ export class IPReputationChecker {
|
|||||||
private reverseIP(ip: string): string {
|
private reverseIP(ip: string): string {
|
||||||
return ip.split('.').reverse().join('.');
|
return ip.split('.').reverse().join('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an error result for when reputation check fails
|
* Create an error result for when reputation check fails
|
||||||
* @param ip IP address
|
* @param ip IP address
|
||||||
@@ -414,7 +393,7 @@ export class IPReputationChecker {
|
|||||||
error: errorMessage
|
error: errorMessage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate IP address format
|
* Validate IP address format
|
||||||
* @param ip IP address to validate
|
* @param ip IP address to validate
|
||||||
@@ -425,7 +404,7 @@ export class IPReputationChecker {
|
|||||||
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
return ipv4Pattern.test(ip);
|
return ipv4Pattern.test(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log reputation check to security logger
|
* Log reputation check to security logger
|
||||||
* @param ip IP address
|
* @param ip IP address
|
||||||
@@ -439,7 +418,7 @@ export class IPReputationChecker {
|
|||||||
} else if (result.score < this.options.mediumRiskThreshold) {
|
} else if (result.score < this.options.mediumRiskThreshold) {
|
||||||
logLevel = SecurityLogLevel.INFO;
|
logLevel = SecurityLogLevel.INFO;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the check
|
// Log the check
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: logLevel,
|
level: logLevel,
|
||||||
@@ -458,131 +437,76 @@ export class IPReputationChecker {
|
|||||||
success: !result.isSpam
|
success: !result.isSpam
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule a debounced cache save (at most once per SAVE_CACHE_DEBOUNCE_MS)
|
|
||||||
*/
|
|
||||||
private debouncedSaveCache(): void {
|
|
||||||
if (this.saveCacheTimer) {
|
|
||||||
return; // already scheduled
|
|
||||||
}
|
|
||||||
this.saveCacheTimer = setTimeout(() => {
|
|
||||||
this.saveCacheTimer = null;
|
|
||||||
this.saveCache().catch((error: unknown) => {
|
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${(error as Error).message}`);
|
|
||||||
});
|
|
||||||
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save cache to disk or storage manager
|
* Persist a single IP reputation result to the database via CachedIPReputation
|
||||||
*/
|
*/
|
||||||
private async saveCache(): Promise<void> {
|
private async persistReputationToDb(ip: string, result: IReputationResult): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Convert cache entries to serializable array
|
const data = {
|
||||||
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
score: result.score,
|
||||||
ip,
|
isSpam: result.isSpam,
|
||||||
data
|
isProxy: result.isProxy,
|
||||||
}));
|
isTor: result.isTor,
|
||||||
|
isVPN: result.isVPN,
|
||||||
// Only save if we have entries
|
country: result.country,
|
||||||
if (entries.length === 0) {
|
asn: result.asn,
|
||||||
return;
|
org: result.org,
|
||||||
}
|
blacklists: result.blacklists,
|
||||||
|
};
|
||||||
const cacheData = JSON.stringify(entries);
|
|
||||||
|
const existing = await CachedIPReputation.findByIP(ip);
|
||||||
// Save to storage manager if available
|
if (existing) {
|
||||||
if (this.storageManager) {
|
existing.updateReputation(data);
|
||||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
await existing.save();
|
||||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
|
||||||
} else {
|
} else {
|
||||||
// Fall back to filesystem
|
const doc = CachedIPReputation.fromReputationData(ip, data);
|
||||||
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
await doc.save();
|
||||||
plugins.fsUtils.ensureDirSync(cacheDir);
|
|
||||||
|
|
||||||
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
|
|
||||||
plugins.fsUtils.toFsSync(cacheData, cacheFile);
|
|
||||||
|
|
||||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${(error as Error).message}`);
|
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load cache from disk or storage manager
|
* Load persisted reputations from CachedIPReputation documents into the in-memory LRU cache
|
||||||
*/
|
*/
|
||||||
private async loadCache(): Promise<void> {
|
private async loadCacheFromDb(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
let cacheData: string | null = null;
|
const docs = await CachedIPReputation.getInstances({});
|
||||||
let fromFilesystem = false;
|
let loadedCount = 0;
|
||||||
|
|
||||||
// Try to load from storage manager first
|
for (const doc of docs) {
|
||||||
if (this.storageManager) {
|
// Skip expired documents
|
||||||
try {
|
if (doc.isExpired()) {
|
||||||
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
continue;
|
||||||
|
|
||||||
if (!cacheData) {
|
|
||||||
// Check if data exists in filesystem and migrate it
|
|
||||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
|
||||||
|
|
||||||
if (plugins.fs.existsSync(cacheFile)) {
|
|
||||||
logger.log('info', 'Migrating IP reputation cache from filesystem to StorageManager');
|
|
||||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
|
||||||
fromFilesystem = true;
|
|
||||||
|
|
||||||
// Migrate to storage manager
|
|
||||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
|
||||||
logger.log('info', 'IP reputation cache migrated to StorageManager successfully');
|
|
||||||
|
|
||||||
// Optionally delete the old file after successful migration
|
|
||||||
try {
|
|
||||||
plugins.fs.unlinkSync(cacheFile);
|
|
||||||
logger.log('info', 'Old cache file removed after migration');
|
|
||||||
} catch (deleteError) {
|
|
||||||
logger.log('warn', `Could not delete old cache file: ${(deleteError as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Error loading from StorageManager: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No storage manager, load from filesystem
|
|
||||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
|
||||||
|
|
||||||
if (plugins.fs.existsSync(cacheFile)) {
|
|
||||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
|
||||||
fromFilesystem = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result: IReputationResult = {
|
||||||
|
score: doc.score,
|
||||||
|
isSpam: doc.isSpam,
|
||||||
|
isProxy: doc.isProxy,
|
||||||
|
isTor: doc.isTor,
|
||||||
|
isVPN: doc.isVPN,
|
||||||
|
country: doc.country || undefined,
|
||||||
|
asn: doc.asn || undefined,
|
||||||
|
org: doc.org || undefined,
|
||||||
|
blacklists: doc.blacklists || [],
|
||||||
|
timestamp: doc.lastAccessedAt?.getTime() ?? doc.createdAt?.getTime() ?? Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.reputationCache.set(doc.ipAddress, result);
|
||||||
|
loadedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and restore cache if data was found
|
if (loadedCount > 0) {
|
||||||
if (cacheData) {
|
logger.log('info', `Loaded ${loadedCount} IP reputation cache entries from database`);
|
||||||
const entries = JSON.parse(cacheData);
|
|
||||||
|
|
||||||
// Validate and filter entries
|
|
||||||
const now = Date.now();
|
|
||||||
const validEntries = entries.filter(entry => {
|
|
||||||
const age = now - entry.data.timestamp;
|
|
||||||
return age < this.options.cacheTTL; // Only load entries that haven't expired
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore cache
|
|
||||||
for (const entry of validEntries) {
|
|
||||||
this.reputationCache.set(entry.ip, entry.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = fromFilesystem ? 'disk' : 'StorageManager';
|
|
||||||
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`);
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to load IP reputation cache: ${(error as Error).message}`);
|
logger.log('error', `Failed to load IP reputation cache from database: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the risk level for a reputation score
|
* Get the risk level for a reputation score
|
||||||
* @param score Reputation score (0-100)
|
* @param score Reputation score (0-100)
|
||||||
@@ -599,21 +523,4 @@ export class IPReputationChecker {
|
|||||||
return 'trusted';
|
return 'trusted';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Update the storage manager after instantiation
|
|
||||||
* This is useful when the storage manager is not available at construction time
|
|
||||||
* @param storageManager The StorageManager instance to use
|
|
||||||
*/
|
|
||||||
public updateStorageManager(storageManager: any): void {
|
|
||||||
this.storageManager = storageManager;
|
|
||||||
logger.log('info', 'IPReputationChecker storage manager updated');
|
|
||||||
|
|
||||||
// If cache is enabled and we have entries, save them to the new storage manager
|
|
||||||
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
|
|
||||||
this.saveCache().catch((error: unknown) => {
|
|
||||||
logger.log('error', `Failed to save cache to new storage manager: ${(error as Error).message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
// Promisify filesystem operations
|
|
||||||
const readFile = plugins.util.promisify(plugins.fs.readFile);
|
|
||||||
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
|
|
||||||
const unlink = plugins.util.promisify(plugins.fs.unlink);
|
|
||||||
const rename = plugins.util.promisify(plugins.fs.rename);
|
|
||||||
const readdir = plugins.util.promisify(plugins.fs.readdir);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage configuration interface
|
|
||||||
*/
|
|
||||||
export interface IStorageConfig {
|
|
||||||
/** Filesystem path for storage */
|
|
||||||
fsPath?: string;
|
|
||||||
/** Custom read function */
|
|
||||||
readFunction?: (key: string) => Promise<string | null>;
|
|
||||||
/** Custom write function */
|
|
||||||
writeFunction?: (key: string, value: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage backend type
|
|
||||||
*/
|
|
||||||
export type StorageBackend = 'filesystem' | 'custom' | 'memory';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Central storage manager for DcRouter
|
|
||||||
* Provides unified key-value storage with multiple backend support
|
|
||||||
*/
|
|
||||||
export class StorageManager {
|
|
||||||
private static readonly MAX_MEMORY_ENTRIES = 10_000;
|
|
||||||
private backend: StorageBackend;
|
|
||||||
private memoryStore: Map<string, string> = new Map();
|
|
||||||
private config: IStorageConfig;
|
|
||||||
private fsBasePath?: string;
|
|
||||||
|
|
||||||
constructor(config?: IStorageConfig) {
|
|
||||||
this.config = config || {};
|
|
||||||
|
|
||||||
// Check if both fsPath and custom functions are provided
|
|
||||||
if (config?.fsPath && (config?.readFunction || config?.writeFunction)) {
|
|
||||||
console.warn(
|
|
||||||
'⚠️ WARNING: Both fsPath and custom read/write functions are configured.\n' +
|
|
||||||
' Using custom read/write functions. fsPath will be ignored.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine backend based on configuration
|
|
||||||
if (config?.readFunction && config?.writeFunction) {
|
|
||||||
this.backend = 'custom';
|
|
||||||
} else if (config?.fsPath) {
|
|
||||||
// Set up internal read/write functions for filesystem
|
|
||||||
this.backend = 'custom'; // Use custom backend with internal functions
|
|
||||||
this.fsBasePath = plugins.path.resolve(config.fsPath);
|
|
||||||
this.ensureDirectory(this.fsBasePath);
|
|
||||||
|
|
||||||
// Set up internal filesystem read/write functions
|
|
||||||
this.config.readFunction = (key: string): Promise<string | null> => this.fsRead(key);
|
|
||||||
this.config.writeFunction = async (key: string, value: string) => {
|
|
||||||
await this.fsWrite(key, value);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.backend = 'memory';
|
|
||||||
this.showMemoryWarning();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('info', `StorageManager initialized with ${this.backend} backend`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show warning when using memory backend
|
|
||||||
*/
|
|
||||||
private showMemoryWarning(): void {
|
|
||||||
console.warn(
|
|
||||||
'⚠️ WARNING: StorageManager is using in-memory storage.\n' +
|
|
||||||
' Data will be lost when the process restarts.\n' +
|
|
||||||
' Configure storage.fsPath or storage functions for persistence.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure directory exists for filesystem backend
|
|
||||||
*/
|
|
||||||
private async ensureDirectory(dirPath: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await plugins.fsUtils.ensureDir(dirPath);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Failed to create storage directory: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and sanitize storage key
|
|
||||||
*/
|
|
||||||
private validateKey(key: string): string {
|
|
||||||
if (!key || typeof key !== 'string') {
|
|
||||||
throw new Error('Storage key must be a non-empty string');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure key starts with /
|
|
||||||
if (!key.startsWith('/')) {
|
|
||||||
key = '/' + key;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any dangerous path elements
|
|
||||||
key = key.replace(/\.\./g, '').replace(/\/+/g, '/');
|
|
||||||
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert key to filesystem path
|
|
||||||
*/
|
|
||||||
private keyToPath(key: string): string {
|
|
||||||
if (!this.fsBasePath) {
|
|
||||||
throw new Error('Filesystem base path not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove leading slash and convert to path
|
|
||||||
const relativePath = key.substring(1);
|
|
||||||
return plugins.path.join(this.fsBasePath, relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal filesystem read function
|
|
||||||
*/
|
|
||||||
private async fsRead(key: string): Promise<string | null> {
|
|
||||||
const filePath = this.keyToPath(key);
|
|
||||||
try {
|
|
||||||
const content = await readFile(filePath, 'utf8');
|
|
||||||
return content;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as any).code === 'ENOENT') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal filesystem write function
|
|
||||||
*/
|
|
||||||
private async fsWrite(key: string, value: string): Promise<void> {
|
|
||||||
const filePath = this.keyToPath(key);
|
|
||||||
const dir = plugins.path.dirname(filePath);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
await plugins.fsUtils.ensureDir(dir);
|
|
||||||
|
|
||||||
// Write atomically with temp file
|
|
||||||
const tempPath = `${filePath}.tmp`;
|
|
||||||
await writeFile(tempPath, value, 'utf8');
|
|
||||||
await rename(tempPath, filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get value by key
|
|
||||||
*/
|
|
||||||
async get(key: string): Promise<string | null> {
|
|
||||||
key = this.validateKey(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (this.backend) {
|
|
||||||
|
|
||||||
case 'custom': {
|
|
||||||
if (!this.config.readFunction) {
|
|
||||||
throw new Error('Read function not configured');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await this.config.readFunction(key);
|
|
||||||
} catch (error) {
|
|
||||||
// Assume null if read fails (key doesn't exist)
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'memory': {
|
|
||||||
return this.memoryStore.get(key) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backend: ${this.backend}`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Storage get error for key ${key}: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set value by key
|
|
||||||
*/
|
|
||||||
async set(key: string, value: string): Promise<void> {
|
|
||||||
key = this.validateKey(key);
|
|
||||||
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new Error('Storage value must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (this.backend) {
|
|
||||||
case 'filesystem': {
|
|
||||||
const filePath = this.keyToPath(key);
|
|
||||||
const dirPath = plugins.path.dirname(filePath);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
await plugins.fsUtils.ensureDir(dirPath);
|
|
||||||
|
|
||||||
// Write atomically
|
|
||||||
const tempPath = filePath + '.tmp';
|
|
||||||
await writeFile(tempPath, value, 'utf8');
|
|
||||||
await rename(tempPath, filePath);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'custom': {
|
|
||||||
if (!this.config.writeFunction) {
|
|
||||||
throw new Error('Write function not configured');
|
|
||||||
}
|
|
||||||
await this.config.writeFunction(key, value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'memory': {
|
|
||||||
this.memoryStore.set(key, value);
|
|
||||||
// Evict oldest entries if memory store exceeds limit
|
|
||||||
while (this.memoryStore.size > StorageManager.MAX_MEMORY_ENTRIES) {
|
|
||||||
const firstKey = this.memoryStore.keys().next().value!;
|
|
||||||
this.memoryStore.delete(firstKey);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backend: ${this.backend}`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Storage set error for key ${key}: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete value by key
|
|
||||||
*/
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
key = this.validateKey(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (this.backend) {
|
|
||||||
case 'filesystem': {
|
|
||||||
const filePath = this.keyToPath(key);
|
|
||||||
try {
|
|
||||||
await unlink(filePath);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as any).code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'custom': {
|
|
||||||
// Try to delete by setting empty value
|
|
||||||
if (this.config.writeFunction) {
|
|
||||||
await this.config.writeFunction(key, '');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'memory': {
|
|
||||||
this.memoryStore.delete(key);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backend: ${this.backend}`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Storage delete error for key ${key}: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List keys by prefix
|
|
||||||
*/
|
|
||||||
async list(prefix?: string): Promise<string[]> {
|
|
||||||
prefix = prefix ? this.validateKey(prefix) : '/';
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (this.backend) {
|
|
||||||
case 'custom': {
|
|
||||||
// If we have fsBasePath, this is actually filesystem backend
|
|
||||||
if (this.fsBasePath) {
|
|
||||||
const basePath = this.keyToPath(prefix);
|
|
||||||
const keys: string[] = [];
|
|
||||||
|
|
||||||
const walkDir = async (dir: string, baseDir: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = plugins.path.join(dir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await walkDir(fullPath, baseDir);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
// Convert path back to key
|
|
||||||
const relativePath = plugins.path.relative(this.fsBasePath!, fullPath);
|
|
||||||
const key = '/' + relativePath.replace(/\\/g, '/');
|
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
keys.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as any).code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await walkDir(basePath, basePath);
|
|
||||||
return keys.sort();
|
|
||||||
} else {
|
|
||||||
// True custom backends need to implement their own listing
|
|
||||||
logger.log('warn', 'List operation not supported for custom backend');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'memory': {
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (const key of this.memoryStore.keys()) {
|
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
keys.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backend: ${this.backend}`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Storage list error for prefix ${prefix}: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if key exists
|
|
||||||
*/
|
|
||||||
async exists(key: string): Promise<boolean> {
|
|
||||||
key = this.validateKey(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const value = await this.get(key);
|
|
||||||
return value !== null;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get storage backend type
|
|
||||||
*/
|
|
||||||
getBackend(): StorageBackend {
|
|
||||||
// If we're using custom backend with fsBasePath, report it as filesystem
|
|
||||||
if (this.backend === 'custom' && this.fsBasePath) {
|
|
||||||
return 'filesystem' as StorageBackend;
|
|
||||||
}
|
|
||||||
return this.backend;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON helper: Get and parse JSON value
|
|
||||||
*/
|
|
||||||
async getJSON<T = any>(key: string): Promise<T | null> {
|
|
||||||
const value = await this.get(key);
|
|
||||||
if (value === null || value.trim() === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(value) as T;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Failed to parse JSON for key ${key}: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON helper: Set value as JSON
|
|
||||||
*/
|
|
||||||
async setJSON(key: string, value: any): Promise<void> {
|
|
||||||
const jsonString = JSON.stringify(value, null, 2);
|
|
||||||
await this.set(key, jsonString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Storage module exports
|
|
||||||
export * from './classes.storagemanager.js';
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"order": 2
|
"order": 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
import { VpnServerKeysDoc, VpnClientDoc } from '../db/index.js';
|
||||||
|
|
||||||
const STORAGE_PREFIX_KEYS = '/vpn/server-keys';
|
|
||||||
const STORAGE_PREFIX_CLIENTS = '/vpn/clients/';
|
|
||||||
|
|
||||||
export interface IVpnManagerConfig {
|
export interface IVpnManagerConfig {
|
||||||
/** VPN subnet CIDR (default: '10.8.0.0/24') */
|
/** VPN subnet CIDR (default: '10.8.0.0/24') */
|
||||||
@@ -14,53 +11,53 @@ export interface IVpnManagerConfig {
|
|||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||||
serverEndpoint?: string;
|
serverEndpoint?: string;
|
||||||
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
||||||
forwardingMode?: 'tun' | 'socket';
|
initialClients?: Array<{
|
||||||
}
|
clientId: string;
|
||||||
|
targetProfileIds?: string[];
|
||||||
interface IPersistedServerKeys {
|
description?: string;
|
||||||
noisePrivateKey: string;
|
}>;
|
||||||
noisePublicKey: string;
|
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
||||||
wgPrivateKey: string;
|
onClientChanged?: () => void;
|
||||||
wgPublicKey: string;
|
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
|
||||||
}
|
destinationPolicy?: {
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
interface IPersistedClient {
|
target?: string;
|
||||||
clientId: string;
|
allowList?: string[];
|
||||||
enabled: boolean;
|
blockList?: string[];
|
||||||
tags?: string[];
|
};
|
||||||
description?: string;
|
/** Compute per-client AllowedIPs based on the client's target profile IDs.
|
||||||
assignedIp?: string;
|
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||||
noisePublicKey: string;
|
* When not set, defaults to [subnet]. */
|
||||||
wgPublicKey: string;
|
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
|
||||||
createdAt: number;
|
/** Resolve per-client destination allow-list IPs from target profile IDs.
|
||||||
updatedAt: number;
|
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
|
||||||
expiresAt?: string;
|
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
|
||||||
|
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
|
||||||
|
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
|
||||||
|
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
/** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */
|
||||||
|
bridgeLanSubnet?: string;
|
||||||
|
/** Physical network interface for bridge mode (auto-detected if omitted) */
|
||||||
|
bridgePhysicalInterface?: string;
|
||||||
|
/** Start of VPN client IP range in LAN subnet (host offset, default: 200) */
|
||||||
|
bridgeIpRangeStart?: number;
|
||||||
|
/** End of VPN client IP range in LAN subnet (host offset, default: 250) */
|
||||||
|
bridgeIpRangeEnd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the SmartVPN server lifecycle and VPN client CRUD.
|
* Manages the SmartVPN server lifecycle and VPN client CRUD.
|
||||||
* Persists server keys and client registrations via StorageManager.
|
* Persists server keys and client registrations via smartdata document classes.
|
||||||
*/
|
*/
|
||||||
export class VpnManager {
|
export class VpnManager {
|
||||||
private storageManager: StorageManager;
|
|
||||||
private config: IVpnManagerConfig;
|
private config: IVpnManagerConfig;
|
||||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||||
private clients: Map<string, IPersistedClient> = new Map();
|
private clients: Map<string, VpnClientDoc> = new Map();
|
||||||
private serverKeys?: IPersistedServerKeys;
|
private serverKeys?: VpnServerKeysDoc;
|
||||||
private _forwardingMode: 'tun' | 'socket';
|
|
||||||
|
|
||||||
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
|
constructor(config: IVpnManagerConfig) {
|
||||||
this.storageManager = storageManager;
|
|
||||||
this.config = config;
|
this.config = config;
|
||||||
// Auto-detect forwarding mode: tun if root, socket otherwise
|
|
||||||
this._forwardingMode = config.forwardingMode
|
|
||||||
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The effective forwarding mode (tun or socket). */
|
|
||||||
public get forwardingMode(): 'tun' | 'socket' {
|
|
||||||
return this._forwardingMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The VPN subnet CIDR. */
|
/** The VPN subnet CIDR. */
|
||||||
@@ -86,43 +83,97 @@ export class VpnManager {
|
|||||||
|
|
||||||
// Build client entries for the daemon
|
// Build client entries for the daemon
|
||||||
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
|
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
|
||||||
|
let anyClientUsesHostIp = false;
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
clientEntries.push({
|
if (client.useHostIp) {
|
||||||
|
anyClientUsesHostIp = true;
|
||||||
|
}
|
||||||
|
const entry: plugins.smartvpn.IClientEntry = {
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
publicKey: client.noisePublicKey,
|
publicKey: client.noisePublicKey,
|
||||||
wgPublicKey: client.wgPublicKey,
|
wgPublicKey: client.wgPublicKey,
|
||||||
enabled: client.enabled,
|
enabled: client.enabled,
|
||||||
tags: client.tags,
|
|
||||||
description: client.description,
|
description: client.description,
|
||||||
assignedIp: client.assignedIp,
|
assignedIp: client.assignedIp,
|
||||||
expiresAt: client.expiresAt,
|
expiresAt: client.expiresAt,
|
||||||
});
|
security: this.buildClientSecurity(client),
|
||||||
|
};
|
||||||
|
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
||||||
|
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
||||||
|
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
||||||
|
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
||||||
|
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
||||||
|
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
||||||
|
clientEntries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subnet = this.getSubnet();
|
const subnet = this.getSubnet();
|
||||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||||
|
|
||||||
|
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||||
|
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||||
|
let configuredMode = this.config.forwardingMode ?? 'socket';
|
||||||
|
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||||
|
configuredMode = 'hybrid';
|
||||||
|
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||||
|
}
|
||||||
|
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||||
|
const isBridge = forwardingMode === 'bridge';
|
||||||
|
|
||||||
// Create and start VpnServer
|
// Create and start VpnServer
|
||||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||||
transport: { transport: 'stdio' },
|
transport: { transport: 'stdio' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Default destination policy: bridge mode allows traffic through directly,
|
||||||
|
// socket mode forces traffic to SmartProxy on 127.0.0.1
|
||||||
|
const defaultDestinationPolicy: plugins.smartvpn.IDestinationPolicy = isBridge
|
||||||
|
? { default: 'allow' as const }
|
||||||
|
: { default: 'forceTarget' as const, target: '127.0.0.1' };
|
||||||
|
|
||||||
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
|
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
|
||||||
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
||||||
privateKey: this.serverKeys.noisePrivateKey,
|
privateKey: this.serverKeys.noisePrivateKey,
|
||||||
publicKey: this.serverKeys.noisePublicKey,
|
publicKey: this.serverKeys.noisePublicKey,
|
||||||
subnet,
|
subnet,
|
||||||
dns: this.config.dns,
|
dns: this.config.dns,
|
||||||
forwardingMode: this._forwardingMode,
|
forwardingMode: forwardingMode as any,
|
||||||
transportMode: 'all',
|
transportMode: 'all',
|
||||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||||
wgListenPort,
|
wgListenPort,
|
||||||
clients: clientEntries,
|
clients: clientEntries,
|
||||||
socketForwardProxyProtocol: this._forwardingMode === 'socket',
|
socketForwardProxyProtocol: !isBridge,
|
||||||
|
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
||||||
|
serverEndpoint: this.config.serverEndpoint
|
||||||
|
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||||
|
: undefined,
|
||||||
|
clientAllowedIPs: [subnet],
|
||||||
|
// Bridge-specific config
|
||||||
|
...(isBridge ? {
|
||||||
|
bridgeLanSubnet: this.config.bridgeLanSubnet,
|
||||||
|
bridgePhysicalInterface: this.config.bridgePhysicalInterface,
|
||||||
|
bridgeIpRangeStart: this.config.bridgeIpRangeStart,
|
||||||
|
bridgeIpRangeEnd: this.config.bridgeIpRangeEnd,
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.vpnServer.start(serverConfig);
|
await this.vpnServer.start(serverConfig);
|
||||||
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
|
||||||
|
// Create initial clients from config (idempotent — skip already-persisted)
|
||||||
|
if (this.config.initialClients) {
|
||||||
|
for (const initial of this.config.initialClients) {
|
||||||
|
if (!this.clients.has(initial.clientId)) {
|
||||||
|
const bundle = await this.createClient({
|
||||||
|
clientId: initial.clientId,
|
||||||
|
targetProfileIds: initial.targetProfileIds,
|
||||||
|
description: initial.description,
|
||||||
|
});
|
||||||
|
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,8 +199,15 @@ export class VpnManager {
|
|||||||
*/
|
*/
|
||||||
public async createClient(opts: {
|
public async createClient(opts: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
tags?: string[];
|
targetProfileIds?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||||
if (!this.vpnServer) {
|
if (!this.vpnServer) {
|
||||||
throw new Error('VPN server not running');
|
throw new Error('VPN server not running');
|
||||||
@@ -157,35 +215,74 @@ export class VpnManager {
|
|||||||
|
|
||||||
const bundle = await this.vpnServer.createClient({
|
const bundle = await this.vpnServer.createClient({
|
||||||
clientId: opts.clientId,
|
clientId: opts.clientId,
|
||||||
tags: opts.tags,
|
|
||||||
description: opts.description,
|
description: opts.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update WireGuard config endpoint if serverEndpoint is configured
|
// Override AllowedIPs with per-client values based on target profiles
|
||||||
if (this.config.serverEndpoint && bundle.wireguardConfig) {
|
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||||
const wgPort = this.config.wgListenPort ?? 51820;
|
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
|
||||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
/Endpoint\s*=\s*.+/,
|
/AllowedIPs\s*=\s*.+/,
|
||||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist client entry (without private keys)
|
// Persist client entry (including WG private key for export/QR)
|
||||||
const persisted: IPersistedClient = {
|
const doc = new VpnClientDoc();
|
||||||
clientId: bundle.entry.clientId,
|
doc.clientId = bundle.entry.clientId;
|
||||||
enabled: bundle.entry.enabled ?? true,
|
doc.enabled = bundle.entry.enabled ?? true;
|
||||||
tags: bundle.entry.tags,
|
doc.targetProfileIds = opts.targetProfileIds;
|
||||||
description: bundle.entry.description,
|
doc.description = bundle.entry.description;
|
||||||
assignedIp: bundle.entry.assignedIp,
|
doc.assignedIp = bundle.entry.assignedIp;
|
||||||
noisePublicKey: bundle.entry.publicKey,
|
doc.noisePublicKey = bundle.entry.publicKey;
|
||||||
wgPublicKey: bundle.entry.wgPublicKey || '',
|
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
createdAt: Date.now(),
|
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||||
updatedAt: Date.now(),
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||||
expiresAt: bundle.entry.expiresAt,
|
doc.createdAt = Date.now();
|
||||||
};
|
doc.updatedAt = Date.now();
|
||||||
this.clients.set(persisted.clientId, persisted);
|
doc.expiresAt = bundle.entry.expiresAt;
|
||||||
await this.persistClient(persisted);
|
if (opts.destinationAllowList !== undefined) {
|
||||||
|
doc.destinationAllowList = opts.destinationAllowList;
|
||||||
|
}
|
||||||
|
if (opts.destinationBlockList !== undefined) {
|
||||||
|
doc.destinationBlockList = opts.destinationBlockList;
|
||||||
|
}
|
||||||
|
if (opts.useHostIp !== undefined) {
|
||||||
|
doc.useHostIp = opts.useHostIp;
|
||||||
|
}
|
||||||
|
if (opts.useDhcp !== undefined) {
|
||||||
|
doc.useDhcp = opts.useDhcp;
|
||||||
|
}
|
||||||
|
if (opts.staticIp !== undefined) {
|
||||||
|
doc.staticIp = opts.staticIp;
|
||||||
|
}
|
||||||
|
if (opts.forceVlan !== undefined) {
|
||||||
|
doc.forceVlan = opts.forceVlan;
|
||||||
|
}
|
||||||
|
if (opts.vlanId !== undefined) {
|
||||||
|
doc.vlanId = opts.vlanId;
|
||||||
|
}
|
||||||
|
this.clients.set(doc.clientId, doc);
|
||||||
|
try {
|
||||||
|
await this.persistClient(doc);
|
||||||
|
} 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
|
||||||
|
const security = this.buildClientSecurity(doc);
|
||||||
|
if (security.destinationPolicy) {
|
||||||
|
await this.vpnServer!.updateClient(doc.clientId, { security });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.onClientChanged?.();
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,14 +294,18 @@ export class VpnManager {
|
|||||||
throw new Error('VPN server not running');
|
throw new Error('VPN server not running');
|
||||||
}
|
}
|
||||||
await this.vpnServer.removeClient(clientId);
|
await this.vpnServer.removeClient(clientId);
|
||||||
|
const doc = this.clients.get(clientId);
|
||||||
this.clients.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
|
if (doc) {
|
||||||
|
await doc.delete();
|
||||||
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all registered clients (without secrets).
|
* List all registered clients (without secrets).
|
||||||
*/
|
*/
|
||||||
public listClients(): IPersistedClient[] {
|
public listClients(): VpnClientDoc[] {
|
||||||
return [...this.clients.values()];
|
return [...this.clients.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +321,7 @@ export class VpnManager {
|
|||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
}
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,6 +336,44 @@ export class VpnManager {
|
|||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
}
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a client's metadata (description, target profiles) without rotating keys.
|
||||||
|
*/
|
||||||
|
public async updateClient(clientId: string, update: {
|
||||||
|
description?: string;
|
||||||
|
targetProfileIds?: string[];
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (!client) throw new Error(`Client not found: ${clientId}`);
|
||||||
|
if (update.description !== undefined) client.description = update.description;
|
||||||
|
if (update.targetProfileIds !== undefined) client.targetProfileIds = update.targetProfileIds;
|
||||||
|
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
|
||||||
|
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
|
||||||
|
if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
|
||||||
|
if (update.useDhcp !== undefined) client.useDhcp = update.useDhcp;
|
||||||
|
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||||
|
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||||
|
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||||
|
client.updatedAt = Date.now();
|
||||||
|
await this.persistClient(client);
|
||||||
|
|
||||||
|
// Sync per-client security to the running daemon
|
||||||
|
if (this.vpnServer) {
|
||||||
|
const security = this.buildClientSecurity(client);
|
||||||
|
await this.vpnServer.updateClient(clientId, { security });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,20 +383,13 @@ export class VpnManager {
|
|||||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
||||||
|
|
||||||
// Update endpoint in WireGuard config
|
// Update persisted entry with new keys (including private key for export/QR)
|
||||||
if (this.config.serverEndpoint && bundle.wireguardConfig) {
|
|
||||||
const wgPort = this.config.wgListenPort ?? 51820;
|
|
||||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
|
||||||
/Endpoint\s*=\s*.+/,
|
|
||||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update persisted entry with new public keys
|
|
||||||
const client = this.clients.get(clientId);
|
const client = this.clients.get(clientId);
|
||||||
if (client) {
|
if (client) {
|
||||||
client.noisePublicKey = bundle.entry.publicKey;
|
client.noisePublicKey = bundle.entry.publicKey;
|
||||||
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
|
client.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||||
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
}
|
}
|
||||||
@@ -265,19 +398,32 @@ export class VpnManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export a client config (without secrets).
|
* Export a client config. Injects stored WG private key and per-client AllowedIPs.
|
||||||
*/
|
*/
|
||||||
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
||||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
let config = await this.vpnServer.exportClientConfig(clientId, format);
|
let config = await this.vpnServer.exportClientConfig(clientId, format);
|
||||||
|
|
||||||
// Update endpoint in WireGuard config
|
if (format === 'wireguard') {
|
||||||
if (format === 'wireguard' && this.config.serverEndpoint) {
|
const persisted = this.clients.get(clientId);
|
||||||
const wgPort = this.config.wgListenPort ?? 51820;
|
|
||||||
config = config.replace(
|
// Inject stored WG private key so exports produce valid, scannable configs
|
||||||
/Endpoint\s*=\s*.+/,
|
if (persisted?.wgPrivateKey) {
|
||||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
config = config.replace(
|
||||||
);
|
'[Interface]\n',
|
||||||
|
`[Interface]\nPrivateKey = ${persisted.wgPrivateKey}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override AllowedIPs with per-client values based on target profiles
|
||||||
|
if (this.config.getClientAllowedIPs) {
|
||||||
|
const profileIds = persisted?.targetProfileIds || [];
|
||||||
|
const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
|
||||||
|
config = config.replace(
|
||||||
|
/AllowedIPs\s*=\s*.+/,
|
||||||
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
@@ -328,10 +474,53 @@ export class VpnManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-client security ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build per-client security settings for the smartvpn daemon.
|
||||||
|
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
|
||||||
|
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
||||||
|
*/
|
||||||
|
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||||
|
const security: plugins.smartvpn.IClientSecurity = {};
|
||||||
|
|
||||||
|
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
|
||||||
|
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||||
|
|
||||||
|
// Merge with per-client explicit allow list
|
||||||
|
const mergedAllowList = [
|
||||||
|
...(client.destinationAllowList || []),
|
||||||
|
...profileDirectTargets,
|
||||||
|
];
|
||||||
|
|
||||||
|
security.destinationPolicy = {
|
||||||
|
default: 'forceTarget' as const,
|
||||||
|
target: '127.0.0.1',
|
||||||
|
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||||
|
blockList: client.destinationBlockList,
|
||||||
|
};
|
||||||
|
|
||||||
|
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<IPersistedServerKeys> {
|
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
||||||
const stored = await this.storageManager.getJSON<IPersistedServerKeys>(STORAGE_PREFIX_KEYS);
|
const stored = await VpnServerKeysDoc.load();
|
||||||
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
|
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
|
||||||
logger.log('info', 'Loaded VPN server keys from storage');
|
logger.log('info', 'Loaded VPN server keys from storage');
|
||||||
return stored;
|
return stored;
|
||||||
@@ -347,32 +536,28 @@ export class VpnManager {
|
|||||||
const wgKeys = await tempServer.generateWgKeypair();
|
const wgKeys = await tempServer.generateWgKeypair();
|
||||||
tempServer.stop();
|
tempServer.stop();
|
||||||
|
|
||||||
const keys: IPersistedServerKeys = {
|
const doc = stored || new VpnServerKeysDoc();
|
||||||
noisePrivateKey: noiseKeys.privateKey,
|
doc.noisePrivateKey = noiseKeys.privateKey;
|
||||||
noisePublicKey: noiseKeys.publicKey,
|
doc.noisePublicKey = noiseKeys.publicKey;
|
||||||
wgPrivateKey: wgKeys.privateKey,
|
doc.wgPrivateKey = wgKeys.privateKey;
|
||||||
wgPublicKey: wgKeys.publicKey,
|
doc.wgPublicKey = wgKeys.publicKey;
|
||||||
};
|
await doc.save();
|
||||||
|
|
||||||
await this.storageManager.setJSON(STORAGE_PREFIX_KEYS, keys);
|
|
||||||
logger.log('info', 'Generated and persisted new VPN server keys');
|
logger.log('info', 'Generated and persisted new VPN server keys');
|
||||||
return keys;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadPersistedClients(): Promise<void> {
|
private async loadPersistedClients(): Promise<void> {
|
||||||
const keys = await this.storageManager.list(STORAGE_PREFIX_CLIENTS);
|
const docs = await VpnClientDoc.findAll();
|
||||||
for (const key of keys) {
|
for (const doc of docs) {
|
||||||
const client = await this.storageManager.getJSON<IPersistedClient>(key);
|
this.clients.set(doc.clientId, doc);
|
||||||
if (client) {
|
|
||||||
this.clients.set(client.clientId, client);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (this.clients.size > 0) {
|
if (this.clients.size > 0) {
|
||||||
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
|
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistClient(client: IPersistedClient): Promise<void> {
|
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||||
await this.storageManager.setJSON(`${STORAGE_PREFIX_CLIENTS}${client.clientId}`, client);
|
await client.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
174
ts_interfaces/data/dns-provider.ts
Normal file
174
ts_interfaces/data/dns-provider.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Stable ID for the built-in DcRouter pseudo-provider. The Providers list
|
||||||
|
* surfaces this as the first, non-deletable row so operators see a uniform
|
||||||
|
* "who serves this?" answer for every domain. The ID is magic — it never
|
||||||
|
* exists in the DnsProviderDoc collection; handlers inject it at read time
|
||||||
|
* and reject any mutation that targets it.
|
||||||
|
*/
|
||||||
|
export const DCROUTER_BUILTIN_PROVIDER_ID = '__dcrouter__';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported DNS provider types.
|
||||||
|
*
|
||||||
|
* - 'cloudflare' → Cloudflare account (API token-based). Provider stays
|
||||||
|
* authoritative; dcrouter pushes record changes via API.
|
||||||
|
* - 'dcrouter' → Built-in pseudo-provider for dcrouter-hosted zones.
|
||||||
|
* dcrouter itself is the authoritative DNS server. No
|
||||||
|
* credentials, cannot be created/edited/deleted through
|
||||||
|
* the provider CRUD — the Providers view renders it from
|
||||||
|
* a handler-level synthetic row.
|
||||||
|
*
|
||||||
|
* The abstraction is designed so additional providers (Route53, Gandi,
|
||||||
|
* DigitalOcean, foreign dcrouters…) can be added by implementing the
|
||||||
|
* IDnsProvider class interface in ts/dns/providers/.
|
||||||
|
*/
|
||||||
|
export type TDnsProviderType = 'cloudflare' | 'dcrouter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of the last connection test against a provider.
|
||||||
|
*/
|
||||||
|
export type TDnsProviderStatus = 'untested' | 'ok' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare-specific credential shape.
|
||||||
|
*/
|
||||||
|
export interface ICloudflareCredentials {
|
||||||
|
apiToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of all supported provider credential shapes.
|
||||||
|
* Persisted opaquely on `IDnsProvider.credentials`.
|
||||||
|
*/
|
||||||
|
export type TDnsProviderCredentials =
|
||||||
|
| ({ type: 'cloudflare' } & ICloudflareCredentials);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A registered DNS provider account. Holds the credentials needed to
|
||||||
|
* call the provider's API and a snapshot of its last health check.
|
||||||
|
*/
|
||||||
|
export interface IDnsProvider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsProviderType;
|
||||||
|
/** Opaque credentials object — shape depends on `type`. */
|
||||||
|
credentials: TDnsProviderCredentials;
|
||||||
|
status: TDnsProviderStatus;
|
||||||
|
lastTestedAt?: number;
|
||||||
|
lastError?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A redacted view of IDnsProvider safe to send to the UI / over the wire.
|
||||||
|
* Strips secret fields from `credentials` while preserving the rest.
|
||||||
|
*/
|
||||||
|
export interface IDnsProviderPublic {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsProviderType;
|
||||||
|
status: TDnsProviderStatus;
|
||||||
|
lastTestedAt?: number;
|
||||||
|
lastError?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
/** Whether credentials are configured (true after creation). Never the secret itself. */
|
||||||
|
hasCredentials: boolean;
|
||||||
|
/**
|
||||||
|
* True for the built-in DcRouter pseudo-provider — read-only, cannot be
|
||||||
|
* created / edited / deleted. Injected by the handler layer, never
|
||||||
|
* persisted in the DnsProviderDoc collection.
|
||||||
|
*/
|
||||||
|
builtIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A domain reported by a provider's API (not yet imported into dcrouter).
|
||||||
|
*/
|
||||||
|
export interface IProviderDomainListing {
|
||||||
|
/** FQDN of the zone (e.g. 'example.com'). */
|
||||||
|
name: string;
|
||||||
|
/** Provider's internal zone identifier (zone_id for Cloudflare). */
|
||||||
|
externalId: string;
|
||||||
|
/** Authoritative nameservers reported by the provider. */
|
||||||
|
nameservers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema entry for a single credential field, used by the OpsServer UI to
|
||||||
|
* render a provider's credential form dynamically.
|
||||||
|
*/
|
||||||
|
export interface IDnsProviderCredentialField {
|
||||||
|
/** Key under which the value is stored in the credentials object. */
|
||||||
|
key: string;
|
||||||
|
/** Label shown to the user. */
|
||||||
|
label: string;
|
||||||
|
/** Optional inline help text. */
|
||||||
|
helpText?: string;
|
||||||
|
/** Whether the field must be filled. */
|
||||||
|
required: boolean;
|
||||||
|
/** True for secret fields (rendered as password input, never echoed back). */
|
||||||
|
secret: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata describing a DNS provider type. Drives:
|
||||||
|
* - the OpsServer UI's provider type picker + credential form,
|
||||||
|
* - documentation of which credentials each provider needs,
|
||||||
|
* - end-to-end consistency between the type union, the discriminated
|
||||||
|
* credentials union, the runtime factory, and the form rendering.
|
||||||
|
*
|
||||||
|
* To add a new provider, append a new entry to `dnsProviderTypeDescriptors`
|
||||||
|
* below — and follow the checklist in `ts/dns/providers/factory.ts`.
|
||||||
|
*/
|
||||||
|
export interface IDnsProviderTypeDescriptor {
|
||||||
|
type: TDnsProviderType;
|
||||||
|
/** Human-readable name for the UI. */
|
||||||
|
displayName: string;
|
||||||
|
/** One-line description shown next to the type picker. */
|
||||||
|
description: string;
|
||||||
|
/** Schema for the credentials form. */
|
||||||
|
credentialFields: IDnsProviderCredentialField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for which DNS provider types exist and what
|
||||||
|
* credentials each one needs. Used by both backend and frontend.
|
||||||
|
*/
|
||||||
|
export const dnsProviderTypeDescriptors: ReadonlyArray<IDnsProviderTypeDescriptor> = [
|
||||||
|
{
|
||||||
|
type: 'dcrouter',
|
||||||
|
displayName: 'DcRouter (built-in)',
|
||||||
|
description:
|
||||||
|
'Built-in authoritative DNS. Records are served directly by dcrouter — delegate the domain\'s NS records to make this effective.',
|
||||||
|
credentialFields: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'cloudflare',
|
||||||
|
displayName: 'Cloudflare',
|
||||||
|
description:
|
||||||
|
'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);
|
||||||
|
}
|
||||||
44
ts_interfaces/data/dns-record.ts
Normal file
44
ts_interfaces/data/dns-record.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Supported DNS record types.
|
||||||
|
*/
|
||||||
|
export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA' | 'CAA';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a DNS record came from.
|
||||||
|
*
|
||||||
|
* - 'local' → originated in this dcrouter (created via UI / API)
|
||||||
|
* - 'synced' → pulled from an upstream provider (Cloudflare, foreign
|
||||||
|
* dcrouter, …) during a sync operation
|
||||||
|
*/
|
||||||
|
export type TDnsRecordSource = 'local' | 'synced';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DNS record. For dcrouter-hosted (authoritative) domains, the record is
|
||||||
|
* registered with the embedded smartdns.DnsServer. For provider-managed
|
||||||
|
* domains, the record is mirrored from / pushed to the provider API and
|
||||||
|
* `providerRecordId` holds the provider's internal record id (for updates
|
||||||
|
* and deletes).
|
||||||
|
*/
|
||||||
|
export interface IDnsRecord {
|
||||||
|
id: string;
|
||||||
|
/** ID of the parent IDomain. */
|
||||||
|
domainId: string;
|
||||||
|
/** Fully qualified record name (e.g. 'www.example.com'). */
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
/**
|
||||||
|
* Record value as a string. For MX records, formatted as
|
||||||
|
* `<priority> <exchange>` (e.g. `10 mail.example.com`).
|
||||||
|
*/
|
||||||
|
value: string;
|
||||||
|
/** TTL in seconds. */
|
||||||
|
ttl: number;
|
||||||
|
/** Cloudflare-specific: whether the record is proxied through Cloudflare. */
|
||||||
|
proxied?: boolean;
|
||||||
|
source: TDnsRecordSource;
|
||||||
|
/** Provider's internal record id (for updates/deletes). Only set for provider records. */
|
||||||
|
providerRecordId?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
36
ts_interfaces/data/domain.ts
Normal file
36
ts_interfaces/data/domain.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Where a domain came from / how it is managed.
|
||||||
|
*
|
||||||
|
* - 'dcrouter' → dcrouter is the authoritative DNS server for this domain;
|
||||||
|
* records are served by the embedded smartdns.DnsServer.
|
||||||
|
* Operators delegate the domain's NS records to make this
|
||||||
|
* effective.
|
||||||
|
* - 'provider' → domain was imported from an external DNS provider
|
||||||
|
* (e.g. Cloudflare). The provider stays authoritative;
|
||||||
|
* dcrouter only reads/writes records via the provider API.
|
||||||
|
*/
|
||||||
|
export type TDomainSource = 'dcrouter' | 'provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A domain under management by dcrouter.
|
||||||
|
*/
|
||||||
|
export interface IDomain {
|
||||||
|
id: string;
|
||||||
|
/** Fully qualified domain name (e.g. 'example.com'). */
|
||||||
|
name: string;
|
||||||
|
source: TDomainSource;
|
||||||
|
/** ID of the DnsProvider that owns this domain — only set when source === 'provider'. */
|
||||||
|
providerId?: string;
|
||||||
|
/** True when dcrouter is the authoritative DNS server for this domain (source === 'dcrouter'). */
|
||||||
|
authoritative: boolean;
|
||||||
|
/** Authoritative nameservers (display only — populated from provider for imported domains). */
|
||||||
|
nameservers?: string[];
|
||||||
|
/** Provider's internal zone identifier — only set when source === 'provider'. */
|
||||||
|
externalZoneId?: string;
|
||||||
|
/** Last time records were synced from the provider — only set when source === 'provider'. */
|
||||||
|
lastSyncedAt?: number;
|
||||||
|
description?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
@@ -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,21 +51,14 @@ export interface IRouteRemoteIngress {
|
|||||||
edgeFilter?: string[];
|
edgeFilter?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Route-level VPN access configuration.
|
|
||||||
* When attached to a route, restricts access to VPN clients only.
|
|
||||||
*/
|
|
||||||
export interface IRouteVpn {
|
|
||||||
/** Whether this route requires VPN access */
|
|
||||||
required: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,22 +1,99 @@
|
|||||||
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
|
||||||
|
export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Management Data Types
|
// Route Management Data Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage';
|
export type TApiTokenScope =
|
||||||
|
| 'routes:read' | 'routes:write'
|
||||||
|
| 'config:read'
|
||||||
|
| 'tokens:read' | 'tokens:manage'
|
||||||
|
| 'source-profiles:read' | 'source-profiles: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';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Source Profile Types (source-side: who can access)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable, named source profile that can be referenced by routes.
|
||||||
|
* 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 ISourceProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
/** The security configuration — mirrors SmartProxy's IRouteSecurity. */
|
||||||
|
security: IRouteSecurity;
|
||||||
|
/** IDs of profiles this one extends (resolved top-down, later overrides earlier). */
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network Target Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable, named network target (host + port) that can be referenced by routes.
|
||||||
|
*/
|
||||||
|
export interface INetworkTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Route Metadata Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata on a stored route tracking where its resolved values came from.
|
||||||
|
*/
|
||||||
|
export interface IRouteMetadata {
|
||||||
|
/** ID of the SourceProfileDoc used to resolve this route's security. */
|
||||||
|
sourceProfileRef?: string;
|
||||||
|
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
|
||||||
|
networkTargetRef?: string;
|
||||||
|
/** Snapshot of the profile name at resolution time, for display. */
|
||||||
|
sourceProfileName?: string;
|
||||||
|
/** Snapshot of the target name at resolution time, for display. */
|
||||||
|
networkTargetName?: string;
|
||||||
|
/** Timestamp of last reference resolution. */
|
||||||
|
lastResolvedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
||||||
storedRouteId?: string;
|
storedRouteId?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
|
metadata?: IRouteMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,11 +127,12 @@ 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;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
metadata?: IRouteMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user