Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.nogit/
|
||||||
|
.git/
|
||||||
|
.playwright-mcp/
|
||||||
|
.vscode/
|
||||||
|
test/
|
||||||
|
test_watch/
|
||||||
|
|||||||
418
changelog.md
418
changelog.md
@@ -1,5 +1,423 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-30 - 11.15.0 - feat(vpn)
|
||||||
add tag-based VPN route access control and support configured initial VPN clients
|
add tag-based VPN route access control and support configured initial VPN clients
|
||||||
|
|
||||||
|
|||||||
25
package.json
25
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "11.15.0",
|
"version": "13.1.2",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@git.zone/tsrun": "^2.0.2",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^3.6.3",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@git.zone/tswatch": "^3.3.2",
|
"@git.zone/tswatch": "^3.3.2",
|
||||||
"@types/node": "^25.5.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.67.1",
|
||||||
"@design.estate/dees-element": "^2.2.4",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/lik": "^6.4.0",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
"@push.rocks/projectinfo": "^5.1.0",
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartacme": "^9.3.1",
|
"@push.rocks/smartacme": "^9.5.0",
|
||||||
"@push.rocks/smartdata": "^7.1.3",
|
"@push.rocks/smartdata": "^7.1.7",
|
||||||
"@push.rocks/smartdb": "^2.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.1.1",
|
||||||
"@push.rocks/smartmta": "^5.3.1",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartnetwork": "^4.5.2",
|
"@push.rocks/smartnetwork": "^4.5.2",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^27.1.0",
|
"@push.rocks/smartproxy": "^27.5.0",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.3.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartvpn": "1.13.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
285
readme.md
285
readme.md
@@ -25,7 +25,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- [Remote Ingress](#remote-ingress)
|
- [Remote Ingress](#remote-ingress)
|
||||||
- [VPN Access Control](#vpn-access-control)
|
- [VPN Access Control](#vpn-access-control)
|
||||||
- [Certificate Management](#certificate-management)
|
- [Certificate Management](#certificate-management)
|
||||||
- [Storage & Caching](#storage--caching)
|
- [Storage & Database](#storage--database)
|
||||||
- [Security Features](#security-features)
|
- [Security Features](#security-features)
|
||||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||||
- [API Client](#api-client)
|
- [API Client](#api-client)
|
||||||
@@ -76,11 +76,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
|
|
||||||
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
|
### 🔐 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
|
- **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: { required: true }` to restrict access to VPN clients only
|
- **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
|
||||||
- **Rootless operation** — auto-detects privileges: kernel TUN when running as root, userspace NAT (smoltcp) when not
|
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
|
||||||
- **Client management** — create, enable, disable, rotate keys, export WireGuard `.conf` files via OpsServer API
|
- **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
|
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
|
||||||
- **PROXY protocol v2** — in socket mode, the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
- **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
|
||||||
@@ -90,10 +93,11 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Socket-handler mode** — direct socket passing eliminates internal port hops
|
- **Socket-handler mode** — direct socket passing eliminates internal port hops
|
||||||
- **Real-time metrics** via SmartMetrics (CPU, memory, connections, throughput)
|
- **Real-time metrics** via SmartMetrics (CPU, memory, connections, throughput)
|
||||||
|
|
||||||
### 💾 Persistent Storage & Caching
|
### 💾 Unified Database
|
||||||
- **Multiple storage backends**: filesystem, custom functions, or in-memory
|
- **Two deployment modes**: embedded LocalSmartDb (zero-config) or external MongoDB
|
||||||
- **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
|
- **15 document classes** covering routes, certs, VPN, RADIUS, security profiles, network targets, and caches
|
||||||
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
||||||
|
- **Reusable references** — security profiles and network targets that propagate changes to all referencing routes
|
||||||
|
|
||||||
### 🖥️ OpsServer Dashboard
|
### 🖥️ OpsServer Dashboard
|
||||||
- **Web-based management interface** with real-time monitoring
|
- **Web-based management interface** with real-time monitoring
|
||||||
@@ -101,7 +105,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
||||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||||
- **Remote ingress management** with connection token generation and one-click copy
|
- **Remote ingress management** with connection token generation and one-click copy
|
||||||
- **Read-only configuration display** — DcRouter is configured through code
|
- **Security profiles & network targets** — reusable security configurations and host:port targets with propagation to referencing routes
|
||||||
|
- **Global warning banners** when database is disabled (management features unavailable)
|
||||||
|
- **Read-only configuration display** for system overview
|
||||||
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
|
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
|
||||||
|
|
||||||
### 🔧 Programmatic API Client
|
### 🔧 Programmatic API Client
|
||||||
@@ -261,14 +267,13 @@ const router = new DcRouter({
|
|||||||
vpnConfig: {
|
vpnConfig: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
serverEndpoint: 'vpn.example.com',
|
serverEndpoint: 'vpn.example.com',
|
||||||
wgListenPort: 51820,
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Persistent storage
|
// Unified database (embedded LocalSmartDb or external MongoDB)
|
||||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
dbConfig: { enabled: true },
|
||||||
|
|
||||||
// Cache database
|
|
||||||
cacheConfig: { enabled: true, storagePath: '~/.serve.zone/dcrouter/tsmdb' },
|
|
||||||
|
|
||||||
// TLS & ACME
|
// TLS & ACME
|
||||||
tls: { contactEmail: 'admin@example.com' },
|
tls: { contactEmail: 'admin@example.com' },
|
||||||
@@ -306,8 +311,7 @@ graph TB
|
|||||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||||
OS[OpsServer Dashboard]
|
OS[OpsServer Dashboard]
|
||||||
MM[Metrics Manager]
|
MM[Metrics Manager]
|
||||||
SM[Storage Manager]
|
DB2[DcRouterDb<br/><i>smartdata + smartdb</i>]
|
||||||
CD[Cache Database]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Backend Services"
|
subgraph "Backend Services"
|
||||||
@@ -334,8 +338,7 @@ graph TB
|
|||||||
DC --> CM
|
DC --> CM
|
||||||
DC --> OS
|
DC --> OS
|
||||||
DC --> MM
|
DC --> MM
|
||||||
DC --> SM
|
DC --> DB2
|
||||||
DC --> CD
|
|
||||||
|
|
||||||
SP --> WEB
|
SP --> WEB
|
||||||
SP --> API
|
SP --> API
|
||||||
@@ -360,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
|
||||||
@@ -381,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
|
||||||
@@ -456,7 +459,17 @@ interface IDcRouterOptions {
|
|||||||
wgListenPort?: number; // default: 51820
|
wgListenPort?: number; // default: 51820
|
||||||
dns?: string[]; // DNS servers pushed to VPN clients
|
dns?: string[]; // DNS servers pushed to VPN clients
|
||||||
serverEndpoint?: string; // Hostname in generated client configs
|
serverEndpoint?: string; // Hostname in generated client configs
|
||||||
forwardingMode?: 'tun' | 'socket'; // default: auto-detect (root → tun, else socket)
|
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) ────────────────────────────────────────────
|
||||||
@@ -493,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
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1014,17 +1019,34 @@ DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks
|
|||||||
|
|
||||||
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
|
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`)
|
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
|
||||||
3. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected with the VPN subnet
|
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. SmartProxy enforces the allowlist — only VPN-sourced traffic is accepted on those routes
|
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
|
||||||
|
|
||||||
### Two Operating Modes
|
### Destination Policy
|
||||||
|
|
||||||
| Mode | Root Required? | How It Works |
|
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
|
||||||
|------|---------------|-------------|
|
|
||||||
| **TUN** (`forwardingMode: 'tun'`) | Yes | Kernel TUN device — VPN traffic enters the network stack with real VPN IPs |
|
|
||||||
| **Socket** (`forwardingMode: 'socket'`) | No | Userspace NAT via smoltcp — outbound connections send PROXY protocol v2 to preserve VPN client IPs |
|
|
||||||
|
|
||||||
DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls back to socket mode. You can override this with the `forwardingMode` option.
|
```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
|
### Configuration
|
||||||
|
|
||||||
@@ -1032,26 +1054,47 @@ DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls
|
|||||||
const router = new DcRouter({
|
const router = new DcRouter({
|
||||||
vpnConfig: {
|
vpnConfig: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
||||||
wgListenPort: 51820, // WireGuard UDP port (default)
|
wgListenPort: 51820, // WireGuard UDP port (default)
|
||||||
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
|
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
|
||||||
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
|
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
|
||||||
// forwardingMode: 'socket', // Override auto-detection
|
|
||||||
|
// 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: {
|
smartProxyConfig: {
|
||||||
routes: [
|
routes: [
|
||||||
// This route is VPN-only — non-VPN clients are blocked
|
// 🔐 VPN-only: any VPN client can access
|
||||||
{
|
{
|
||||||
name: 'admin-panel',
|
name: 'internal-app',
|
||||||
match: { domains: ['admin.example.com'], ports: [443] },
|
match: { domains: ['internal.example.com'], ports: [443] },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
targets: [{ host: '192.168.1.50', port: 8080 }],
|
targets: [{ host: '192.168.1.50', port: 8080 }],
|
||||||
tls: { mode: 'terminate', certificate: 'auto' },
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
},
|
},
|
||||||
vpn: { required: true }, // 🔐 Only VPN clients can access this
|
vpn: { enabled: true },
|
||||||
},
|
},
|
||||||
// This route is public — anyone can access it
|
// 🔐 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',
|
name: 'public-site',
|
||||||
match: { domains: ['example.com'], ports: [443] },
|
match: { domains: ['example.com'], ports: [443] },
|
||||||
@@ -1066,17 +1109,30 @@ const router = new DcRouter({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Client Management via OpsServer API
|
### Client Tags
|
||||||
|
|
||||||
Once the VPN server is running, you can manage clients through the OpsServer dashboard or API:
|
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
|
- **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
|
- **Enable / Disable** — toggle client access without deleting
|
||||||
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
||||||
- **Export config** — re-export in WireGuard or SmartVPN format
|
- **Export config** — download in WireGuard (`.conf`), SmartVPN (`.json`), or scan as QR code
|
||||||
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
|
- **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 QR code — no custom VPN software needed.
|
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
|
||||||
|
|
||||||
@@ -1146,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
|
||||||
|
|
||||||
@@ -1252,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 |
|
||||||
@@ -1318,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
|
||||||
@@ -1328,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
|
||||||
@@ -1435,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
|
||||||
|
|
||||||
@@ -1506,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
|
||||||
|
|
||||||
@@ -1575,7 +1672,7 @@ The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsd
|
|||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
|||||||
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 }
|
||||||
|
```
|
||||||
@@ -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.15.0',
|
version: '13.1.2',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
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,19 +11,20 @@ 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';
|
||||||
|
|
||||||
@@ -122,37 +123,27 @@ export interface IDcRouterOptions {
|
|||||||
/** Other DNS providers can be added here */
|
/** 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 +183,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,14 +197,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) */
|
|
||||||
forwardingMode?: 'tun' | 'socket';
|
|
||||||
/** Pre-defined VPN clients created on startup */
|
/** Pre-defined VPN clients created on startup */
|
||||||
clients?: Array<{
|
clients?: Array<{
|
||||||
clientId: string;
|
clientId: string;
|
||||||
serverDefinedClientTags?: string[];
|
targetProfileIds?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}>;
|
}>;
|
||||||
|
/** Destination routing policy for VPN client traffic.
|
||||||
|
* 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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,12 +250,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
|
||||||
@@ -259,6 +276,8 @@ 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;
|
||||||
|
|
||||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
public detectedPublicIp: string | null = null;
|
public detectedPublicIp: string | null = null;
|
||||||
@@ -305,16 +324,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',
|
||||||
@@ -343,23 +352,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 }),
|
||||||
@@ -384,10 +393,10 @@ export class DcRouter {
|
|||||||
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
|
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// SmartProxy: critical, depends on CacheDb (if enabled)
|
// SmartProxy: critical, depends on DcRouterDb (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');
|
||||||
}
|
}
|
||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('SmartProxy')
|
new plugins.taskbuffer.Service('SmartProxy')
|
||||||
@@ -425,7 +434,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();
|
||||||
}
|
}
|
||||||
@@ -448,36 +465,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
|
|
||||||
? (tags?: string[]) => {
|
// Initialize target profile manager
|
||||||
if (tags?.length && this.vpnManager) {
|
this.targetProfileManager = new TargetProfileManager();
|
||||||
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
|
await this.targetProfileManager.initialize();
|
||||||
|
|
||||||
|
this.routeConfigManager = new RouteConfigManager(
|
||||||
|
() => this.getConstructorRoutes(),
|
||||||
|
() => this.smartProxy,
|
||||||
|
() => this.options.http3,
|
||||||
|
this.options.vpnConfig?.enabled
|
||||||
|
? (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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
: 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[]);
|
||||||
}
|
}
|
||||||
: undefined,
|
if (this.tunnelManager) {
|
||||||
);
|
this.tunnelManager.syncAllowedEdges();
|
||||||
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
}
|
||||||
await this.apiTokenManager.initialize();
|
},
|
||||||
await this.routeConfigManager.initialize();
|
);
|
||||||
})
|
this.apiTokenManager = new ApiTokenManager();
|
||||||
.withStop(async () => {
|
await this.apiTokenManager.initialize();
|
||||||
this.routeConfigManager = undefined;
|
await this.routeConfigManager.initialize();
|
||||||
this.apiTokenManager = undefined;
|
|
||||||
})
|
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||||
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
|
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) {
|
||||||
@@ -677,9 +727,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
|
||||||
@@ -689,14 +738,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
|
||||||
@@ -717,31 +761,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'})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -807,12 +865,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
|
||||||
@@ -848,14 +902,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;
|
||||||
},
|
},
|
||||||
@@ -867,18 +918,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
|
||||||
@@ -893,7 +955,7 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||||
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
|
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
|
||||||
certManager: new StorageBackedCertManager(this.storageManager),
|
certManager: new StorageBackedCertManager(),
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
challengeHandlers: challengeHandlers,
|
challengeHandlers: challengeHandlers,
|
||||||
challengePriority: ['dns-01'],
|
challengePriority: ['dns-01'],
|
||||||
@@ -963,19 +1025,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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,15 +1058,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}`);
|
||||||
@@ -1040,16 +1091,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 */ }
|
||||||
}
|
}
|
||||||
@@ -2033,13 +2087,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;
|
||||||
@@ -2059,7 +2120,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}`);
|
||||||
@@ -2093,65 +2154,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,
|
||||||
forwardingMode: this.options.vpnConfig.forwardingMode,
|
|
||||||
initialClients: this.options.vpnConfig.clients,
|
initialClients: this.options.vpnConfig.clients,
|
||||||
|
destinationPolicy: this.options.vpnConfig.destinationPolicy,
|
||||||
|
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: () => {
|
onClientChanged: () => {
|
||||||
// Re-apply routes so tag-based ipAllowLists get updated
|
// Re-apply routes so profile-based ipAllowLists get updated
|
||||||
this.routeConfigManager?.applyRoutes();
|
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
|
||||||
|
this.routeConfigManager?.applyRoutes().catch((err) => {
|
||||||
|
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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 });
|
||||||
|
// Evict oldest entries if cache exceeds 1000 entries
|
||||||
let vpnAllowList: string[];
|
if (this.vpnDomainIpCache.size > 1000) {
|
||||||
if (dcrouterRoute.vpn.allowedServerDefinedClientTags?.length && this.vpnManager) {
|
const firstKey = this.vpnDomainIpCache.keys().next().value;
|
||||||
// Tag-based: only specific client IPs
|
if (firstKey) this.vpnDomainIpCache.delete(firstKey);
|
||||||
vpnAllowList = this.vpnManager.getClientIpsForServerDefinedTags(
|
}
|
||||||
dcrouterRoute.vpn.allowedServerDefinedClientTags,
|
return ips;
|
||||||
);
|
} catch (err) {
|
||||||
} else {
|
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
|
||||||
// No tags specified: entire VPN subnet
|
return cached?.ips || []; // Return stale cache on failure, or empty
|
||||||
vpnAllowList = [vpnSubnet];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...route,
|
|
||||||
security: {
|
|
||||||
...route.security,
|
|
||||||
ipAllowList: [...existing, ...vpnAllowList],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return route;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (injectedCount > 0) {
|
|
||||||
logger.log('info', `VPN: Injected ipAllowList 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
|
||||||
*/
|
*/
|
||||||
@@ -2162,7 +2256,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 getVpnAllowList?: (tags?: string[]) => string[],
|
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,55 +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
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async applyRoutes(): Promise<void> {
|
public async applyRoutes(): Promise<void> {
|
||||||
const smartProxy = this.getSmartProxy();
|
await this.routeUpdateMutex.runExclusive(async () => {
|
||||||
if (!smartProxy) return;
|
const smartProxy = this.getSmartProxy();
|
||||||
|
if (!smartProxy) return;
|
||||||
|
|
||||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
// 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 and VPN augmentation)
|
// Helper: inject VPN security into a vpnOnly route
|
||||||
const http3Config = this.getHttp3Config?.();
|
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||||
const vpnAllowList = this.getVpnAllowList;
|
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 (vpnAllowList) {
|
}
|
||||||
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()) {
|
||||||
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
|
if (stored.enabled) {
|
||||||
route = {
|
let route = stored.route;
|
||||||
...route,
|
if (http3Config?.enabled !== false) {
|
||||||
security: {
|
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||||
...route.security,
|
|
||||||
ipAllowList: [...existing, ...allowList],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
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({});
|
||||||
|
}
|
||||||
|
}
|
||||||
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({});
|
||||||
|
}
|
||||||
|
}
|
||||||
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
ts/db/documents/index.ts
Normal file
27
ts/db/documents/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// 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';
|
||||||
@@ -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
|
||||||
@@ -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,9 @@ 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;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -88,6 +91,9 @@ 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);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
export class CertificateHandler {
|
export class CertificateHandler {
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
@@ -42,7 +43,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 +188,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 +295,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 +310,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 +331,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 +355,41 @@ 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.
|
||||||
|
if (forceRenew && dcRouter.smartAcme) {
|
||||||
|
try {
|
||||||
|
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: `Failed to renew certificate for ${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}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try provisioning via the first matching route
|
|
||||||
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
|
||||||
if (routeNames.length > 0) {
|
|
||||||
try {
|
|
||||||
await smartProxy.provisionCertificate(routeNames[0]);
|
|
||||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -365,19 +398,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 +443,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 +509,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 ---
|
||||||
@@ -151,15 +149,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 +183,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ 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';
|
||||||
@@ -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) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
serverDefinedClientTags: c.serverDefinedClientTags,
|
targetProfileIds: c.targetProfileIds,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
assignedIp: c.assignedIp,
|
assignedIp: c.assignedIp,
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
updatedAt: c.updatedAt,
|
updatedAt: c.updatedAt,
|
||||||
expiresAt: c.expiresAt,
|
expiresAt: c.expiresAt,
|
||||||
|
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,
|
||||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
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,
|
||||||
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
targetProfileIds: persistedClient?.targetProfileIds,
|
||||||
description: bundle.entry.description,
|
description: bundle.entry.description,
|
||||||
assignedIp: bundle.entry.assignedIp,
|
assignedIp: bundle.entry.assignedIp,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
expiresAt: bundle.entry.expiresAt,
|
expiresAt: bundle.entry.expiresAt,
|
||||||
|
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,63 +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) */
|
|
||||||
forwardingMode?: 'tun' | 'socket';
|
|
||||||
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
||||||
initialClients?: Array<{
|
initialClients?: Array<{
|
||||||
clientId: string;
|
clientId: string;
|
||||||
serverDefinedClientTags?: string[];
|
targetProfileIds?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}>;
|
}>;
|
||||||
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
||||||
onClientChanged?: () => void;
|
onClientChanged?: () => void;
|
||||||
}
|
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
|
||||||
|
destinationPolicy?: {
|
||||||
interface IPersistedServerKeys {
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
noisePrivateKey: string;
|
target?: string;
|
||||||
noisePublicKey: string;
|
allowList?: string[];
|
||||||
wgPrivateKey: string;
|
blockList?: string[];
|
||||||
wgPublicKey: string;
|
};
|
||||||
}
|
/** Compute per-client AllowedIPs based on the client's target profile IDs.
|
||||||
|
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||||
interface IPersistedClient {
|
* When not set, defaults to [subnet]. */
|
||||||
clientId: string;
|
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
|
||||||
enabled: boolean;
|
/** Resolve per-client destination allow-list IPs from target profile IDs.
|
||||||
serverDefinedClientTags?: string[];
|
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
|
||||||
description?: string;
|
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
|
||||||
assignedIp?: string;
|
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
|
||||||
noisePublicKey: string;
|
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
|
||||||
wgPublicKey: string;
|
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||||
createdAt: number;
|
/** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */
|
||||||
updatedAt: number;
|
bridgeLanSubnet?: string;
|
||||||
expiresAt?: string;
|
/** Physical network interface for bridge mode (auto-detected if omitted) */
|
||||||
/** @deprecated Legacy field — migrated to serverDefinedClientTags on load */
|
bridgePhysicalInterface?: string;
|
||||||
tags?: 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. */
|
||||||
@@ -96,39 +83,78 @@ 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,
|
||||||
serverDefinedClientTags: client.serverDefinedClientTags,
|
|
||||||
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);
|
||||||
@@ -139,7 +165,7 @@ export class VpnManager {
|
|||||||
if (!this.clients.has(initial.clientId)) {
|
if (!this.clients.has(initial.clientId)) {
|
||||||
const bundle = await this.createClient({
|
const bundle = await this.createClient({
|
||||||
clientId: initial.clientId,
|
clientId: initial.clientId,
|
||||||
serverDefinedClientTags: initial.serverDefinedClientTags,
|
targetProfileIds: initial.targetProfileIds,
|
||||||
description: initial.description,
|
description: initial.description,
|
||||||
});
|
});
|
||||||
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
|
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
|
||||||
@@ -147,7 +173,7 @@ export class VpnManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,8 +199,15 @@ export class VpnManager {
|
|||||||
*/
|
*/
|
||||||
public async createClient(opts: {
|
public async createClient(opts: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
serverDefinedClientTags?: 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');
|
||||||
@@ -182,34 +215,72 @@ export class VpnManager {
|
|||||||
|
|
||||||
const bundle = await this.vpnServer.createClient({
|
const bundle = await this.vpnServer.createClient({
|
||||||
clientId: opts.clientId,
|
clientId: opts.clientId,
|
||||||
serverDefinedClientTags: opts.serverDefinedClientTags,
|
|
||||||
description: opts.description,
|
description: opts.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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;
|
||||||
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
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?.();
|
this.config.onClientChanged?.();
|
||||||
return bundle;
|
return bundle;
|
||||||
@@ -223,15 +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?.();
|
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()];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +339,43 @@ export class VpnManager {
|
|||||||
this.config.onClientChanged?.();
|
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?.();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotate a client's keys. Returns the new config bundle.
|
* Rotate a client's keys. Returns the new config bundle.
|
||||||
*/
|
*/
|
||||||
@@ -272,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);
|
||||||
}
|
}
|
||||||
@@ -294,40 +398,37 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tag-based access control ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
|
|
||||||
*/
|
|
||||||
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
|
|
||||||
const ips: string[] = [];
|
|
||||||
for (const client of this.clients.values()) {
|
|
||||||
if (!client.enabled || !client.assignedIp) continue;
|
|
||||||
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
|
|
||||||
ips.push(client.assignedIp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ips;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status and telemetry ───────────────────────────────────────────────
|
// ── Status and telemetry ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -373,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;
|
||||||
@@ -392,38 +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) {
|
|
||||||
// Migrate legacy `tags` → `serverDefinedClientTags`
|
|
||||||
if (!client.serverDefinedClientTags && client.tags) {
|
|
||||||
client.serverDefinedClientTags = client.tags;
|
|
||||||
delete client.tags;
|
|
||||||
await this.persistClient(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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ 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 './target-profile.js';
|
||||||
export * from './vpn.js';
|
export * from './vpn.js';
|
||||||
@@ -51,23 +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;
|
|
||||||
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
|
|
||||||
allowedServerDefinedClientTags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended route config used within dcrouter.
|
* Extended route config used within dcrouter.
|
||||||
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
|
* Adds optional `remoteIngress` and `vpnOnly` properties to SmartProxy's IRouteConfig.
|
||||||
* SmartProxy ignores unknown properties at runtime.
|
* SmartProxy ignores unknown properties at runtime.
|
||||||
*/
|
*/
|
||||||
export type IDcRouterRouteConfig = IRouteConfig & {
|
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||||
remoteIngress?: IRouteRemoteIngress;
|
remoteIngress?: IRouteRemoteIngress;
|
||||||
vpn?: IRouteVpn;
|
/** When true, only VPN clients whose TargetProfile matches this route get access.
|
||||||
|
* Matching is determined by domain overlap, target overlap, or direct routeRef. */
|
||||||
|
vpnOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,95 @@
|
|||||||
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';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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 +123,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;
|
||||||
|
}
|
||||||
@@ -4,12 +4,20 @@
|
|||||||
export interface IVpnClient {
|
export interface IVpnClient {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
serverDefinedClientTags?: string[];
|
/** IDs of TargetProfiles assigned to this client */
|
||||||
|
targetProfileIds?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
assignedIp?: string;
|
assignedIp?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +25,6 @@ export interface IVpnClient {
|
|||||||
*/
|
*/
|
||||||
export interface IVpnServerStatus {
|
export interface IVpnServerStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
forwardingMode: 'tun' | 'socket';
|
|
||||||
subnet: string;
|
subnet: string;
|
||||||
wgListenPort: number;
|
wgListenPort: number;
|
||||||
serverPublicKeys: {
|
serverPublicKeys: {
|
||||||
@@ -28,6 +35,18 @@ export interface IVpnServerStatus {
|
|||||||
connectedClients: number;
|
connectedClients: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A currently connected VPN client (runtime info from the daemon).
|
||||||
|
*/
|
||||||
|
export interface IVpnConnectedClient {
|
||||||
|
clientId: string;
|
||||||
|
assignedIp: string;
|
||||||
|
connectedSince: string;
|
||||||
|
bytesSent: number;
|
||||||
|
bytesReceived: number;
|
||||||
|
transport: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VPN client telemetry data.
|
* VPN client telemetry data.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ interface IIdentity {
|
|||||||
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
||||||
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
||||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||||
|
| `IRadiusStats` | Running, uptime, auth requests/accepts/rejects, sessions, data transfer |
|
||||||
|
| `IVpnStats` | Running, subnet, registered/connected clients, WireGuard port |
|
||||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||||
|
|
||||||
#### Route Management Interfaces
|
#### Route Management Interfaces
|
||||||
@@ -90,6 +92,13 @@ interface IIdentity {
|
|||||||
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
||||||
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
||||||
|
|
||||||
|
#### Security & Reference Interfaces
|
||||||
|
| Interface | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `ISecurityProfile` | Reusable security config: id, name, description, security (ipAllowList, ipBlockList, maxConnections, rateLimit, etc.), extendsProfiles |
|
||||||
|
| `INetworkTarget` | Reusable host:port destination: id, name, description, host (string or string[]), port |
|
||||||
|
| `IRouteMetadata` | Route-to-reference links: securityProfileRef, networkTargetRef, snapshot names, lastResolvedAt |
|
||||||
|
|
||||||
#### Remote Ingress Interfaces
|
#### Remote Ingress Interfaces
|
||||||
| Interface | Description |
|
| Interface | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
@@ -97,13 +106,13 @@ interface IIdentity {
|
|||||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
||||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
||||||
| `IRouteVpn` | Route-level VPN config: `required` flag to restrict access to VPN clients |
|
| `IRouteVpn` | Route-level VPN config: `enabled`/`mandatory` flags and optional `allowedServerDefinedClientTags` |
|
||||||
|
|
||||||
#### VPN Interfaces
|
#### VPN Interfaces
|
||||||
| Interface | Description |
|
| Interface | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `IVpnClient` | Client registration: clientId, enabled, tags, description, assignedIp, timestamps |
|
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
|
||||||
| `IVpnServerStatus` | Server status: running, forwardingMode, subnet, wgListenPort, publicKeys, client counts |
|
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
|
||||||
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
||||||
|
|
||||||
### Request Interfaces (`requests`)
|
### Request Interfaces (`requests`)
|
||||||
@@ -128,7 +137,8 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
|
|||||||
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
||||||
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
||||||
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
||||||
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request |
|
| `IReq_GetNetworkStats` | `getNetworkStats` | Network throughput and connection analytics |
|
||||||
|
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request (server, email, DNS, security, network, RADIUS, VPN) |
|
||||||
|
|
||||||
#### ⚙️ Configuration
|
#### ⚙️ Configuration
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
@@ -241,6 +251,26 @@ interface ICertificateInfo {
|
|||||||
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
|
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
|
||||||
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
|
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
|
||||||
|
|
||||||
|
#### 🛡️ Security Profiles
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetSecurityProfiles` | `getSecurityProfiles` | List all security profiles |
|
||||||
|
| `IReq_GetSecurityProfile` | `getSecurityProfile` | Get a single profile by ID |
|
||||||
|
| `IReq_CreateSecurityProfile` | `createSecurityProfile` | Create a reusable security profile |
|
||||||
|
| `IReq_UpdateSecurityProfile` | `updateSecurityProfile` | Update a profile (propagates to routes) |
|
||||||
|
| `IReq_DeleteSecurityProfile` | `deleteSecurityProfile` | Delete a profile (with optional force) |
|
||||||
|
| `IReq_GetSecurityProfileUsage` | `getSecurityProfileUsage` | Get routes referencing a profile |
|
||||||
|
|
||||||
|
#### 🎯 Network Targets
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetNetworkTargets` | `getNetworkTargets` | List all network targets |
|
||||||
|
| `IReq_GetNetworkTarget` | `getNetworkTarget` | Get a single target by ID |
|
||||||
|
| `IReq_CreateNetworkTarget` | `createNetworkTarget` | Create a reusable host:port target |
|
||||||
|
| `IReq_UpdateNetworkTarget` | `updateNetworkTarget` | Update a target (propagates to routes) |
|
||||||
|
| `IReq_DeleteNetworkTarget` | `deleteNetworkTarget` | Delete a target (with optional force) |
|
||||||
|
| `IReq_GetNetworkTargetUsage` | `getNetworkTargetUsage` | Get routes referencing a target |
|
||||||
|
|
||||||
## Example: Full API Integration
|
## Example: Full API Integration
|
||||||
|
|
||||||
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
|
|||||||
request: {
|
request: {
|
||||||
identity: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
forceRenew?: boolean;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface IReq_GetCombinedMetrics {
|
|||||||
dns?: boolean;
|
dns?: boolean;
|
||||||
security?: boolean;
|
security?: boolean;
|
||||||
network?: boolean;
|
network?: boolean;
|
||||||
|
radius?: boolean;
|
||||||
|
vpn?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -19,6 +21,8 @@ export interface IReq_GetCombinedMetrics {
|
|||||||
dns?: data.IDnsStats;
|
dns?: data.IDnsStats;
|
||||||
security?: data.ISecurityMetrics;
|
security?: data.ISecurityMetrics;
|
||||||
network?: data.INetworkMetrics;
|
network?: data.INetworkMetrics;
|
||||||
|
radius?: data.IRadiusStats;
|
||||||
|
vpn?: data.IVpnStats;
|
||||||
};
|
};
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ export * from './certificate.js';
|
|||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
export * from './route-management.js';
|
export * from './route-management.js';
|
||||||
export * from './api-tokens.js';
|
export * from './api-tokens.js';
|
||||||
export * from './vpn.js';
|
export * from './vpn.js';
|
||||||
|
export * from './source-profiles.js';
|
||||||
|
export * from './target-profiles.js';
|
||||||
|
export * from './network-targets.js';
|
||||||
127
ts_interfaces/requests/network-targets.ts
Normal file
127
ts_interfaces/requests/network-targets.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { INetworkTarget } from '../data/route-management.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network Target Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all network targets.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetNetworkTargets extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetNetworkTargets
|
||||||
|
> {
|
||||||
|
method: 'getNetworkTargets';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
targets: INetworkTarget[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single network target by ID.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetNetworkTarget
|
||||||
|
> {
|
||||||
|
method: 'getNetworkTarget';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
target: INetworkTarget | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new network target.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateNetworkTarget
|
||||||
|
> {
|
||||||
|
method: 'createNetworkTarget';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
id?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a network target.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateNetworkTarget
|
||||||
|
> {
|
||||||
|
method: 'updateNetworkTarget';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
host?: string | string[];
|
||||||
|
port?: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
affectedRouteCount?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a network target.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteNetworkTarget
|
||||||
|
> {
|
||||||
|
method: 'deleteNetworkTarget';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which routes reference a network target.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetNetworkTargetUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetNetworkTargetUsage
|
||||||
|
> {
|
||||||
|
method: 'getNetworkTargetUsage';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
routes: Array<{ id: string; name: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type * as authInterfaces from '../data/auth.js';
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
import type { IMergedRoute, IRouteWarning } from '../data/route-management.js';
|
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
|
||||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Management Endpoints
|
// Route Management Endpoints
|
||||||
@@ -36,8 +37,9 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity?: authInterfaces.IIdentity;
|
||||||
apiToken?: string;
|
apiToken?: string;
|
||||||
route: IRouteConfig;
|
route: IDcRouterRouteConfig;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
metadata?: IRouteMetadata;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -58,8 +60,9 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
identity?: authInterfaces.IIdentity;
|
identity?: authInterfaces.IIdentity;
|
||||||
apiToken?: string;
|
apiToken?: string;
|
||||||
id: string;
|
id: string;
|
||||||
route?: Partial<IRouteConfig>;
|
route?: Partial<IDcRouterRouteConfig>;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
metadata?: Partial<IRouteMetadata>;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
127
ts_interfaces/requests/source-profiles.ts
Normal file
127
ts_interfaces/requests/source-profiles.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { ISourceProfile, IRouteSecurity } from '../data/route-management.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Source Profile Endpoints (source-side: who can access)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all source profiles.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetSourceProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetSourceProfiles
|
||||||
|
> {
|
||||||
|
method: 'getSourceProfiles';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
profiles: ISourceProfile[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single source profile by ID.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetSourceProfile
|
||||||
|
> {
|
||||||
|
method: 'getSourceProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
profile: ISourceProfile | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new source profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateSourceProfile
|
||||||
|
> {
|
||||||
|
method: 'createSourceProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
id?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a source profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateSourceProfile
|
||||||
|
> {
|
||||||
|
method: 'updateSourceProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
security?: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
affectedRouteCount?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a source profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteSourceProfile
|
||||||
|
> {
|
||||||
|
method: 'deleteSourceProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which routes reference a source profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetSourceProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetSourceProfileUsage
|
||||||
|
> {
|
||||||
|
method: 'getSourceProfileUsage';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
routes: Array<{ id: string; name: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
128
ts_interfaces/requests/target-profiles.ts
Normal file
128
ts_interfaces/requests/target-profiles.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { ITargetProfile, ITargetProfileTarget } from '../data/target-profile.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Target Profile Endpoints (target-side: what can be accessed)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all target profiles.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetTargetProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetTargetProfiles
|
||||||
|
> {
|
||||||
|
method: 'getTargetProfiles';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
profiles: ITargetProfile[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single target profile by ID.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetTargetProfile
|
||||||
|
> {
|
||||||
|
method: 'getTargetProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
profile: ITargetProfile | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new target profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateTargetProfile
|
||||||
|
> {
|
||||||
|
method: 'createTargetProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
domains?: string[];
|
||||||
|
targets?: ITargetProfileTarget[];
|
||||||
|
routeRefs?: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
id?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a target profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateTargetProfile
|
||||||
|
> {
|
||||||
|
method: 'updateTargetProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
domains?: string[];
|
||||||
|
targets?: ITargetProfileTarget[];
|
||||||
|
routeRefs?: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a target profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteTargetProfile
|
||||||
|
> {
|
||||||
|
method: 'deleteTargetProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which VPN clients reference a target profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetTargetProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetTargetProfileUsage
|
||||||
|
> {
|
||||||
|
method: 'getTargetProfileUsage';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
clients: Array<{ clientId: string; description?: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as authInterfaces from '../data/auth.js';
|
import * as authInterfaces from '../data/auth.js';
|
||||||
import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry } from '../data/vpn.js';
|
import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry, IVpnConnectedClient } from '../data/vpn.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// VPN Client Management
|
// VPN Client Management
|
||||||
@@ -49,8 +49,16 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
|||||||
request: {
|
request: {
|
||||||
identity: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
serverDefinedClientTags?: string[];
|
targetProfileIds?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -61,6 +69,50 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a VPN client's metadata (description, tags) without rotating keys.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateVpnClient
|
||||||
|
> {
|
||||||
|
method: 'updateVpnClient';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
clientId: string;
|
||||||
|
description?: string;
|
||||||
|
targetProfileIds?: string[];
|
||||||
|
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currently connected VPN clients.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetVpnConnectedClients extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetVpnConnectedClients
|
||||||
|
> {
|
||||||
|
method: 'getVpnConnectedClients';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
connectedClients: IVpnConnectedClient[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a VPN client.
|
* Delete a VPN client.
|
||||||
*/
|
*/
|
||||||
|
|||||||
70
ts_migrations/index.ts
Normal file
70
ts_migrations/index.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dcrouter migration runner.
|
||||||
|
*
|
||||||
|
* Uses @push.rocks/smartmigration via dynamic import so smartmigration's type
|
||||||
|
* chain (which pulls in mongodb 7.x and related types) doesn't leak into
|
||||||
|
* compile-time type checking for this folder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Matches the subset of IMigrationRunResult we actually log. */
|
||||||
|
export interface IMigrationRunResult {
|
||||||
|
stepsApplied: Array<unknown>;
|
||||||
|
wasFreshInstall: boolean;
|
||||||
|
currentVersionBefore: string | null;
|
||||||
|
currentVersionAfter: string;
|
||||||
|
totalDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMigrationRunner {
|
||||||
|
run(): Promise<IMigrationRunResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||||
|
*
|
||||||
|
* Call `.run()` on the returned instance at startup (after DcRouterDb is ready,
|
||||||
|
* before any service that reads migrated collections).
|
||||||
|
*
|
||||||
|
* @param db - The initialized SmartdataDb instance from DcRouterDb.getDb()
|
||||||
|
* @param targetVersion - The current app version (from commitinfo.version)
|
||||||
|
*/
|
||||||
|
export async function createMigrationRunner(
|
||||||
|
db: unknown,
|
||||||
|
targetVersion: string,
|
||||||
|
): Promise<IMigrationRunner> {
|
||||||
|
const sm = await import('@push.rocks/smartmigration');
|
||||||
|
const migration = new sm.SmartMigration({
|
||||||
|
targetVersion,
|
||||||
|
db: db as any,
|
||||||
|
// Brand-new installs skip all migrations and stamp directly to the current version.
|
||||||
|
freshInstallVersion: targetVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register steps in execution order. Each step's .from() must match the
|
||||||
|
// previous step's .to() to form a contiguous chain.
|
||||||
|
migration
|
||||||
|
.step('rename-target-profile-host-to-ip')
|
||||||
|
.from('13.0.11').to('13.1.0')
|
||||||
|
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||||
|
.up(async (ctx) => {
|
||||||
|
const collection = ctx.mongo!.collection('targetprofiledoc');
|
||||||
|
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||||
|
let migrated = 0;
|
||||||
|
for await (const doc of cursor) {
|
||||||
|
const targets = ((doc as any).targets || []).map((t: any) => {
|
||||||
|
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
|
||||||
|
const { host, ...rest } = t;
|
||||||
|
return { ...rest, ip: host };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return migration;
|
||||||
|
}
|
||||||
3
ts_migrations/tspublish.json
Normal file
3
ts_migrations/tspublish.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
@@ -87,11 +87,11 @@ export function getOciContainerConfig(): IDcRouterOptions {
|
|||||||
} as IDcRouterOptions['emailConfig'];
|
} as IDcRouterOptions['emailConfig'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache config
|
// DB config
|
||||||
const cacheEnabled = process.env.DCROUTER_CACHE_ENABLED;
|
const cacheEnabled = process.env.DCROUTER_CACHE_ENABLED;
|
||||||
if (cacheEnabled !== undefined) {
|
if (cacheEnabled !== undefined) {
|
||||||
options.cacheConfig = {
|
options.dbConfig = {
|
||||||
...options.cacheConfig,
|
...options.dbConfig,
|
||||||
enabled: cacheEnabled === 'true',
|
enabled: cacheEnabled === 'true',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.15.0',
|
version: '13.1.2',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import * as interfaces from '../dist_ts_interfaces/index.js';
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
// Create main app state instance
|
// Create main app state instance
|
||||||
export const appState = new plugins.domtools.plugins.smartstate.Smartstate();
|
export const appState = new plugins.domtools.plugins.smartstate.Smartstate();
|
||||||
@@ -15,6 +15,8 @@ export interface IStatsState {
|
|||||||
emailStats: interfaces.data.IEmailStats | null;
|
emailStats: interfaces.data.IEmailStats | null;
|
||||||
dnsStats: interfaces.data.IDnsStats | null;
|
dnsStats: interfaces.data.IDnsStats | null;
|
||||||
securityMetrics: interfaces.data.ISecurityMetrics | null;
|
securityMetrics: interfaces.data.ISecurityMetrics | null;
|
||||||
|
radiusStats: interfaces.data.IRadiusStats | null;
|
||||||
|
vpnStats: interfaces.data.IVpnStats | null;
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -54,6 +56,8 @@ export interface INetworkState {
|
|||||||
requestsPerSecond: number;
|
requestsPerSecond: number;
|
||||||
requestsTotal: number;
|
requestsTotal: number;
|
||||||
backends: interfaces.data.IBackendInfo[];
|
backends: interfaces.data.IBackendInfo[];
|
||||||
|
frontendProtocols: interfaces.data.IProtocolDistribution | null;
|
||||||
|
backendProtocols: interfaces.data.IProtocolDistribution | null;
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -91,6 +95,8 @@ export const statsStatePart = await appState.getStatePart<IStatsState>(
|
|||||||
emailStats: null,
|
emailStats: null,
|
||||||
dnsStats: null,
|
dnsStats: null,
|
||||||
securityMetrics: null,
|
securityMetrics: null,
|
||||||
|
radiusStats: null,
|
||||||
|
vpnStats: null,
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -110,7 +116,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
|||||||
// Determine initial view from URL path
|
// Determine initial view from URL path
|
||||||
const getInitialView = (): string => {
|
const getInitialView = (): string => {
|
||||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||||
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'];
|
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'sourceprofiles', 'networktargets', 'targetprofiles'];
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const view = segments[0];
|
const view = segments[0];
|
||||||
return validViews.includes(view) ? view : 'overview';
|
return validViews.includes(view) ? view : 'overview';
|
||||||
@@ -150,6 +156,8 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
|||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
requestsTotal: 0,
|
requestsTotal: 0,
|
||||||
backends: [],
|
backends: [],
|
||||||
|
frontendProtocols: null,
|
||||||
|
backendProtocols: null,
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -319,6 +327,8 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA
|
|||||||
dns: true,
|
dns: true,
|
||||||
security: true,
|
security: true,
|
||||||
network: false, // Network is fetched separately for the network view
|
network: false, // Network is fetched separately for the network view
|
||||||
|
radius: true,
|
||||||
|
vpn: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -328,6 +338,8 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA
|
|||||||
emailStats: combinedResponse.metrics.email || currentState.emailStats,
|
emailStats: combinedResponse.metrics.email || currentState.emailStats,
|
||||||
dnsStats: combinedResponse.metrics.dns || currentState.dnsStats,
|
dnsStats: combinedResponse.metrics.dns || currentState.dnsStats,
|
||||||
securityMetrics: combinedResponse.metrics.security || currentState.securityMetrics,
|
securityMetrics: combinedResponse.metrics.security || currentState.securityMetrics,
|
||||||
|
radiusStats: combinedResponse.metrics.radius || currentState.radiusStats,
|
||||||
|
vpnStats: combinedResponse.metrics.vpn || currentState.vpnStats,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -427,6 +439,8 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
if (viewName === 'routes' && currentState.activeView !== 'routes') {
|
if (viewName === 'routes' && currentState.activeView !== 'routes') {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||||
|
// Also fetch profiles/targets for the Create Route dropdowns
|
||||||
|
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +458,20 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If switching to security profiles or network targets views, fetch profiles/targets data
|
||||||
|
if ((viewName === 'sourceprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
|
||||||
|
setTimeout(() => {
|
||||||
|
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If switching to target profiles view, fetch target profiles data
|
||||||
|
if (viewName === 'targetprofiles' && currentState.activeView !== viewName) {
|
||||||
|
setTimeout(() => {
|
||||||
|
targetProfilesStatePart.dispatchAction(fetchTargetProfilesAction, null);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
activeView: viewName,
|
activeView: viewName,
|
||||||
@@ -506,6 +534,8 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
|||||||
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
||||||
requestsTotal: networkStatsResponse.requestsTotal || 0,
|
requestsTotal: networkStatsResponse.requestsTotal || 0,
|
||||||
backends: networkStatsResponse.backends || [],
|
backends: networkStatsResponse.backends || [],
|
||||||
|
frontendProtocols: networkStatsResponse.frontendProtocols || null,
|
||||||
|
backendProtocols: networkStatsResponse.backendProtocols || null,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -588,8 +618,8 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
|
export const reprovisionCertificateAction = certificateStatePart.createAction<{ domain: string; forceRenew?: boolean }>(
|
||||||
async (statePartArg, domain, actionContext): Promise<ICertificateState> => {
|
async (statePartArg, dataArg, actionContext): Promise<ICertificateState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState()!;
|
const currentState = statePartArg.getState()!;
|
||||||
|
|
||||||
@@ -600,7 +630,8 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
|||||||
|
|
||||||
await request.fire({
|
await request.fire({
|
||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
domain,
|
domain: dataArg.domain,
|
||||||
|
forceRenew: dataArg.forceRenew,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch overview after reprovisioning
|
// Re-fetch overview after reprovisioning
|
||||||
@@ -911,6 +942,7 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
|
|
||||||
export interface IVpnState {
|
export interface IVpnState {
|
||||||
clients: interfaces.data.IVpnClient[];
|
clients: interfaces.data.IVpnClient[];
|
||||||
|
connectedClients: interfaces.data.IVpnConnectedClient[];
|
||||||
status: interfaces.data.IVpnServerStatus | null;
|
status: interfaces.data.IVpnServerStatus | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -923,6 +955,7 @@ export const vpnStatePart = await appState.getStatePart<IVpnState>(
|
|||||||
'vpn',
|
'vpn',
|
||||||
{
|
{
|
||||||
clients: [],
|
clients: [],
|
||||||
|
connectedClients: [],
|
||||||
status: null,
|
status: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -950,14 +983,20 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
|
|||||||
interfaces.requests.IReq_GetVpnStatus
|
interfaces.requests.IReq_GetVpnStatus
|
||||||
>('/typedrequest', 'getVpnStatus');
|
>('/typedrequest', 'getVpnStatus');
|
||||||
|
|
||||||
const [clientsResponse, statusResponse] = await Promise.all([
|
const connectedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetVpnConnectedClients
|
||||||
|
>('/typedrequest', 'getVpnConnectedClients');
|
||||||
|
|
||||||
|
const [clientsResponse, statusResponse, connectedResponse] = await Promise.all([
|
||||||
clientsRequest.fire({ identity: context.identity }),
|
clientsRequest.fire({ identity: context.identity }),
|
||||||
statusRequest.fire({ identity: context.identity }),
|
statusRequest.fire({ identity: context.identity }),
|
||||||
|
connectedRequest.fire({ identity: context.identity }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
clients: clientsResponse.clients,
|
clients: clientsResponse.clients,
|
||||||
|
connectedClients: connectedResponse.connectedClients,
|
||||||
status: statusResponse.status,
|
status: statusResponse.status,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -974,8 +1013,16 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
|
|||||||
|
|
||||||
export const createVpnClientAction = vpnStatePart.createAction<{
|
export const createVpnClientAction = vpnStatePart.createAction<{
|
||||||
clientId: string;
|
clientId: string;
|
||||||
serverDefinedClientTags?: string[];
|
targetProfileIds?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState()!;
|
const currentState = statePartArg.getState()!;
|
||||||
@@ -988,8 +1035,16 @@ export const createVpnClientAction = vpnStatePart.createAction<{
|
|||||||
const response = await request.fire({
|
const response = await request.fire({
|
||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
targetProfileIds: dataArg.targetProfileIds,
|
||||||
description: dataArg.description,
|
description: dataArg.description,
|
||||||
|
|
||||||
|
destinationAllowList: dataArg.destinationAllowList,
|
||||||
|
destinationBlockList: dataArg.destinationBlockList,
|
||||||
|
useHostIp: dataArg.useHostIp,
|
||||||
|
useDhcp: dataArg.useDhcp,
|
||||||
|
staticIp: dataArg.staticIp,
|
||||||
|
forceVlan: dataArg.forceVlan,
|
||||||
|
vlanId: dataArg.vlanId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
@@ -1054,12 +1109,452 @@ export const toggleVpnClientAction = vpnStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateVpnClientAction = vpnStatePart.createAction<{
|
||||||
|
clientId: string;
|
||||||
|
description?: string;
|
||||||
|
targetProfileIds?: string[];
|
||||||
|
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateVpnClient
|
||||||
|
>('/typedrequest', 'updateVpnClient');
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
clientId: 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...currentState, error: response.message || 'Failed to update client' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actionContext!.dispatch(fetchVpnAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update VPN client',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const clearNewClientConfigAction = vpnStatePart.createAction(
|
export const clearNewClientConfigAction = vpnStatePart.createAction(
|
||||||
async (statePartArg): Promise<IVpnState> => {
|
async (statePartArg): Promise<IVpnState> => {
|
||||||
return { ...statePartArg.getState()!, newClientConfig: null };
|
return { ...statePartArg.getState()!, newClientConfig: null };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Target Profiles State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ITargetProfilesState {
|
||||||
|
profiles: interfaces.data.ITargetProfile[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const targetProfilesStatePart = await appState.getStatePart<ITargetProfilesState>(
|
||||||
|
'targetProfiles',
|
||||||
|
{
|
||||||
|
profiles: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
},
|
||||||
|
'soft'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Target Profiles Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchTargetProfilesAction = targetProfilesStatePart.createAction(
|
||||||
|
async (statePartArg): Promise<ITargetProfilesState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetTargetProfiles
|
||||||
|
>('/typedrequest', 'getTargetProfiles');
|
||||||
|
|
||||||
|
const response = await request.fire({ identity: context.identity });
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles: response.profiles,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch target profiles',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
domains?: string[];
|
||||||
|
targets?: Array<{ ip: string; port: number }>;
|
||||||
|
routeRefs?: string[];
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateTargetProfile
|
||||||
|
>('/typedrequest', 'createTargetProfile');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
domains: dataArg.domains,
|
||||||
|
targets: dataArg.targets,
|
||||||
|
routeRefs: dataArg.routeRefs,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to create target profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create target profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
domains?: string[];
|
||||||
|
targets?: Array<{ ip: string; port: number }>;
|
||||||
|
routeRefs?: string[];
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateTargetProfile
|
||||||
|
>('/typedrequest', 'updateTargetProfile');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
domains: dataArg.domains,
|
||||||
|
targets: dataArg.targets,
|
||||||
|
routeRefs: dataArg.routeRefs,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to update target profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update target profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteTargetProfile
|
||||||
|
>('/typedrequest', 'deleteTargetProfile');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
force: dataArg.force,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to delete target profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete target profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Source Profiles & Network Targets State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IProfilesTargetsState {
|
||||||
|
profiles: interfaces.data.ISourceProfile[];
|
||||||
|
targets: interfaces.data.INetworkTarget[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profilesTargetsStatePart = await appState.getStatePart<IProfilesTargetsState>(
|
||||||
|
'profilesTargets',
|
||||||
|
{
|
||||||
|
profiles: [],
|
||||||
|
targets: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
},
|
||||||
|
'soft'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Source Profiles & Network Targets Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createAction(
|
||||||
|
async (statePartArg): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profilesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetSourceProfiles
|
||||||
|
>('/typedrequest', 'getSourceProfiles');
|
||||||
|
|
||||||
|
const targetsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetNetworkTargets
|
||||||
|
>('/typedrequest', 'getNetworkTargets');
|
||||||
|
|
||||||
|
const [profilesResponse, targetsResponse] = await Promise.all([
|
||||||
|
profilesRequest.fire({ identity: context.identity }),
|
||||||
|
targetsRequest.fire({ identity: context.identity }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles: profilesResponse.profiles,
|
||||||
|
targets: targetsResponse.targets,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch profiles/targets',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createProfileAction = profilesTargetsStatePart.createAction<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: any;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateSourceProfile
|
||||||
|
>('/typedrequest', 'createSourceProfile');
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
});
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateProfileAction = profilesTargetsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
security?: any;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateSourceProfile
|
||||||
|
>('/typedrequest', 'updateSourceProfile');
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
});
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteProfileAction = profilesTargetsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteSourceProfile
|
||||||
|
>('/typedrequest', 'deleteSourceProfile');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
force: dataArg.force,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to delete profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createTargetAction = profilesTargetsStatePart.createAction<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateNetworkTarget
|
||||||
|
>('/typedrequest', 'createNetworkTarget');
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
});
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateTargetAction = profilesTargetsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
host?: string | string[];
|
||||||
|
port?: number;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateNetworkTarget
|
||||||
|
>('/typedrequest', 'updateNetworkTarget');
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
});
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteTargetAction = profilesTargetsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteNetworkTarget
|
||||||
|
>('/typedrequest', 'deleteNetworkTarget');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
force: dataArg.force,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to delete target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Management Actions
|
// Route Management Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1098,6 +1593,7 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
|
|||||||
export const createRouteAction = routeManagementStatePart.createAction<{
|
export const createRouteAction = routeManagementStatePart.createAction<{
|
||||||
route: any;
|
route: any;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
metadata?: any;
|
||||||
}>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
|
}>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState()!;
|
const currentState = statePartArg.getState()!;
|
||||||
@@ -1111,6 +1607,7 @@ export const createRouteAction = routeManagementStatePart.createAction<{
|
|||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
route: dataArg.route,
|
route: dataArg.route,
|
||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
|
metadata: dataArg.metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
||||||
@@ -1122,6 +1619,37 @@ export const createRouteAction = routeManagementStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateRouteAction = routeManagementStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
route?: any;
|
||||||
|
enabled?: boolean;
|
||||||
|
metadata?: any;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateRoute
|
||||||
|
>('/typedrequest', 'updateRoute');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
route: dataArg.route,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
metadata: dataArg.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update route',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
||||||
async (statePartArg, routeId, actionContext): Promise<IRouteManagementState> => {
|
async (statePartArg, routeId, actionContext): Promise<IRouteManagementState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
@@ -1431,6 +1959,8 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
dns: true,
|
dns: true,
|
||||||
security: true,
|
security: true,
|
||||||
network: currentView === 'network', // Only fetch network if on network view
|
network: currentView === 'network', // Only fetch network if on network view
|
||||||
|
radius: true,
|
||||||
|
vpn: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1442,6 +1972,8 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
emailStats: combinedResponse.metrics.email || currentStatsState.emailStats,
|
emailStats: combinedResponse.metrics.email || currentStatsState.emailStats,
|
||||||
dnsStats: combinedResponse.metrics.dns || currentStatsState.dnsStats,
|
dnsStats: combinedResponse.metrics.dns || currentStatsState.dnsStats,
|
||||||
securityMetrics: combinedResponse.metrics.security || currentStatsState.securityMetrics,
|
securityMetrics: combinedResponse.metrics.security || currentStatsState.securityMetrics,
|
||||||
|
radiusStats: combinedResponse.metrics.radius || currentStatsState.radiusStats,
|
||||||
|
vpnStats: combinedResponse.metrics.vpn || currentStatsState.vpnStats,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -1482,6 +2014,8 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
requestsPerSecond: network.requestsPerSecond || 0,
|
requestsPerSecond: network.requestsPerSecond || 0,
|
||||||
requestsTotal: network.requestsTotal || 0,
|
requestsTotal: network.requestsTotal || 0,
|
||||||
backends: network.backends || [],
|
backends: network.backends || [],
|
||||||
|
frontendProtocols: network.frontendProtocols || null,
|
||||||
|
backendProtocols: network.backendProtocols || null,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -1503,6 +2037,8 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
requestsPerSecond: network.requestsPerSecond || 0,
|
requestsPerSecond: network.requestsPerSecond || 0,
|
||||||
requestsTotal: network.requestsTotal || 0,
|
requestsTotal: network.requestsTotal || 0,
|
||||||
backends: network.backends || [],
|
backends: network.backends || [],
|
||||||
|
frontendProtocols: network.frontendProtocols || null,
|
||||||
|
backendProtocols: network.backendProtocols || null,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ export * from './ops-view-security.js';
|
|||||||
export * from './ops-view-certificates.js';
|
export * from './ops-view-certificates.js';
|
||||||
export * from './ops-view-remoteingress.js';
|
export * from './ops-view-remoteingress.js';
|
||||||
export * from './ops-view-vpn.js';
|
export * from './ops-view-vpn.js';
|
||||||
|
export * from './ops-view-sourceprofiles.js';
|
||||||
|
export * from './ops-view-networktargets.js';
|
||||||
|
export * from './ops-view-targetprofiles.js';
|
||||||
export * from './shared/index.js';
|
export * from './shared/index.js';
|
||||||
@@ -2,7 +2,6 @@ import * as plugins from '../plugins.js';
|
|||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
import { appRouter } from '../router.js';
|
import { appRouter } from '../router.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
css,
|
css,
|
||||||
@@ -25,6 +24,9 @@ import { OpsViewSecurity } from './ops-view-security.js';
|
|||||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||||
import { OpsViewVpn } from './ops-view-vpn.js';
|
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||||
|
import { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js';
|
||||||
|
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
|
||||||
|
import { OpsViewTargetProfiles } from './ops-view-targetprofiles.js';
|
||||||
|
|
||||||
@customElement('ops-dashboard')
|
@customElement('ops-dashboard')
|
||||||
export class OpsDashboard extends DeesElement {
|
export class OpsDashboard extends DeesElement {
|
||||||
@@ -41,6 +43,12 @@ export class OpsDashboard extends DeesElement {
|
|||||||
theme: 'light',
|
theme: 'light',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@state() accessor configState: appstate.IConfigState = {
|
||||||
|
config: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
// Store viewTabs as a property to maintain object references
|
// Store viewTabs as a property to maintain object references
|
||||||
private viewTabs = [
|
private viewTabs = [
|
||||||
{
|
{
|
||||||
@@ -73,6 +81,21 @@ export class OpsDashboard extends DeesElement {
|
|||||||
iconName: 'lucide:route',
|
iconName: 'lucide:route',
|
||||||
element: OpsViewRoutes,
|
element: OpsViewRoutes,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'SourceProfiles',
|
||||||
|
iconName: 'lucide:shieldCheck',
|
||||||
|
element: OpsViewSourceProfiles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NetworkTargets',
|
||||||
|
iconName: 'lucide:server',
|
||||||
|
element: OpsViewNetworkTargets,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'TargetProfiles',
|
||||||
|
iconName: 'lucide:target',
|
||||||
|
element: OpsViewTargetProfiles,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'ApiTokens',
|
name: 'ApiTokens',
|
||||||
iconName: 'lucide:key',
|
iconName: 'lucide:key',
|
||||||
@@ -100,6 +123,20 @@ export class OpsDashboard extends DeesElement {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private get globalMessages() {
|
||||||
|
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
|
||||||
|
const config = this.configState.config;
|
||||||
|
if (config && !config.cache.enabled) {
|
||||||
|
messages.push({
|
||||||
|
id: 'db-disabled',
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Database is disabled. Creating and editing routes, profiles, targets, and API tokens is not available.',
|
||||||
|
dismissible: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current view tab based on the UI state's activeView.
|
* Get the current view tab based on the UI state's activeView.
|
||||||
* Used to pass the correct selectedView to dees-simple-appdash on initial render.
|
* Used to pass the correct selectedView to dees-simple-appdash on initial render.
|
||||||
@@ -125,6 +162,14 @@ export class OpsDashboard extends DeesElement {
|
|||||||
});
|
});
|
||||||
this.rxSubscriptions.push(loginSubscription);
|
this.rxSubscriptions.push(loginSubscription);
|
||||||
|
|
||||||
|
// Subscribe to config state (for global warnings)
|
||||||
|
const configSubscription = appstate.configStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((configState) => {
|
||||||
|
this.configState = configState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(configSubscription);
|
||||||
|
|
||||||
// Subscribe to UI state
|
// Subscribe to UI state
|
||||||
const uiSubscription = appstate.uiStatePart
|
const uiSubscription = appstate.uiStatePart
|
||||||
.select((stateArg) => stateArg)
|
.select((stateArg) => stateArg)
|
||||||
@@ -193,6 +238,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
name="DCRouter OpsServer"
|
name="DCRouter OpsServer"
|
||||||
.viewTabs=${this.viewTabs}
|
.viewTabs=${this.viewTabs}
|
||||||
.selectedView=${this.currentViewTab}
|
.selectedView=${this.currentViewTab}
|
||||||
|
.globalMessages=${this.globalMessages}
|
||||||
>
|
>
|
||||||
</dees-simple-appdash>
|
</dees-simple-appdash>
|
||||||
</dees-simple-login>
|
</dees-simple-login>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
const { apiTokens } = this.routeState;
|
const { apiTokens } = this.routeState;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>API Tokens</ops-sectionheading>
|
<dees-heading level="2">API Tokens</dees-heading>
|
||||||
|
|
||||||
<div class="apiTokensContainer">
|
<div class="apiTokensContainer">
|
||||||
<dees-table
|
<dees-table
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
const { summary } = this.certState;
|
const { summary } = this.certState;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Certificates</ops-sectionheading>
|
<dees-heading level="2">Certificates</dees-heading>
|
||||||
|
|
||||||
<div class="certificatesContainer">
|
<div class="certificatesContainer">
|
||||||
${this.renderStatsTiles(summary)}
|
${this.renderStatsTiles(summary)}
|
||||||
@@ -299,7 +299,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'Reprovision',
|
name: 'Reprovision',
|
||||||
iconName: 'lucide:RefreshCw',
|
iconName: 'lucide:RefreshCw',
|
||||||
type: ['inRow'],
|
type: ['inRow', 'contextmenu'],
|
||||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||||
const cert = actionData.item;
|
const cert = actionData.item;
|
||||||
if (!cert.canReprovision) {
|
if (!cert.canReprovision) {
|
||||||
@@ -311,16 +311,41 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await appstate.certificateStatePart.dispatchAction(
|
|
||||||
appstate.reprovisionCertificateAction,
|
const doReprovision = async (forceRenew = false) => {
|
||||||
cert.domain,
|
await appstate.certificateStatePart.dispatchAction(
|
||||||
);
|
appstate.reprovisionCertificateAction,
|
||||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
{ domain: cert.domain, forceRenew },
|
||||||
DeesToast.show({
|
);
|
||||||
message: `Reprovisioning triggered for ${cert.domain}`,
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
type: 'success',
|
DeesToast.show({
|
||||||
duration: 3000,
|
message: forceRenew
|
||||||
});
|
? `Force renewal triggered for ${cert.domain}`
|
||||||
|
: `Reprovisioning triggered for ${cert.domain}`,
|
||||||
|
type: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cert.status === 'valid') {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Certificate Still Valid',
|
||||||
|
content: html`<p style="margin: 0; line-height: 1.5;">The certificate for <strong>${cert.domain}</strong> is still valid${cert.expiryDate ? ` until ${new Date(cert.expiryDate).toLocaleDateString()}` : ''}. Do you want to force renew it now?</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Force Renew',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
await doReprovision(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await doReprovision();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Configuration</ops-sectionheading>
|
<dees-heading level="2">Configuration</dees-heading>
|
||||||
|
|
||||||
${this.configState.isLoading
|
${this.configState.isLoading
|
||||||
? html`
|
? html`
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Email Operations</ops-sectionheading>
|
<dees-heading level="2">Email Operations</dees-heading>
|
||||||
<div class="viewContainer">
|
<div class="viewContainer">
|
||||||
${this.currentView === 'detail' && this.selectedEmail
|
${this.currentView === 'detail' && this.selectedEmail
|
||||||
? html`
|
? html`
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Logs</ops-sectionheading>
|
<dees-heading level="2">Logs</dees-heading>
|
||||||
|
|
||||||
<dees-chart-log
|
<dees-chart-log
|
||||||
.label=${'Application Logs'}
|
.label=${'Application Logs'}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ interface INetworkRequest {
|
|||||||
|
|
||||||
@customElement('ops-view-network')
|
@customElement('ops-view-network')
|
||||||
export class OpsViewNetwork extends DeesElement {
|
export class OpsViewNetwork extends DeesElement {
|
||||||
|
/** How far back the traffic chart shows */
|
||||||
|
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
/** How often a new data point is added */
|
||||||
|
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
|
||||||
|
/** Derived: max data points the buffer holds */
|
||||||
|
private static readonly MAX_DATA_POINTS = OpsViewNetwork.CHART_WINDOW_MS / OpsViewNetwork.UPDATE_INTERVAL_MS;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor statsState = appstate.statsStatePart.getState()!;
|
accessor statsState = appstate.statsStatePart.getState()!;
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
|
|
||||||
// Track if we need to update the chart to avoid unnecessary re-renders
|
// Track if we need to update the chart to avoid unnecessary re-renders
|
||||||
private lastChartUpdate = 0;
|
private lastChartUpdate = 0;
|
||||||
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
|
private chartUpdateThreshold = OpsViewNetwork.UPDATE_INTERVAL_MS; // Minimum ms between chart updates
|
||||||
|
|
||||||
private trafficUpdateTimer: any = null;
|
private trafficUpdateTimer: any = null;
|
||||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||||
@@ -104,13 +111,11 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
|
|
||||||
private initializeTrafficData() {
|
private initializeTrafficData() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Fixed 5 minute time range
|
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork;
|
||||||
const range = 5 * 60 * 1000; // 5 minutes
|
|
||||||
const bucketSize = range / 60; // 60 data points
|
|
||||||
|
|
||||||
// Initialize with empty data points for both in and out
|
// Initialize with empty data points for both in and out
|
||||||
const emptyData = Array.from({ length: 60 }, (_, i) => {
|
const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => {
|
||||||
const time = now - ((59 - i) * bucketSize);
|
const time = now - ((MAX_DATA_POINTS - 1 - i) * UPDATE_INTERVAL_MS);
|
||||||
return {
|
return {
|
||||||
x: new Date(time).toISOString(),
|
x: new Date(time).toISOString(),
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -143,23 +148,23 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
|
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Use history as the chart data, keeping the most recent 60 points (5 min window)
|
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork;
|
||||||
const sliceStart = Math.max(0, historyIn.length - 60);
|
|
||||||
|
// Use history as the chart data, keeping the most recent points within the window
|
||||||
|
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
|
||||||
this.trafficDataIn = historyIn.slice(sliceStart);
|
this.trafficDataIn = historyIn.slice(sliceStart);
|
||||||
this.trafficDataOut = historyOut.slice(sliceStart);
|
this.trafficDataOut = historyOut.slice(sliceStart);
|
||||||
|
|
||||||
// If fewer than 60 points, pad the front with zeros
|
// If fewer than MAX_DATA_POINTS, pad the front with zeros
|
||||||
if (this.trafficDataIn.length < 60) {
|
if (this.trafficDataIn.length < MAX_DATA_POINTS) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const range = 5 * 60 * 1000;
|
const padCount = MAX_DATA_POINTS - this.trafficDataIn.length;
|
||||||
const bucketSize = range / 60;
|
|
||||||
const padCount = 60 - this.trafficDataIn.length;
|
|
||||||
const firstTimestamp = this.trafficDataIn.length > 0
|
const firstTimestamp = this.trafficDataIn.length > 0
|
||||||
? new Date(this.trafficDataIn[0].x).getTime()
|
? new Date(this.trafficDataIn[0].x).getTime()
|
||||||
: now;
|
: now;
|
||||||
|
|
||||||
const padIn = Array.from({ length: padCount }, (_, i) => ({
|
const padIn = Array.from({ length: padCount }, (_, i) => ({
|
||||||
x: new Date(firstTimestamp - ((padCount - i) * bucketSize)).toISOString(),
|
x: new Date(firstTimestamp - ((padCount - i) * UPDATE_INTERVAL_MS)).toISOString(),
|
||||||
y: 0,
|
y: 0,
|
||||||
}));
|
}));
|
||||||
const padOut = padIn.map(p => ({ ...p }));
|
const padOut = padIn.map(p => ({ ...p }));
|
||||||
@@ -269,12 +274,18 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
|
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
|
||||||
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
|
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.protocolChartGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Network Activity</ops-sectionheading>
|
<dees-heading level="2">Network Activity</dees-heading>
|
||||||
|
|
||||||
<div class="networkContainer">
|
<div class="networkContainer">
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
@@ -287,29 +298,22 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'Inbound',
|
name: 'Inbound',
|
||||||
data: this.trafficDataIn,
|
data: this.trafficDataIn,
|
||||||
color: '#22c55e', // Green for download
|
color: '#22c55e',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Outbound',
|
name: 'Outbound',
|
||||||
data: this.trafficDataOut,
|
data: this.trafficDataOut,
|
||||||
color: '#8b5cf6', // Purple for upload
|
color: '#8b5cf6',
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
.stacked=${false}
|
.realtimeMode=${true}
|
||||||
|
.rollingWindow=${OpsViewNetwork.CHART_WINDOW_MS}
|
||||||
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
||||||
.tooltipFormatter=${(point: any) => {
|
|
||||||
const mbps = point.y || 0;
|
|
||||||
const seriesName = point.series?.name || 'Throughput';
|
|
||||||
const timestamp = new Date(point.x).toLocaleTimeString();
|
|
||||||
return `
|
|
||||||
<div style="padding: 8px;">
|
|
||||||
<div style="font-weight: bold; margin-bottom: 4px;">${timestamp}</div>
|
|
||||||
<div>${seriesName}: ${mbps.toFixed(2)} Mbit/s</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}}
|
|
||||||
></dees-chart-area>
|
></dees-chart-area>
|
||||||
|
|
||||||
|
<!-- Protocol Distribution Charts -->
|
||||||
|
${this.renderProtocolCharts()}
|
||||||
|
|
||||||
<!-- Top IPs Section -->
|
<!-- Top IPs Section -->
|
||||||
${this.renderTopIPs()}
|
${this.renderTopIPs()}
|
||||||
|
|
||||||
@@ -532,7 +536,54 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderProtocolCharts(): TemplateResult {
|
||||||
|
const fp = this.networkState.frontendProtocols;
|
||||||
|
const bp = this.networkState.backendProtocols;
|
||||||
|
|
||||||
|
const protoColors: Record<string, string> = {
|
||||||
|
'HTTP/1.1': '#1976d2',
|
||||||
|
'HTTP/2': '#388e3c',
|
||||||
|
'HTTP/3': '#7b1fa2',
|
||||||
|
'WebSocket': '#f57c00',
|
||||||
|
'Other': '#757575',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDonutData = (dist: interfaces.data.IProtocolDistribution | null) => {
|
||||||
|
if (!dist) return [];
|
||||||
|
const items: Array<{ name: string; value: number; color: string }> = [];
|
||||||
|
if (dist.h1Active > 0) items.push({ name: 'HTTP/1.1', value: dist.h1Active, color: protoColors['HTTP/1.1'] });
|
||||||
|
if (dist.h2Active > 0) items.push({ name: 'HTTP/2', value: dist.h2Active, color: protoColors['HTTP/2'] });
|
||||||
|
if (dist.h3Active > 0) items.push({ name: 'HTTP/3', value: dist.h3Active, color: protoColors['HTTP/3'] });
|
||||||
|
if (dist.wsActive > 0) items.push({ name: 'WebSocket', value: dist.wsActive, color: protoColors['WebSocket'] });
|
||||||
|
if (dist.otherActive > 0) items.push({ name: 'Other', value: dist.otherActive, color: protoColors['Other'] });
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const frontendData = buildDonutData(fp);
|
||||||
|
const backendData = buildDonutData(bp);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="protocolChartGrid">
|
||||||
|
<dees-chart-donut
|
||||||
|
.label=${'Frontend Protocols'}
|
||||||
|
.data=${frontendData.length > 0 ? frontendData : [{ name: 'No Traffic', value: 1, color: '#757575' }]}
|
||||||
|
.showLegend=${true}
|
||||||
|
.showLabels=${true}
|
||||||
|
.innerRadiusPercent=${'55%'}
|
||||||
|
.valueFormatter=${(val: number) => `${val} active`}
|
||||||
|
></dees-chart-donut>
|
||||||
|
<dees-chart-donut
|
||||||
|
.label=${'Backend Protocols'}
|
||||||
|
.data=${backendData.length > 0 ? backendData : [{ name: 'No Traffic', value: 1, color: '#757575' }]}
|
||||||
|
.showLegend=${true}
|
||||||
|
.showLabels=${true}
|
||||||
|
.innerRadiusPercent=${'55%'}
|
||||||
|
.valueFormatter=${(val: number) => `${val} active`}
|
||||||
|
></dees-chart-donut>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderTopIPs(): TemplateResult {
|
private renderTopIPs(): TemplateResult {
|
||||||
if (this.networkState.topIPs.length === 0) {
|
if (this.networkState.topIPs.length === 0) {
|
||||||
return html``;
|
return html``;
|
||||||
@@ -719,9 +770,8 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
private startTrafficUpdateTimer() {
|
private startTrafficUpdateTimer() {
|
||||||
this.stopTrafficUpdateTimer(); // Clear any existing timer
|
this.stopTrafficUpdateTimer(); // Clear any existing timer
|
||||||
this.trafficUpdateTimer = setInterval(() => {
|
this.trafficUpdateTimer = setInterval(() => {
|
||||||
// Add a new data point every second
|
|
||||||
this.addTrafficDataPoint();
|
this.addTrafficDataPoint();
|
||||||
}, 1000); // Update every second
|
}, OpsViewNetwork.UPDATE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addTrafficDataPoint() {
|
private addTrafficDataPoint() {
|
||||||
@@ -752,7 +802,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
|
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
|
||||||
if (this.trafficDataIn.length >= 60) {
|
if (this.trafficDataIn.length >= OpsViewNetwork.MAX_DATA_POINTS) {
|
||||||
this.trafficDataIn.shift();
|
this.trafficDataIn.shift();
|
||||||
this.trafficDataOut.shift();
|
this.trafficDataOut.shift();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user