Compare commits
218 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5689e93665 | |||
| c224028495 | |||
| 4fbe01823b | |||
| 34ba2c9f02 | |||
| 52aed0e96e | |||
| ea2e618990 | |||
| 140637a307 | |||
| 21c80e173d | |||
| e77fe9451e | |||
| 7971bd249e | |||
| 6099563acd | |||
| bf4c181026 | |||
| d9d12427d3 | |||
| 91aa9a7228 | |||
| 877356b247 | |||
| 2325f01cde | |||
| 00fdadb088 | |||
| 2b76e05a40 | |||
| 1b37944aab | |||
| 35a01a6981 | |||
| 3058706d2a | |||
| 0e4d6a3c0c | |||
| 2bc2475878 | |||
| 37eab7c7b1 | |||
| 8ab7343606 | |||
| f04feec273 | |||
| d320590ce2 | |||
| 0ee57f433b | |||
| b28b5eea84 | |||
| 27d7489af9 | |||
| 940c7dc92e | |||
| 7fa6d82e58 | |||
| f29ed9757e | |||
| ad45d1b8b9 | |||
| 68473f8550 | |||
| 07cfe76cac | |||
| 3775957bf2 | |||
| 31ce18a025 | |||
| 0cccec5526 | |||
| 0373f02f86 | |||
| 52dac0339f | |||
| b6f7f5f63f | |||
| 6271bb1079 | |||
| 0fa65f31c3 | |||
| 93d6c7d341 | |||
| b2ccd54079 | |||
| 4e9b09616d | |||
| ddb420835e | |||
| 505fd044c0 | |||
| 7711204fef | |||
| d7b6fbb241 | |||
| a670b27a1c | |||
| c2f57b086f | |||
| 083f16d7b4 | |||
| 2994b6e686 | |||
| ba15c169d7 | |||
| bbd5707711 | |||
| 1ddf83b28d | |||
| 25365678e0 | |||
| 96d215fc66 | |||
| 648ba9e61d | |||
| fcc1d9fede | |||
| 336e8aa4cc | |||
| c8f19cf783 | |||
| 12b2cc11da | |||
| ffcc35be64 | |||
| 59e0d41bdb | |||
| 9509d87b1e | |||
| b835e2d0eb | |||
| 6c3d8714a2 | |||
| 94f53f0259 | |||
| 1004f8579f | |||
| a77ec6884a | |||
| 6112e4e884 | |||
| 4a6913d4bb | |||
| f6a9e344e5 | |||
| b3296c6522 | |||
| 10a2b922d3 | |||
| ee5cdde225 | |||
| d2e9efccd0 | |||
| a07901a28a | |||
| a3954d6eb5 | |||
| 9685fcd89d | |||
| 74c23ce5ff | |||
| 746fbb15e6 | |||
| 415065b246 | |||
| 30aeef7bbd | |||
| dba1c70fa7 | |||
| f9cfb3d36b | |||
| 43b92b784d | |||
| b62a322c54 | |||
| a3a64e9a02 | |||
| 491e51f40b | |||
| b46247d9cb | |||
| 9c0e46ff4e | |||
| f62bc4a526 | |||
| 8f23600ec1 | |||
| 141f185fbf | |||
| 6f4a5f19e7 | |||
| 9d8354e58f | |||
| 947637eed7 | |||
| 5202c2ea27 | |||
| 6684dc43da | |||
| 04ec387ce5 | |||
| f145798f39 | |||
| 55f5465a9a | |||
| 0577f45ced | |||
| 7d23617f15 | |||
| 02415f8c53 | |||
| 73a47e5a97 | |||
| 5e980812b0 | |||
| 76e9735cde | |||
| 8bfc0c2fa2 | |||
| 55699f6618 | |||
| 6344c2deae | |||
| c1452131fa | |||
| 81f8e543e1 | |||
| bb6c26484d | |||
| 193a4bb180 | |||
| 0d9e6a4925 | |||
| ece9e46be9 | |||
| 918390a6a4 | |||
| 4ec0b67a71 | |||
| 356d6eca77 | |||
| 39c77accf8 | |||
| b8fba52cb3 | |||
| f247c77807 | |||
| e88938cf95 | |||
| 4f705a591e | |||
| 29687670e8 | |||
| 95daee1d8f | |||
| 11ca64a1cd | |||
| cfb727b86d | |||
| 1e4b9997f4 | |||
| bb32f23d77 | |||
| 1aa6451dba | |||
| eb0408c036 | |||
| 098a2567fa | |||
| c6534df362 | |||
| 2e4b375ad5 | |||
| 802bcf1c3d | |||
| bad0bd9053 | |||
| ca990781b0 | |||
| 6807aefce8 | |||
| 450ec4816e | |||
| ab4310b775 | |||
| 6efd986406 | |||
| 7370d7f0e7 | |||
| e733067c25 | |||
| bc2ed808f9 | |||
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 | |||
| d53cff6a94 | |||
| eb211348d2 | |||
| 43618abeba | |||
| dd9769b814 | |||
| 99b40fea3f | |||
| 6f72e4fdbc | |||
| fbe845cd8e | |||
| 31413d28be | |||
| cd286cede6 | |||
| 36a3060cce | |||
| d2b108317e | |||
| dcd75f5e47 | |||
| 3d443fa147 | |||
| 2efdd2f16b | |||
| ec0348a83c | |||
| 6c4adf70c7 | |||
| 29d6076355 | |||
| fa96a41e68 | |||
| 1ea38ed5d2 | |||
| 7209903d02 | |||
| 20eda1ab3e | |||
| 44f2a7f0a9 | |||
| 0195a21f30 | |||
| 4dca747386 | |||
| 7663f502fa | |||
| 104cd417d8 | |||
| 93254d5d3d | |||
| 9a3f121a9c | |||
| bef74eb1aa | |||
| 308d8e4851 | |||
| dc010dc3ae | |||
| 61d5d3b1ad | |||
| dd70790d40 | |||
| 2f8c04edc4 | |||
| 474cc328dd | |||
| 39ff159bf7 | |||
| c7fe7aeb50 | |||
| 2cf362020f | |||
| b62bad3616 | |||
| 3d372863a4 | |||
| 1045dc04fe | |||
| 89ef7597df | |||
| 0804544564 | |||
| 671e72452a | |||
| 647c705b81 | |||
| 40c3202082 | |||
| 3b91ed3d5a | |||
| 133b17f136 | |||
| efa45dfdc9 | |||
| 79b4ea6bd9 | |||
| b483412a2e | |||
| d964515ff9 | |||
| e2c453423e | |||
| c44b7d513a | |||
| 2487f77b8a | |||
| ea80ef005c | |||
| dd45b7fbe7 | |||
| ca73da7b9b | |||
| f6e1951aa2 | |||
| 76fd563e21 |
@@ -1 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.nogit/
|
||||||
|
.git/
|
||||||
|
.playwright-mcp/
|
||||||
|
.vscode/
|
||||||
|
test/
|
||||||
|
test_watch/
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"json.schemas": [
|
"json.schemas": [
|
||||||
{
|
{
|
||||||
"fileMatch": ["/npmextra.json"],
|
"fileMatch": ["/.smartconfig.json"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -18,9 +18,17 @@ WORKDIR /app
|
|||||||
COPY --from=build /app /app
|
COPY --from=build /app /app
|
||||||
|
|
||||||
ENV DCROUTER_MODE=OCI_CONTAINER
|
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||||
|
ENV DCROUTER_HEAP_SIZE=512
|
||||||
|
ENV UV_THREADPOOL_SIZE=16
|
||||||
|
|
||||||
RUN pnpm install -g @servezone/healthy
|
RUN pnpm install -g @servezone/healthy
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
|
||||||
|
|
||||||
EXPOSE 80
|
LABEL org.opencontainers.image.title="dcrouter" \
|
||||||
CMD ["npm", "start"]
|
org.opencontainers.image.description="Multi-service datacenter gateway" \
|
||||||
|
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
|
||||||
|
|
||||||
|
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
|
||||||
|
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]
|
||||||
|
|||||||
691
changelog.md
691
changelog.md
@@ -1,5 +1,696 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.8.0 - feat(acme)
|
||||||
|
add DB-backed ACME configuration management and OpsServer certificate settings UI
|
||||||
|
|
||||||
|
- introduces a singleton AcmeConfig manager and document persisted in the database, with first-boot seeding from legacy tls.contactEmail and smartProxyConfig.acme options
|
||||||
|
- updates SmartProxy startup to read live ACME settings from the database and only enable DNS-01 challenge wiring when ACME is configured and enabled
|
||||||
|
- adds authenticated OpsServer typed request endpoints and API token scopes for reading and updating ACME configuration
|
||||||
|
- adds web app state and a certificates view card/modal for viewing and editing ACME settings from the Domains certificate UI
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.7.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.7.0 - feat(dns-providers)
|
||||||
|
add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows
|
||||||
|
|
||||||
|
- Introduce shared DNS provider type descriptors and credential field metadata to drive provider forms dynamically.
|
||||||
|
- Add a reusable dns-provider-form component and update provider create/edit dialogs to use typed provider selection and credential handling.
|
||||||
|
- Remove Cloudflare-specific ACME helper exposure and clarify provider-agnostic DNS manager and factory documentation for future provider implementations.
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.6.0 - feat(dns)
|
||||||
|
add db-backed DNS provider, domain, and record management with ops UI support
|
||||||
|
|
||||||
|
- introduce DnsManager-backed persistence for DNS providers, domains, and records with Cloudflare provider support
|
||||||
|
- replace constructor-based ACME DNS challenge configuration with provider records stored in the database
|
||||||
|
- add opsserver typed request handlers and API token scopes for managing DNS providers, domains, and records
|
||||||
|
- add a new Domains section in the ops UI for providers, domains, DNS records, and certificates
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
|
||||||
|
add admin user listing to the access dashboard
|
||||||
|
|
||||||
|
- register a new admin-only typed request endpoint to list users with id, username, and role while excluding passwords
|
||||||
|
- add users state management and a dedicated access dashboard view for browsing OpsServer user accounts
|
||||||
|
- update access routing to include the new users subview and improve related table filtering and section headings
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.4.2 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.4.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.4.0 - feat(web-ui)
|
||||||
|
reorganize dashboard views into grouped navigation with new email, access, and network subviews
|
||||||
|
|
||||||
|
- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security.
|
||||||
|
- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation.
|
||||||
|
- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior.
|
||||||
|
- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling.
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.3.0 - feat(web-ui)
|
||||||
|
reorganize network and security views into tabbed subviews with route-aware navigation
|
||||||
|
|
||||||
|
- add URL-based subview support in app state and router for network and security sections
|
||||||
|
- group routes, source profiles, network targets, and target profiles under the network view with tab navigation
|
||||||
|
- split security into dedicated overview, blocked IPs, authentication, and email security subviews
|
||||||
|
- update configuration navigation to deep-link directly to the network routes subview
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.2.2 - fix(project)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.2.1 - fix(project)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.2.0 - feat(ops-ui)
|
||||||
|
add column filters to operations tables across admin views
|
||||||
|
|
||||||
|
- Enable table column filters for API tokens, certificates, network requests, top IPs, backends, network targets, remote ingress edges, security views, source profiles, target profiles, and VPN clients.
|
||||||
|
- Improves filtering and exploration of operational data throughout the admin interface without changing backend behavior.
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.1.3 - fix(certificate-handler)
|
||||||
|
preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
|
||||||
|
|
||||||
|
- add deriveCertDomainName helper to match shared ACME certificate identities across wildcard and subdomain routes
|
||||||
|
- pass includeWildcard when force-renewing certificates so renewed certs keep wildcard SAN coverage for sibling subdomains
|
||||||
|
- persist renewed certificate data to all sibling route domains that share the same cert identity and clear cached certificate status entries
|
||||||
|
- add regression tests for certificate domain derivation and force-renew wildcard handling
|
||||||
|
|
||||||
|
## 2026-04-07 - 13.1.2 - fix(deps)
|
||||||
|
bump @serve.zone/catalog to ^2.12.3
|
||||||
|
|
||||||
|
- Updates @serve.zone/catalog from ^2.12.0 to ^2.12.3 in package.json
|
||||||
|
|
||||||
|
## 2026-04-07 - 13.1.1 - fix(deps)
|
||||||
|
bump catalog-related dependencies to newer patch and minor releases
|
||||||
|
|
||||||
|
- update @design.estate/dees-catalog from ^3.66.0 to ^3.67.1
|
||||||
|
- update @serve.zone/catalog from ^2.11.2 to ^2.12.0
|
||||||
|
|
||||||
|
## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations)
|
||||||
|
add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips
|
||||||
|
|
||||||
|
- runs smartmigration at startup before configuration is loaded and adds a migration for target profile targets from host to ip
|
||||||
|
- changes VPN client routing to always force traffic through SmartProxy while allowing direct target bypasses from target profiles
|
||||||
|
- supports domain-scoped VPN ipAllowList entries for vpnOnly routes based on matching target profile domains
|
||||||
|
- updates certificate reprovisioning to reapply routes so renewed certificates are loaded into the running proxy
|
||||||
|
- removes the forceDestinationSmartproxy VPN client option from API, persistence, manager, and web UI
|
||||||
|
|
||||||
|
## 2026-04-06 - 13.0.11 - fix(routing)
|
||||||
|
serialize route updates and correct VPN-gated route application
|
||||||
|
|
||||||
|
- RouteConfigManager now serializes concurrent applyRoutes calls to prevent overlapping SmartProxy updates and stale route overwrites.
|
||||||
|
- VPN-only routes deny access until VPN state is ready, then re-apply routes after VPN clients load or change to refresh ipAllowLists safely.
|
||||||
|
- Certificate provisioning retries now go through RouteConfigManager when available so the full merged route set is reapplied consistently.
|
||||||
|
- Reference resolution now expands network targets with multiple hosts into multiple route targets.
|
||||||
|
- Adds rollback when VPN client persistence fails, enforces unique target profile names, and fixes maxConnections parsing in the source profiles UI.
|
||||||
|
|
||||||
|
## 2026-04-06 - 13.0.10 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-06 - 13.0.9 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-06 - 13.0.8 - fix(ops-view-vpn)
|
||||||
|
show target profile names in VPN forms and load profile candidates for autocomplete
|
||||||
|
|
||||||
|
- fetch target profiles when the VPN operations view connects so profile data is available in the UI
|
||||||
|
- replace comma-separated target profile ID inputs with a restricted autocomplete list based on available target profiles
|
||||||
|
- map stored target profile IDs to profile names for table and detail displays, while resolving selected names back to IDs on save
|
||||||
|
|
||||||
|
## 2026-04-06 - 13.0.7 - fix(vpn,target-profiles)
|
||||||
|
refresh VPN client security when target profiles change and include profile target IPs in direct destination allow-lists
|
||||||
|
|
||||||
|
- Adds direct target IP resolution from target profiles so forced SmartProxy clients can bypass rewriting for explicit profile targets.
|
||||||
|
- Refreshes running VPN client security policies after target profile updates or deletions to keep destination access rules in sync.
|
||||||
|
|
||||||
|
## 2026-04-05 - 13.0.6 - fix(certificates)
|
||||||
|
resolve base-domain certificate lookups and route profile list inputs
|
||||||
|
|
||||||
|
- Look up ACME certificate metadata by base domain first, with fallback to the exact domain, so subdomain certificate status and deletion work reliably.
|
||||||
|
- Trigger certificate reprovisioning through SmartProxy routes and clear cached status before refresh, including force-renew cache invalidation handling.
|
||||||
|
- Replace comma-separated target profile form fields with list inputs and route suggestions for domains, targets, and route references.
|
||||||
|
|
||||||
|
## 2026-04-05 - 13.0.5 - fix(ts_web)
|
||||||
|
replace custom section heading component with dees-heading across ops views
|
||||||
|
|
||||||
|
- updates all operations dashboard views to use <dees-heading level="2"> for section titles
|
||||||
|
- removes the unused shared ops-sectionheading component export and source file
|
||||||
|
- bumps UI and data layer dependencies to compatible patch/minor releases
|
||||||
|
|
||||||
|
## 2026-04-05 - 13.0.4 - fix(deps)
|
||||||
|
bump @push.rocks/smartdata and @push.rocks/smartdb to the latest patch releases
|
||||||
|
|
||||||
|
- Updates @push.rocks/smartdata from ^7.1.4 to ^7.1.5
|
||||||
|
- Updates @push.rocks/smartdb from ^2.5.2 to ^2.5.4
|
||||||
|
|
||||||
|
## 2026-04-05 - 13.0.3 - fix(deps)
|
||||||
|
bump @push.rocks/smartdb to ^2.5.2
|
||||||
|
|
||||||
|
- Updates @push.rocks/smartdb from ^2.5.1 to ^2.5.2 in package.json.
|
||||||
|
|
||||||
|
## 2026-04-05 - 13.0.2 - fix(deps)
|
||||||
|
bump smartdata, smartdb, and catalog dependencies
|
||||||
|
|
||||||
|
- updates @push.rocks/smartdata from ^7.1.3 to ^7.1.4
|
||||||
|
- updates @push.rocks/smartdb from ^2.4.1 to ^2.5.1
|
||||||
|
- updates @serve.zone/catalog from ^2.11.1 to ^2.11.2
|
||||||
|
|
||||||
|
## 2026-04-05 - 13.0.1 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies
|
||||||
|
|
||||||
|
- updates @design.estate/dees-catalog from ^3.55.6 to ^3.59.1
|
||||||
|
- updates @push.rocks/smartdb from ^2.3.1 to ^2.4.1
|
||||||
|
|
||||||
|
## 2026-04-05 - 13.0.0 - BREAKING CHANGE(vpn)
|
||||||
|
replace tag-based VPN access control with source and target profiles
|
||||||
|
|
||||||
|
- Renames Security Profiles to Source Profiles across APIs, persistence, route metadata, tests, and UI.
|
||||||
|
- Adds TargetProfile management, storage, API handlers, and dashboard views to define VPN-accessible domains, targets, and route references.
|
||||||
|
- Replaces route-level vpn configuration with vpnOnly and switches VPN clients from serverDefinedClientTags to targetProfileIds for access resolution.
|
||||||
|
- Updates route application and VPN AllowedIPs generation to derive client access from matching target profiles instead of tags.
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.10.0 - feat(routes)
|
||||||
|
add TLS configuration controls for route create and edit flows
|
||||||
|
|
||||||
|
- Adds TLS mode and certificate selection to the route create and edit dialogs, including support for custom PEM key/certificate input.
|
||||||
|
- Allows route updates to explicitly remove nested TLS settings by treating null action properties as deletions during route patch merging.
|
||||||
|
- Bumps @design.estate/dees-catalog to ^3.55.6 and @serve.zone/catalog to ^2.11.1.
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.9.4 - fix(deps)
|
||||||
|
bump @push.rocks/smartdb to ^2.3.1
|
||||||
|
|
||||||
|
- updates the @push.rocks/smartdb dependency from ^2.1.1 to ^2.3.1
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.9.3 - fix(route-management)
|
||||||
|
include stored VPN routes in domain resolution and align programmatic route types with dcrouter configs
|
||||||
|
|
||||||
|
- Scans enabled stored/programmatic routes for VPN domain matches when resolving client access domains.
|
||||||
|
- Replaces generic smartproxy route typings with IDcRouterRouteConfig across route management and stored route models.
|
||||||
|
- Updates @push.rocks/smartproxy to ^27.4.0.
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.9.2 - fix(config-ui)
|
||||||
|
handle missing HTTP/3 config safely and standardize overview section headings
|
||||||
|
|
||||||
|
- Prevents route augmentation logic from failing when HTTP/3 configuration is undefined by using optional chaining.
|
||||||
|
- Updates the operations overview to use dees-heading components for activity, email, DNS, RADIUS, and VPN section headings.
|
||||||
|
- Bumps @push.rocks/smartproxy from ^27.2.0 to ^27.3.1.
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.9.1 - fix(monitoring)
|
||||||
|
update SmartProxy and use direct connection protocol metrics access
|
||||||
|
|
||||||
|
- bump @push.rocks/smartproxy from ^27.1.0 to ^27.2.0
|
||||||
|
- replace fallback any-based access with direct frontend and backend protocol metric calls in MetricsManager
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.9.0 - feat(monitoring)
|
||||||
|
add frontend and backend protocol distribution metrics to network stats
|
||||||
|
|
||||||
|
- Expose frontend and backend protocol distribution data in monitoring metrics, stats responses, and shared interfaces.
|
||||||
|
- Render protocol distribution donut charts in the ops network view using the new stats fields.
|
||||||
|
- Preserve existing stored certificate IDs when updating certificate records by domain.
|
||||||
|
- Bump @design.estate/dees-catalog to ^3.55.5 for the new chart component support.
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.8.1 - fix(ops-view-routes)
|
||||||
|
correct route form dropdown selection handling for security profiles and network targets
|
||||||
|
|
||||||
|
- Update route edit and create forms to use selectedOption for dropdowns backed by the newer dees-catalog version
|
||||||
|
- Normalize submitted dropdown values to extract option keys before storing securityProfileRef and networkTargetRef
|
||||||
|
- Refresh documentation to reflect expanded stats coverage for network, RADIUS, and VPN metrics
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.8.0 - feat(certificates)
|
||||||
|
add force renew option for domain certificate reprovisioning
|
||||||
|
|
||||||
|
- pass an optional forceRenew flag through certificate reprovision requests from the UI to the ops handler
|
||||||
|
- use smartacme forceRenew support and return renewal-specific success messages
|
||||||
|
- update the SmartAcme dependency to version ^9.4.0
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.7.0 - feat(opsserver)
|
||||||
|
add RADIUS and VPN metrics to combined ops stats and overview dashboards, and stream live log buffer entries in follow mode
|
||||||
|
|
||||||
|
- Expose RADIUS and VPN sections in the combined stats API and shared TypeScript interfaces
|
||||||
|
- Populate frontend app state and overview tiles with RADIUS authentication, session, traffic, and VPN client metrics
|
||||||
|
- Replace simulated follow-mode log events with real log buffer tailing and timestamp-based incremental streaming
|
||||||
|
- Use commit metadata for reported server version instead of a hardcoded value
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.6 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog to ^3.52.3
|
||||||
|
|
||||||
|
- Updates @design.estate/dees-catalog from ^3.52.2 to ^3.52.3 in package.json
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.5 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog to ^3.52.2
|
||||||
|
|
||||||
|
- Updates the @design.estate/dees-catalog dependency from ^3.52.0 to ^3.52.2 in package.json.
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.4 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog to ^3.52.0
|
||||||
|
|
||||||
|
- Updates the @design.estate/dees-catalog dependency from ^3.51.2 to ^3.52.0 in package.json.
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.3 - fix(deps)
|
||||||
|
bump @types/node and @design.estate/dees-catalog patch versions
|
||||||
|
|
||||||
|
- updates @types/node from ^25.5.1 to ^25.5.2
|
||||||
|
- updates @design.estate/dees-catalog from ^3.51.1 to ^3.51.2
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.2 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog to ^3.51.1
|
||||||
|
|
||||||
|
- Updates @design.estate/dees-catalog from ^3.51.0 to ^3.51.1 in package.json
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.0 - feat(certificates)
|
||||||
|
add confirmation before force renewing valid certificates from the certificate actions menu
|
||||||
|
|
||||||
|
- Expose the Reprovision action in the certificate context menu
|
||||||
|
- Prompt for confirmation when reprovisioning a certificate that is still valid
|
||||||
|
- Update dees-catalog and @types/node dependencies
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.5.2 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.5.1 - fix(ops-view-network)
|
||||||
|
centralize traffic chart timing constants for consistent rolling window updates
|
||||||
|
|
||||||
|
- Defines shared constants for the chart window, update interval, and maximum buffered data points
|
||||||
|
- Replaces hardcoded traffic history sizes and timer intervals with derived values across initialization, history loading, and live updates
|
||||||
|
- Keeps the chart rolling window configuration aligned with the in-memory traffic buffer
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.5.0 - feat(ops-view-routes)
|
||||||
|
add priority support and list-based domain editing for routes
|
||||||
|
|
||||||
|
- Adds a priority field to route create and edit forms so route matching order can be configured.
|
||||||
|
- Replaces comma-separated domain text input with a list-based domain editor and updates form handling to persist domains as arrays.
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.4.0 - feat(routes)
|
||||||
|
add route edit and delete actions to the ops routes view
|
||||||
|
|
||||||
|
- introduces an update route action in web app state and refreshes merged routes after changes
|
||||||
|
- adds edit and delete handlers with modal-based confirmation and route form inputs for programmatic routes
|
||||||
|
- enables realtime chart window configuration in network and overview dashboards
|
||||||
|
- bumps @serve.zone/catalog to ^2.11.0
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.3.0 - feat(docs,ops-dashboard)
|
||||||
|
document unified database and reusable security profile and network target management
|
||||||
|
|
||||||
|
- Update project and interface documentation to replace separate storage/cache configuration with a unified database model
|
||||||
|
- Document new security profile and network target APIs, data models, and dashboard capabilities
|
||||||
|
- Add a global dashboard warning when the database is disabled so unavailable management features are clearly indicated
|
||||||
|
- Bump @design.estate/dees-catalog and @serve.zone/catalog to support the updated dashboard experience
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.6 - fix(ops-ui)
|
||||||
|
improve operations table actions and modal form handling for profiles and network targets
|
||||||
|
|
||||||
|
- adds section headings for the Security Profiles and Network Targets views
|
||||||
|
- updates edit and delete actions to support in-row table actions in addition to context menus
|
||||||
|
- makes create and edit dialogs query forms safely from modal content and adds early returns when forms are unavailable
|
||||||
|
- enables the database configuration in the development watch server
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.5 - fix(dcrouter)
|
||||||
|
sync allowed tunnel edges when merged routes change
|
||||||
|
|
||||||
|
- Triggers tunnelManager.syncAllowedEdges() after route updates are applied
|
||||||
|
- Keeps derived ports in the Rust hub binary aligned with merged route changes
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.4 - fix(routes)
|
||||||
|
support profile and target metadata in route creation and refresh remote ingress routes after config initialization
|
||||||
|
|
||||||
|
- Re-applies routes to the remote ingress manager after config managers finish to avoid missing DB-backed routes during initialization
|
||||||
|
- Fetches profiles and targets when opening or authenticating into the routes view so route creation dropdowns are populated
|
||||||
|
- Includes selected security profile and network target metadata when creating programmatic routes and displays that metadata in route details
|
||||||
|
- Improves security profile forms by switching IP allow/block lists to list inputs instead of comma-separated text fields
|
||||||
|
- Updates UI dependencies including smartdb, dees-catalog, and serve.zone catalog
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.3 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.2 - fix(route-config)
|
||||||
|
sync applied routes to remote ingress manager after route updates
|
||||||
|
|
||||||
|
- add an optional route-applied callback to RouteConfigManager
|
||||||
|
- forward merged SmartProxy routes to RemoteIngressManager whenever routes are updated
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.1 - fix(web-ui)
|
||||||
|
align dees-table props and action handlers in security profile and network target views
|
||||||
|
|
||||||
|
- replace deprecated table heading prop with heading1 and heading2 in both admin views
|
||||||
|
- rename table action callbacks from action to actionFunc for create, refresh, edit, and delete actions
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.0 - feat(config)
|
||||||
|
add reusable security profiles and network targets with route reference resolution
|
||||||
|
|
||||||
|
- introduces persisted security profile and network target models plus typed OpsServer CRUD and usage endpoints
|
||||||
|
- adds route metadata support so routes can reference profiles and targets and be re-resolved after updates
|
||||||
|
- supports optional seeding of default profiles and targets when the database is empty
|
||||||
|
- adds dashboard views and state management for managing security profiles and network targets
|
||||||
|
- includes tests for reference resolver behavior and API fallback/auth handling
|
||||||
|
|
||||||
|
## 2026-04-01 - 12.1.0 - feat(vpn)
|
||||||
|
add per-client routing controls and bridge forwarding support for VPN clients
|
||||||
|
|
||||||
|
- adds persisted per-client VPN settings for SmartProxy enforcement, destination allow/block lists, host IP assignment, DHCP/static IP selection, and VLAN options
|
||||||
|
- passes new VPN routing and bridge configuration through request handlers, app state, and the ops UI for creating, editing, and viewing clients
|
||||||
|
- supports bridge and hybrid forwarding modes in the VPN manager, including auto-upgrading to hybrid when clients request host IP access
|
||||||
|
- updates smartvpn and dees-catalog dependencies to support the new VPN forwarding capabilities
|
||||||
|
|
||||||
|
## 2026-03-31 - 12.0.0 - BREAKING CHANGE(db)
|
||||||
|
replace StorageManager and CacheDb with a unified smartdata-backed database layer
|
||||||
|
|
||||||
|
- introduces DcRouterDb with embedded LocalSmartDb or external MongoDB support via dbConfig
|
||||||
|
- migrates persisted routes, API tokens, VPN data, certificates, remote ingress, VLAN mappings, RADIUS accounting, and cache records to smartdata document classes
|
||||||
|
- removes StorageManager and CacheDb modules and renames configuration from cacheConfig to dbConfig
|
||||||
|
- updates certificate, security, remote ingress, VPN, and RADIUS components to read and write through document models
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.5 - fix(config)
|
||||||
|
correct VPN mandatory flag default handling in route config manager
|
||||||
|
|
||||||
|
- Changes the VPN mandatory check so it only applies when explicitly set to true, matching the updated default behavior of false.
|
||||||
|
- Prevents routes from being treated as VPN-mandatory when the setting is omitted.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.4 - fix(deps)
|
||||||
|
bump @push.rocks/smartvpn to 1.17.1
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartvpn dependency from 1.16.5 to 1.17.1.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.3 - fix(ts_web)
|
||||||
|
update appstate to import interfaces from source TypeScript module path
|
||||||
|
|
||||||
|
- Replaces the appstate interfaces import from ../dist_ts_interfaces/index.js with ../ts_interfaces/index.js.
|
||||||
|
- Aligns the web app state module with the source interface location instead of the built distribution path.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.2 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.23.0 - feat(vpn)
|
||||||
|
support optional non-mandatory VPN route access and align route config with enabled semantics
|
||||||
|
|
||||||
|
- rename route VPN configuration from `required` to `enabled` across code, docs, and examples
|
||||||
|
- add `vpn.mandatory` to control whether VPN allowlists replace or extend existing `security.ipAllowList` rules
|
||||||
|
- improve VPN client status matching in the ops view by falling back to assigned IP when client IDs differ
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.22.0 - feat(vpn)
|
||||||
|
add VPN client editing and connected client visibility in ops server
|
||||||
|
|
||||||
|
- Adds API support to list currently connected VPN clients and update client metadata without rotating keys
|
||||||
|
- Updates the web VPN view to show live connection status, client detail telemetry, and separate enable/disable actions
|
||||||
|
- Refreshes documentation for smart split tunnel behavior, QR code setup/export, and storage architecture
|
||||||
|
- Bumps @push.rocks/smartvpn from 1.16.4 to 1.16.5
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.5 - fix(routing)
|
||||||
|
apply VPN route allowlists dynamically after VPN clients load
|
||||||
|
|
||||||
|
- Moves VPN security injection for hardcoded and programmatic routes into RouteConfigManager.applyRoutes() so allowlists are generated from current VPN client state.
|
||||||
|
- Re-applies routes after starting the VPN manager to ensure tag-based ipAllowLists are available once VPN clients are loaded.
|
||||||
|
- Avoids caching constructor routes with stale VPN security baked in while preserving HTTP/3 route augmentation.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.4 - fix(deps)
|
||||||
|
bump @push.rocks/smartvpn to 1.16.4
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartvpn dependency from 1.16.3 to 1.16.4 in package.json.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.3 - fix(deps)
|
||||||
|
bump @push.rocks/smartvpn to 1.16.3
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartvpn dependency from 1.16.2 to 1.16.3.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.2 - fix(deps)
|
||||||
|
bump @push.rocks/smartvpn to 1.16.2
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartvpn dependency from 1.16.1 to 1.16.2 in package.json.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.1 - fix(vpn)
|
||||||
|
resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups
|
||||||
|
|
||||||
|
- Derive WireGuard AllowedIPs from DNS A records of matched vpn.required route domains instead of only configured public proxy IPs.
|
||||||
|
- Cache resolved domain IPs for 5 minutes and fall back to stale results on DNS lookup failures.
|
||||||
|
- Make per-client AllowedIPs generation asynchronous throughout VPN config export and regeneration flows.
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.0 - feat(vpn)
|
||||||
|
add tag-aware WireGuard AllowedIPs for VPN-gated routes
|
||||||
|
|
||||||
|
- compute per-client WireGuard AllowedIPs from server-defined client tags and VPN-required proxy routes
|
||||||
|
- include the server public IP in AllowedIPs when a client can access VPN-gated domains so routed traffic reaches the proxy
|
||||||
|
- preserve and inject WireGuard private keys in generated and exported client configs for valid exports
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.20.1 - fix(vpn-manager)
|
||||||
|
persist WireGuard private keys for valid client exports and QR codes
|
||||||
|
|
||||||
|
- Store each client's WireGuard private key when creating and rotating keys.
|
||||||
|
- Inject the stored private key into exported WireGuard configs so generated configs are complete and scannable.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.20.0 - feat(vpn-ui)
|
||||||
|
add QR code export for WireGuard client configurations
|
||||||
|
|
||||||
|
- adds a QR code action for newly created WireGuard configs in the VPN operations view
|
||||||
|
- adds a QR code export option for existing VPN clients alongside file downloads
|
||||||
|
- introduces qrcode and @types/qrcode dependencies and exposes the plugin for web UI use
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.19.1 - fix(vpn)
|
||||||
|
configure SmartVPN client exports with explicit server endpoint and split-tunnel allowed IPs
|
||||||
|
|
||||||
|
- Pass the configured WireGuard server endpoint directly to SmartVPN instead of rewriting generated client configs in dcrouter.
|
||||||
|
- Set client allowed IPs to the VPN subnet so generated WireGuard configs default to split-tunnel routing.
|
||||||
|
- Update documentation to reflect SmartVPN startup, dashboard/API coverage, and the new split-tunnel behavior.
|
||||||
|
- Bump @push.rocks/smartvpn from 1.14.0 to 1.16.1 to support the updated VPN configuration flow.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.19.0 - feat(vpn)
|
||||||
|
document tag-based VPN access control, declarative clients, and destination policy options
|
||||||
|
|
||||||
|
- Adds documentation for restricting VPN-protected routes with allowedServerDefinedClientTags.
|
||||||
|
- Documents pre-defined VPN clients in configuration via vpnConfig.clients.
|
||||||
|
- Describes destinationPolicy behavior for forceTarget, allow, and block traffic handling.
|
||||||
|
- Updates interface docs to reflect serverDefinedClientTags and revised VPN server status fields.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.18.0 - feat(vpn-ui)
|
||||||
|
add format selection for VPN client config exports
|
||||||
|
|
||||||
|
- Show an export modal that lets operators choose between WireGuard (.conf) and SmartVPN (.json) client configs.
|
||||||
|
- Update VPN client row actions to read the selected item from actionData for toggle, export, rotate keys, and delete handlers.
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.17.0 - feat(vpn)
|
||||||
|
expand VPN operations view with client management and config export actions
|
||||||
|
|
||||||
|
- adds predefined VPN clients to the dev server configuration for local testing
|
||||||
|
- adds table actions to create clients, export WireGuard configs, rotate client keys, toggle access, and delete clients
|
||||||
|
- updates the VPN view layout and stats grid binding to match the current component API
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.16.0 - feat(vpn)
|
||||||
|
add destination-based VPN routing policy and standardize socket proxy forwarding
|
||||||
|
|
||||||
|
- replace configurable VPN forwarding mode with socket-based forwarding and always enable proxy protocol support to SmartProxy from localhost
|
||||||
|
- add destinationPolicy configuration for controlling default VPN traffic handling, including forceTarget, allow, and block rules
|
||||||
|
- remove forwarding mode reporting from VPN status APIs, logs, and ops UI to reflect the simplified VPN runtime model
|
||||||
|
- update @push.rocks/smartvpn to 1.14.0 to support the new VPN routing behavior
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.15.0 - feat(vpn)
|
||||||
|
add tag-based VPN route access control and support configured initial VPN clients
|
||||||
|
|
||||||
|
- allow VPN-protected routes to restrict access to clients with matching server-defined tags instead of always permitting the full VPN subnet
|
||||||
|
- create configured VPN clients automatically on startup and re-apply routes when VPN clients change
|
||||||
|
- rename VPN client tag fields to serverDefinedClientTags across APIs, interfaces, handlers, and UI with legacy tag migration on load
|
||||||
|
- upgrade @push.rocks/smartvpn from 1.12.0 to 1.13.0
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.14.0 - feat(docs)
|
||||||
|
document VPN access control and add OpsServer VPN navigation
|
||||||
|
|
||||||
|
- Adds comprehensive README documentation for VPN access control, configuration, operating modes, and client management
|
||||||
|
- Updates TypeScript interface documentation with VPN-related route, client, status, telemetry, and API request types
|
||||||
|
- Extends web dashboard documentation and router view list to include VPN management
|
||||||
|
|
||||||
|
## 2026-03-30 - 11.13.0 - feat(vpn)
|
||||||
|
add VPN server management and route-based VPN access control
|
||||||
|
|
||||||
|
- introduces a VPN manager backed by @push.rocks/smartvpn with persisted server keys and client registrations
|
||||||
|
- adds ops API handlers and typed request interfaces for VPN client lifecycle, status, config export, and telemetry
|
||||||
|
- adds ops dashboard VPN view and application state for managing VPN clients from the web UI
|
||||||
|
- supports vpn.required on routes by injecting VPN subnet allowlists into static and programmatic SmartProxy routes
|
||||||
|
- configures SmartProxy to accept proxy protocol in VPN socket forwarding mode to preserve client tunnel IPs
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.4 - fix(acme)
|
||||||
|
use X509 certificate expiry when reporting ACME certificate validity
|
||||||
|
|
||||||
|
- Parse the actual X509 validTo value from the PEM public certificate and fall back to SmartAcme's stored expiry if parsing fails
|
||||||
|
- Update reported certificate expiry data and event communication timestamps to use the verified validity date
|
||||||
|
- Bump @push.rocks/smartacme to ^9.3.1 and @push.rocks/smartproxy to ^27.1.0
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.3 - fix(dcrouter)
|
||||||
|
re-trigger auto certificate provisioning after SmartAcme becomes ready
|
||||||
|
|
||||||
|
- clear certificate provisioning scheduler state before retrying startup-affected routes
|
||||||
|
- use route updates to re-run certificate provisioning for all current auto-cert routes
|
||||||
|
- remove the unused single-route domain lookup helper
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.2 - fix(dcrouter)
|
||||||
|
guard auto certificate reprovisioning against unnamed routes
|
||||||
|
|
||||||
|
- Only re-triggers certificate provisioning for auto-cert routes when a route name is present.
|
||||||
|
- Prevents reprovision attempts from running with an undefined route name and reduces warning noise.
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.1 - fix(dcrouter)
|
||||||
|
retry auto certificate provisioning after SmartAcme becomes ready
|
||||||
|
|
||||||
|
- detects certificates that failed during startup before the DNS-01 provider was available
|
||||||
|
- clears provisioning backoff and failed status for affected domains before retrying
|
||||||
|
- re-triggers auto certificate provisioning for SmartProxy routes once SmartAcme is ready
|
||||||
|
|
||||||
|
## 2026-03-27 - 11.12.0 - feat(web-ui)
|
||||||
|
pause dashboard polling, sockets, and chart updates when the tab is hidden
|
||||||
|
|
||||||
|
- replace interval-based auto-refresh with scheduled actions using visibility-aware auto-pause
|
||||||
|
- disconnect and reconnect the TypedSocket on tab visibility changes to avoid background log buildup
|
||||||
|
- batch pushed log entries per animation frame and add an in-flight refresh guard to reduce unnecessary re-renders and overlapping requests
|
||||||
|
- update state subscriptions to use select() and document the new tab visibility optimization behavior
|
||||||
|
- bump smartdb, smartproxy, smartstate, remoteingress, dees-element, and tstest dependencies
|
||||||
|
|
||||||
|
## 2026-03-26 - 11.11.0 - feat(docker,cache,proxy)
|
||||||
|
improve container runtime defaults and add configurable connection limits
|
||||||
|
|
||||||
|
- replace the embedded cache backend integration from smartmongo LocalTsmDb to smartdb LocalSmartDb
|
||||||
|
- add OCI container settings for heap size, threadpool size, expanded exposed ports, image metadata, and a direct node startup command
|
||||||
|
- introduce startup checks for file descriptor limits and warn when container nofile limits are too low for production
|
||||||
|
- set gateway-oriented SmartProxy default limits and allow max connections, per-IP connections, and rate limits to be configured through OCI environment variables
|
||||||
|
|
||||||
|
## 2026-03-26 - 11.10.7 - fix(sms)
|
||||||
|
update sms service to use async ProjectInfo initialization
|
||||||
|
|
||||||
|
- Replace direct ProjectInfo construction with the async create() factory in the SMS service startup flow
|
||||||
|
- Bump related dependencies including @push.rocks/projectinfo, @push.rocks/smartdata, @push.rocks/smartmongo, @serve.zone/remoteingress, and @git.zone/tstest
|
||||||
|
|
||||||
|
## 2026-03-26 - 11.10.6 - fix(typescript)
|
||||||
|
tighten TypeScript null safety and error handling across backend and ops UI
|
||||||
|
|
||||||
|
- add explicit unknown error typing and safe message access in logging and handler code
|
||||||
|
- mark deferred-initialized class properties with definite assignment assertions to satisfy stricter TypeScript checks
|
||||||
|
- harden ops web state access and action return types with non-null assertions and explicit Promise state typing
|
||||||
|
- update storage reads to allow missing values and align license file references with the lowercase license filename
|
||||||
|
|
||||||
|
## 2026-03-26 - 11.10.5 - fix(build)
|
||||||
|
rename smart tooling config to .smartconfig.json and update package references
|
||||||
|
|
||||||
|
- Moves the shared tool configuration from npmextra.json to .smartconfig.json.
|
||||||
|
- Updates package.json published files and documentation to reference the new config file.
|
||||||
|
- Refreshes several development and runtime dependency versions alongside the config migration.
|
||||||
|
|
||||||
|
## 2026-03-24 - 11.10.4 - fix(monitoring)
|
||||||
|
handle multiple protocol cache entries per backend in metrics output
|
||||||
|
|
||||||
|
- Group detected protocol cache entries by backend host and port so multiple domain-specific records are preserved.
|
||||||
|
- Emit one backend metrics row per cached domain and avoid dropping unmatched protocol cache entries by tracking seen entries with a composite host:port:domain key.
|
||||||
|
- Use cached protocol values when available while keeping backend-only rows for metrics without protocol cache data.
|
||||||
|
|
||||||
|
## 2026-03-23 - 11.10.3 - fix(deps)
|
||||||
|
bump tstest, smartmetrics, and taskbuffer to latest patch releases
|
||||||
|
|
||||||
|
- update @git.zone/tstest from ^3.5.0 to ^3.5.1
|
||||||
|
- update @push.rocks/smartmetrics from ^3.0.2 to ^3.0.3
|
||||||
|
- update @push.rocks/taskbuffer from ^8.0.0 to ^8.0.2
|
||||||
|
|
||||||
|
## 2026-03-23 - 11.10.2 - fix(deps)
|
||||||
|
bump @api.global/typedserver to ^8.4.6 and @push.rocks/smartproxy to ^26.2.1
|
||||||
|
|
||||||
|
- Updates @api.global/typedserver from ^8.4.2 to ^8.4.6
|
||||||
|
- Updates @push.rocks/smartproxy from ^26.2.0 to ^26.2.1
|
||||||
|
|
||||||
|
## 2026-03-23 - 11.10.1 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^26.2.0
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartproxy dependency from ^26.1.0 to ^26.2.0 in package.json.
|
||||||
|
|
||||||
|
## 2026-03-23 - 11.10.0 - feat(monitoring)
|
||||||
|
add backend protocol metrics to network stats and ops dashboard
|
||||||
|
|
||||||
|
- Expose backend protocol, connection, error, and suppression metrics in stats responses.
|
||||||
|
- Add typed backend info interfaces and app state support for backend metrics.
|
||||||
|
- Render a new backend protocols table in the ops network view with detail modal and suppression badges.
|
||||||
|
- Update smartproxy and lik dependencies to support backend protocol metrics collection.
|
||||||
|
|
||||||
|
## 2026-03-21 - 11.9.1 - fix(lifecycle)
|
||||||
|
clean up service subscriptions, proxy retries, and stale runtime state on shutdown
|
||||||
|
|
||||||
|
- unsubscribe from ServiceManager event streams and use one-time signal handlers to avoid duplicate shutdown execution
|
||||||
|
- reset existing SmartProxy instances before retry setup and prune expired certificate backoff cache entries
|
||||||
|
- add periodic sweeping and shutdown cleanup for stale RADIUS accounting sessions
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.9.0 - feat(dcrouter)
|
||||||
|
add service manager lifecycle orchestration and health-based ops status reporting
|
||||||
|
|
||||||
|
- register dcrouter components with a taskbuffer ServiceManager using dependencies, retries, and critical/optional service roles
|
||||||
|
- update ops stats health output to reflect aggregated service manager state and per-service error or retry details
|
||||||
|
- add @push.rocks/taskbuffer to shared plugins and project dependencies for service lifecycle management
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.11 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.17.10
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartproxy dependency from ^25.17.9 to ^25.17.10 in package.json
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.10 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.17.9
|
||||||
|
|
||||||
|
- Updates @push.rocks/smartproxy from ^25.17.8 to ^25.17.9 in package.json
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.9 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.17.8
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartproxy dependency from ^25.17.7 to ^25.17.8.
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.8 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.17.7
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartproxy dependency from ^25.17.4 to ^25.17.7 in package.json.
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.7 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.17.4
|
||||||
|
|
||||||
|
- updates @push.rocks/smartproxy from ^25.17.3 to ^25.17.4 in package.json
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.6 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.17.3
|
||||||
|
|
||||||
|
- updates @push.rocks/smartproxy from ^25.17.1 to ^25.17.3 in package.json
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.5 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.17.1
|
||||||
|
|
||||||
|
- Updates the @push.rocks/smartproxy dependency from ^25.17.0 to ^25.17.1.
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.4 - fix(deps)
|
||||||
|
bump @serve.zone/remoteingress to ^4.14.0
|
||||||
|
|
||||||
|
- Updates the @serve.zone/remoteingress dependency from ^4.13.2 to ^4.14.0 in package.json.
|
||||||
|
|
||||||
|
## 2026-03-20 - 11.8.3 - fix(deps)
|
||||||
|
bump @serve.zone/remoteingress to ^4.13.2
|
||||||
|
|
||||||
|
- Updates the @serve.zone/remoteingress dependency from ^4.13.1 to ^4.13.2.
|
||||||
|
|
||||||
|
## 2026-03-19 - 11.8.2 - fix(deps)
|
||||||
|
bump smartproxy and remoteingress dependencies
|
||||||
|
|
||||||
|
- updates @push.rocks/smartproxy from ^25.16.3 to ^25.17.0
|
||||||
|
- updates @serve.zone/remoteingress from ^4.13.0 to ^4.13.1
|
||||||
|
|
||||||
|
## 2026-03-19 - 11.8.1 - fix(dcrouter)
|
||||||
|
use constructor routes for remote ingress setup and bump smartproxy dependency
|
||||||
|
|
||||||
|
- Switch remote ingress initialization to use constructorRoutes instead of smartProxyConfig routes so derived edge ports are based on the active route set.
|
||||||
|
- Update @push.rocks/smartproxy from ^25.16.2 to ^25.16.3.
|
||||||
|
|
||||||
## 2026-03-19 - 11.8.0 - feat(remoteingress)
|
## 2026-03-19 - 11.8.0 - feat(remoteingress)
|
||||||
add UDP listen port derivation and edge configuration support
|
add UDP listen port derivation and edge configuration support
|
||||||
|
|
||||||
|
|||||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
59
package.json
59
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "11.8.0",
|
"version": "13.8.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --logfile --timeout 60)",
|
"test": "(tstest test/ --logfile --timeout 60)",
|
||||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
"start": "(node ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
"build:docker": "tsdocker build --verbose",
|
"build:docker": "tsdocker build --verbose",
|
||||||
@@ -22,48 +22,53 @@
|
|||||||
"watch": "tswatch"
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.3.0",
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
"@git.zone/tsbundle": "^2.9.1",
|
"@git.zone/tsbundle": "^2.10.0",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^3.5.0",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@git.zone/tswatch": "^3.3.0",
|
"@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",
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedserver": "^8.4.2",
|
"@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.48.5",
|
"@design.estate/dees-catalog": "^3.69.1",
|
||||||
"@design.estate/dees-element": "^2.2.3",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/lik": "^6.3.1",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
"@push.rocks/projectinfo": "^5.0.2",
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartacme": "^9.3.0",
|
"@push.rocks/smartacme": "^9.5.0",
|
||||||
"@push.rocks/smartdata": "^7.1.0",
|
"@push.rocks/smartdata": "^7.1.7",
|
||||||
|
"@push.rocks/smartdb": "^2.6.2",
|
||||||
"@push.rocks/smartdns": "^7.9.0",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
"@push.rocks/smartfile": "^13.1.2",
|
"@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.2",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
"@push.rocks/smartmongo": "^5.1.0",
|
"@push.rocks/smartmigration": "1.1.1",
|
||||||
"@push.rocks/smartmta": "^5.3.1",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@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": "^25.16.2",
|
"@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.2.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@serve.zone/catalog": "^2.9.0",
|
"@push.rocks/smartvpn": "1.19.2",
|
||||||
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
|
"@serve.zone/catalog": "^2.12.3",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^4.13.0",
|
"@serve.zone/remoteingress": "^4.15.3",
|
||||||
"@tsclass/tsclass": "^9.4.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": [
|
||||||
@@ -111,7 +116,7 @@
|
|||||||
"dist_ts_apiclient/**/*",
|
"dist_ts_apiclient/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
3775
pnpm-lock.yaml
generated
3775
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -133,7 +133,7 @@ The project now uses tswatch for development:
|
|||||||
```bash
|
```bash
|
||||||
pnpm run watch
|
pnpm run watch
|
||||||
```
|
```
|
||||||
Configuration in `npmextra.json`:
|
Configuration in `.smartconfig.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@git.zone/tswatch": {
|
"@git.zone/tswatch": {
|
||||||
|
|||||||
393
readme.md
393
readme.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
||||||
|
|
||||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
|
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -23,8 +23,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- [DNS Server](#dns-server)
|
- [DNS Server](#dns-server)
|
||||||
- [RADIUS Server](#radius-server)
|
- [RADIUS Server](#radius-server)
|
||||||
- [Remote Ingress](#remote-ingress)
|
- [Remote Ingress](#remote-ingress)
|
||||||
|
- [VPN Access Control](#vpn-access-control)
|
||||||
- [Certificate Management](#certificate-management)
|
- [Certificate Management](#certificate-management)
|
||||||
- [Storage & Caching](#storage--caching)
|
- [Storage & Database](#storage--database)
|
||||||
- [Security Features](#security-features)
|
- [Security Features](#security-features)
|
||||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||||
- [API Client](#api-client)
|
- [API Client](#api-client)
|
||||||
@@ -73,6 +74,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
||||||
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
||||||
|
|
||||||
|
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
|
||||||
|
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
|
||||||
|
- **Route-level VPN gating** — mark any route with `vpn: { enabled: true }` to restrict access to VPN clients only, or `vpn: { enabled: true, mandatory: false }` to add VPN clients alongside existing access rules
|
||||||
|
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
|
||||||
|
- **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup
|
||||||
|
- **Rootless operation** — uses userspace NAT (smoltcp) with no root required
|
||||||
|
- **Destination policy** — configurable `forceTarget`, `block`, or `allow` with allowList/blockList for granular traffic control
|
||||||
|
- **Client management** — create, enable, disable, rotate keys, export WireGuard/SmartVPN configs via OpsServer API and dashboard
|
||||||
|
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
|
||||||
|
- **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
||||||
|
|
||||||
### ⚡ High Performance
|
### ⚡ High Performance
|
||||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||||
@@ -81,18 +93,22 @@ 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 + LocalTsmDb (MongoDB-compatible)
|
- **15 document classes** covering routes, certs, VPN, RADIUS, security profiles, network targets, and caches
|
||||||
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
||||||
|
- **Reusable references** — security profiles and network targets that propagate changes to all referencing routes
|
||||||
|
|
||||||
### 🖥️ OpsServer Dashboard
|
### 🖥️ OpsServer Dashboard
|
||||||
- **Web-based management interface** with real-time monitoring
|
- **Web-based management interface** with real-time monitoring
|
||||||
- **JWT authentication** with session persistence
|
- **JWT authentication** with session persistence
|
||||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
|
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
||||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||||
- **Remote ingress management** with connection token generation and one-click copy
|
- **Remote ingress management** with connection token generation and one-click copy
|
||||||
- **Read-only configuration display** — DcRouter is configured through code
|
- **Security profiles & network targets** — reusable security configurations and host:port targets with propagation to referencing routes
|
||||||
|
- **Global warning banners** when database is disabled (management features unavailable)
|
||||||
|
- **Read-only configuration display** for system overview
|
||||||
|
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
|
||||||
|
|
||||||
### 🔧 Programmatic API Client
|
### 🔧 Programmatic API Client
|
||||||
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
|
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
|
||||||
@@ -247,11 +263,17 @@ const router = new DcRouter({
|
|||||||
hubDomain: 'hub.example.com',
|
hubDomain: 'hub.example.com',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Persistent storage
|
// VPN — restrict sensitive routes to VPN clients
|
||||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.example.com',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// Cache database
|
// Unified database (embedded LocalSmartDb or external MongoDB)
|
||||||
cacheConfig: { enabled: true, storagePath: '~/.serve.zone/dcrouter/tsmdb' },
|
dbConfig: { enabled: true },
|
||||||
|
|
||||||
// TLS & ACME
|
// TLS & ACME
|
||||||
tls: { contactEmail: 'admin@example.com' },
|
tls: { contactEmail: 'admin@example.com' },
|
||||||
@@ -275,6 +297,7 @@ graph TB
|
|||||||
DNS[DNS Queries]
|
DNS[DNS Queries]
|
||||||
RAD[RADIUS Clients]
|
RAD[RADIUS Clients]
|
||||||
EDGE[Edge Nodes]
|
EDGE[Edge Nodes]
|
||||||
|
VPN[VPN Clients]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "DcRouter Core"
|
subgraph "DcRouter Core"
|
||||||
@@ -284,11 +307,11 @@ graph TB
|
|||||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||||
RS[SmartRadius Server]
|
RS[SmartRadius Server]
|
||||||
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
||||||
|
VS[SmartVPN Server<br/><i>Rust data plane</i>]
|
||||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||||
OS[OpsServer Dashboard]
|
OS[OpsServer Dashboard]
|
||||||
MM[Metrics Manager]
|
MM[Metrics Manager]
|
||||||
SM[Storage Manager]
|
DB2[DcRouterDb<br/><i>smartdata + smartdb</i>]
|
||||||
CD[Cache Database]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Backend Services"
|
subgraph "Backend Services"
|
||||||
@@ -304,17 +327,18 @@ graph TB
|
|||||||
DNS --> DS
|
DNS --> DS
|
||||||
RAD --> RS
|
RAD --> RS
|
||||||
EDGE --> RI
|
EDGE --> RI
|
||||||
|
VPN --> VS
|
||||||
|
|
||||||
DC --> SP
|
DC --> SP
|
||||||
DC --> ES
|
DC --> ES
|
||||||
DC --> DS
|
DC --> DS
|
||||||
DC --> RS
|
DC --> RS
|
||||||
DC --> RI
|
DC --> RI
|
||||||
|
DC --> VS
|
||||||
DC --> CM
|
DC --> CM
|
||||||
DC --> OS
|
DC --> OS
|
||||||
DC --> MM
|
DC --> MM
|
||||||
DC --> SM
|
DC --> DB2
|
||||||
DC --> CD
|
|
||||||
|
|
||||||
SP --> WEB
|
SP --> WEB
|
||||||
SP --> API
|
SP --> API
|
||||||
@@ -339,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/smartdata` | Embedded MongoDB-compatible database (LocalTsmDb) 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
|
||||||
@@ -360,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
|
||||||
@@ -427,6 +451,27 @@ interface IDcRouterOptions {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── VPN ───────────────────────────────────────────────────────
|
||||||
|
/** VPN server for route-level access control */
|
||||||
|
vpnConfig?: {
|
||||||
|
enabled?: boolean; // default: false
|
||||||
|
subnet?: string; // default: '10.8.0.0/24'
|
||||||
|
wgListenPort?: number; // default: 51820
|
||||||
|
dns?: string[]; // DNS servers pushed to VPN clients
|
||||||
|
serverEndpoint?: string; // Hostname in generated client configs
|
||||||
|
clients?: Array<{ // Pre-defined VPN clients
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
destinationPolicy?: { // Traffic routing policy
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
target?: string; // IP for forceTarget (default: '127.0.0.1')
|
||||||
|
allowList?: string[]; // Pass through directly
|
||||||
|
blockList?: string[]; // Always block (overrides allowList)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
||||||
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
||||||
http3?: {
|
http3?: {
|
||||||
@@ -461,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
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -974,6 +1011,129 @@ The OpsServer Remote Ingress view provides:
|
|||||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||||
| **Delete** | Remove the edge registration |
|
| **Delete** | Remove the edge registration |
|
||||||
|
|
||||||
|
## VPN Access Control
|
||||||
|
|
||||||
|
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
|
||||||
|
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
|
||||||
|
3. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.enabled` are resolved at config generation time, so clients route only the necessary traffic through the tunnel
|
||||||
|
4. Routes with `vpn: { enabled: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change). With `mandatory: true` (default), the allowlist is replaced; with `mandatory: false`, VPN IPs are appended to existing rules
|
||||||
|
5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
|
||||||
|
6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
|
||||||
|
7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
|
||||||
|
|
||||||
|
### Destination Policy
|
||||||
|
|
||||||
|
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Default: all traffic → SmartProxy
|
||||||
|
destinationPolicy: { default: 'forceTarget', target: '127.0.0.1' }
|
||||||
|
|
||||||
|
// Allow direct access to a backend subnet
|
||||||
|
destinationPolicy: {
|
||||||
|
default: 'forceTarget',
|
||||||
|
target: '127.0.0.1',
|
||||||
|
allowList: ['192.168.190.*'], // direct access to this subnet
|
||||||
|
blockList: ['192.168.190.1'], // except the gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block everything except specific IPs
|
||||||
|
destinationPolicy: {
|
||||||
|
default: 'block',
|
||||||
|
allowList: ['10.0.0.*', '192.168.1.*'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const router = new DcRouter({
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
||||||
|
wgListenPort: 51820, // WireGuard UDP port (default)
|
||||||
|
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
|
||||||
|
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
|
||||||
|
|
||||||
|
// Pre-define VPN clients with server-defined tags
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'alice-laptop', serverDefinedClientTags: ['engineering'], description: 'Dev laptop' },
|
||||||
|
{ clientId: 'bob-phone', serverDefinedClientTags: ['engineering', 'mobile'] },
|
||||||
|
{ clientId: 'carol-desktop', serverDefinedClientTags: ['finance'] },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Optional: customize destination policy (default: forceTarget → localhost)
|
||||||
|
// destinationPolicy: { default: 'forceTarget', target: '127.0.0.1', allowList: ['192.168.1.*'] },
|
||||||
|
},
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: [
|
||||||
|
// 🔐 VPN-only: any VPN client can access
|
||||||
|
{
|
||||||
|
name: 'internal-app',
|
||||||
|
match: { domains: ['internal.example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.50', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
vpn: { enabled: true },
|
||||||
|
},
|
||||||
|
// 🔐 VPN + tag-restricted: only 'engineering' tagged clients
|
||||||
|
{
|
||||||
|
name: 'eng-dashboard',
|
||||||
|
match: { domains: ['eng.example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.51', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||||
|
// → alice + bob can access, carol cannot
|
||||||
|
},
|
||||||
|
// 🌐 Public: no VPN
|
||||||
|
{
|
||||||
|
name: 'public-site',
|
||||||
|
match: { domains: ['example.com'], ports: [443] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '192.168.1.10', port: 80 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Tags
|
||||||
|
|
||||||
|
SmartVPN distinguishes between two types of client tags:
|
||||||
|
|
||||||
|
| Tag Type | Set By | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `serverDefinedClientTags` | Admin (via config or API) | **Trusted** — used for route access control |
|
||||||
|
| `clientDefinedClientTags` | Connecting client | **Informational** — displayed in dashboard, never used for security |
|
||||||
|
|
||||||
|
Routes with `allowedServerDefinedClientTags` only permit VPN clients whose admin-assigned tags match. Clients cannot influence their own server-defined tags.
|
||||||
|
|
||||||
|
### Client Management via OpsServer
|
||||||
|
|
||||||
|
The OpsServer dashboard and API provide full VPN client lifecycle management:
|
||||||
|
|
||||||
|
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
|
||||||
|
- **QR code** — scan with the WireGuard mobile app (iOS/Android) for instant setup
|
||||||
|
- **Enable / Disable** — toggle client access without deleting
|
||||||
|
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
||||||
|
- **Export config** — download in WireGuard (`.conf`), SmartVPN (`.json`), or scan as QR code
|
||||||
|
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
|
||||||
|
- **Delete** — remove a client and revoke access
|
||||||
|
|
||||||
|
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or by scanning the QR code — no custom VPN software needed.
|
||||||
|
|
||||||
## Certificate Management
|
## Certificate Management
|
||||||
|
|
||||||
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
||||||
@@ -1042,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 + LocalTsmDb) 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
|
||||||
|
|
||||||
@@ -1148,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 |
|
||||||
@@ -1214,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
|
||||||
@@ -1224,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
|
||||||
@@ -1331,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
|
||||||
|
|
||||||
@@ -1402,41 +1602,64 @@ 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
|
||||||
|
|
||||||
DcRouter ships with a `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
|
DcRouter ships with a production-ready `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. The container image includes tini as PID 1 (via the base image), proper health checks, and configurable resource limits. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
|
||||||
|
|
||||||
### Running with Docker
|
### Running with Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-e DCROUTER_MODE=OCI_CONTAINER \
|
--ulimit nofile=65536:65536 \
|
||||||
-e DCROUTER_TLS_EMAIL=admin@example.com \
|
-e DCROUTER_TLS_EMAIL=admin@example.com \
|
||||||
-e DCROUTER_PUBLIC_IP=203.0.113.1 \
|
-e DCROUTER_PUBLIC_IP=203.0.113.1 \
|
||||||
-e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \
|
-e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \
|
||||||
-e DCROUTER_DNS_SCOPES=example.com \
|
-e DCROUTER_DNS_SCOPES=example.com \
|
||||||
-p 80:80 -p 443:443 -p 25:25 -p 53:53/udp -p 3000:3000 \
|
-p 80:80 -p 443:443 -p 25:25 -p 587:587 -p 465:465 \
|
||||||
|
-p 53:53/udp -p 3000:3000 -p 8443:8443 \
|
||||||
code.foss.global/serve.zone/dcrouter:latest
|
code.foss.global/serve.zone/dcrouter:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> ⚡ **Production tip:** Always set `--ulimit nofile=65536:65536` for production deployments. DcRouter will log a warning at startup if the file descriptor limit is below 65536.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Example |
|
| Variable | Description | Default | Example |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|---------|
|
||||||
| `DCROUTER_MODE` | Set to `OCI_CONTAINER` to enable container mode | `OCI_CONTAINER` |
|
| `DCROUTER_MODE` | Container mode (set automatically in image) | `OCI_CONTAINER` | — |
|
||||||
| `DCROUTER_CONFIG_PATH` | Path to a JSON config file (loaded as base, env vars override) | `/config/dcrouter.json` |
|
| `DCROUTER_CONFIG_PATH` | Path to JSON config file (env vars override) | — | `/config/dcrouter.json` |
|
||||||
| `DCROUTER_BASE_DIR` | Override base data directory | `/data/dcrouter` |
|
| `DCROUTER_BASE_DIR` | Base data directory | `~/.serve.zone/dcrouter` | `/data/dcrouter` |
|
||||||
| `DCROUTER_TLS_EMAIL` | ACME contact email | `admin@example.com` |
|
| `DCROUTER_TLS_EMAIL` | ACME contact email | — | `admin@example.com` |
|
||||||
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | `example.com` |
|
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | — | `example.com` |
|
||||||
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | `203.0.113.1` |
|
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | — | `203.0.113.1` |
|
||||||
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | `198.51.100.1,198.51.100.2` |
|
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | — | `198.51.100.1,198.51.100.2` |
|
||||||
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | `ns1.example.com,ns2.example.com` |
|
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | — | `ns1.example.com,ns2.example.com` |
|
||||||
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | `example.com,other.com` |
|
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | — | `example.com,other.com` |
|
||||||
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | `mail.example.com` |
|
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | — | `mail.example.com` |
|
||||||
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | `25,587,465` |
|
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | — | `25,587,465` |
|
||||||
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` |
|
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | `false` |
|
||||||
|
| `DCROUTER_HEAP_SIZE` | Node.js V8 heap size in MB | `512` | `1024` |
|
||||||
|
| `DCROUTER_MAX_CONNECTIONS` | Global max concurrent connections | `50000` | `100000` |
|
||||||
|
| `DCROUTER_MAX_CONNECTIONS_PER_IP` | Max connections per source IP | `100` | `200` |
|
||||||
|
| `DCROUTER_CONNECTION_RATE_LIMIT` | Max new connections/min per IP | `600` | `1200` |
|
||||||
|
|
||||||
|
### Exposed Ports
|
||||||
|
|
||||||
|
The container exposes all service ports:
|
||||||
|
|
||||||
|
| Port(s) | Protocol | Service |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| 80, 443 | TCP | HTTP/HTTPS (SmartProxy) |
|
||||||
|
| 25, 587, 465 | TCP | SMTP, Submission, SMTPS |
|
||||||
|
| 53 | TCP/UDP | DNS |
|
||||||
|
| 1812, 1813 | UDP | RADIUS auth/acct |
|
||||||
|
| 3000 | TCP | OpsServer dashboard |
|
||||||
|
| 8443 | TCP | Remote ingress tunnels |
|
||||||
|
| 51820 | UDP | WireGuard VPN |
|
||||||
|
| 29000–30000 | TCP | Dynamic port range |
|
||||||
|
|
||||||
### Building the Image
|
### Building the Image
|
||||||
|
|
||||||
@@ -1449,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 }
|
||||||
|
```
|
||||||
196
test/test.cert-renewal.ts
Normal file
196
test/test.cert-renewal.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { deriveCertDomainName } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// deriveCertDomainName — pure helper that mirrors smartacme's certmatcher.
|
||||||
|
// Used by the force-renew sibling-propagation logic to identify which routes
|
||||||
|
// share a single underlying ACME certificate.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName collapses 3-level subdomain to base', async () => {
|
||||||
|
expect(deriveCertDomainName('outline.task.vc')).toEqual('task.vc');
|
||||||
|
expect(deriveCertDomainName('pr.task.vc')).toEqual('task.vc');
|
||||||
|
expect(deriveCertDomainName('mtd.task.vc')).toEqual('task.vc');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName returns base domain unchanged for 2-level domain', async () => {
|
||||||
|
expect(deriveCertDomainName('task.vc')).toEqual('task.vc');
|
||||||
|
expect(deriveCertDomainName('example.com')).toEqual('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName strips wildcard prefix', async () => {
|
||||||
|
expect(deriveCertDomainName('*.task.vc')).toEqual('task.vc');
|
||||||
|
expect(deriveCertDomainName('*.example.com')).toEqual('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName collapses subdomain and wildcard to same identity', async () => {
|
||||||
|
// This is the core property: outline.task.vc and *.task.vc must yield
|
||||||
|
// the same cert identity, otherwise sibling propagation cannot work.
|
||||||
|
const subdomain = deriveCertDomainName('outline.task.vc');
|
||||||
|
const wildcard = deriveCertDomainName('*.task.vc');
|
||||||
|
expect(subdomain).toEqual(wildcard);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName returns undefined for 4+ level domains', async () => {
|
||||||
|
// Matches smartacme's "deeper domains not supported" behavior.
|
||||||
|
expect(deriveCertDomainName('a.b.task.vc')).toBeUndefined();
|
||||||
|
expect(deriveCertDomainName('one.two.three.example.com')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName returns undefined for malformed inputs', async () => {
|
||||||
|
expect(deriveCertDomainName('vc')).toBeUndefined();
|
||||||
|
expect(deriveCertDomainName('')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CertificateHandler.reprovisionCertificateDomain — verify the includeWildcard
|
||||||
|
// option is forwarded to smartAcme.getCertificateForDomain on force renew.
|
||||||
|
//
|
||||||
|
// This is the regression test for Bug 1: previously the call passed only
|
||||||
|
// `{ forceRenew: true }`, causing the re-issued cert to drop the wildcard SAN
|
||||||
|
// and break every sibling subdomain.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||||
|
|
||||||
|
// Build a minimal stub of OpsServer + DcRouter that satisfies CertificateHandler.
|
||||||
|
// We only need: viewRouter.addTypedHandler / adminRouter.addTypedHandler (no-op),
|
||||||
|
// dcRouterRef.smartProxy.routeManager.getRoutes(), dcRouterRef.smartAcme,
|
||||||
|
// dcRouterRef.findRouteNamesForDomain, dcRouterRef.certificateStatusMap.
|
||||||
|
function makeStubOpsServer(opts: {
|
||||||
|
routes: Array<{ name: string; domains: string[] }>;
|
||||||
|
smartAcmeStub: { getCertificateForDomain: (domain: string, options: any) => Promise<any> };
|
||||||
|
}) {
|
||||||
|
const captured: { typedHandlers: any[] } = { typedHandlers: [] };
|
||||||
|
const router = {
|
||||||
|
addTypedHandler(handler: any) { captured.typedHandlers.push(handler); },
|
||||||
|
};
|
||||||
|
|
||||||
|
const routes = opts.routes.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
match: { domains: r.domains, ports: 443 },
|
||||||
|
action: { type: 'forward', tls: { certificate: 'auto' } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dcRouterRef: any = {
|
||||||
|
smartProxy: {
|
||||||
|
routeManager: { getRoutes: () => routes },
|
||||||
|
},
|
||||||
|
smartAcme: opts.smartAcmeStub,
|
||||||
|
findRouteNamesForDomain: (domain: string) =>
|
||||||
|
routes.filter((r) => r.match.domains.includes(domain)).map((r) => r.name),
|
||||||
|
certificateStatusMap: new Map<string, any>(),
|
||||||
|
certProvisionScheduler: null,
|
||||||
|
routeConfigManager: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const opsServerRef: any = {
|
||||||
|
viewRouter: router,
|
||||||
|
adminRouter: router,
|
||||||
|
dcRouterRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { opsServerRef, dcRouterRef, captured };
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('reprovisionCertificateDomain passes includeWildcard=true for non-wildcard domain', async () => {
|
||||||
|
const calls: Array<{ domain: string; options: any }> = [];
|
||||||
|
|
||||||
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||||
|
routes: [
|
||||||
|
{ name: 'outline-route', domains: ['outline.task.vc'] },
|
||||||
|
{ name: 'pr-route', domains: ['pr.task.vc'] },
|
||||||
|
{ name: 'mtd-route', domains: ['mtd.task.vc'] },
|
||||||
|
],
|
||||||
|
smartAcmeStub: {
|
||||||
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||||||
|
calls.push({ domain, options });
|
||||||
|
// Return a cert object shaped like SmartacmeCert
|
||||||
|
return {
|
||||||
|
id: 'test-id',
|
||||||
|
domainName: 'task.vc',
|
||||||
|
created: Date.now(),
|
||||||
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||||
|
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||||
|
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||||
|
csr: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override updateRoutes/applyRoutes to no-op so the test doesn't try to talk to a real proxy
|
||||||
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||||
|
|
||||||
|
// Construct handler — registerHandlers will run and register typed handlers on our stub router.
|
||||||
|
const handler = new CertificateHandler(opsServerRef);
|
||||||
|
|
||||||
|
// Invoke the private reprovision method directly. The Bug 1 fix is verified
|
||||||
|
// by inspecting the captured smartAcme call options regardless of whether
|
||||||
|
// sibling propagation succeeds (it relies on a real DB for ProxyCertDoc).
|
||||||
|
await (handler as any).reprovisionCertificateDomain('outline.task.vc', true);
|
||||||
|
|
||||||
|
// Sibling propagation may fail because ProxyCertDoc.findByDomain needs a real DB.
|
||||||
|
// The Bug 1 fix is verified by the captured smartAcme call regardless.
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(calls[0].domain).toEqual('outline.task.vc');
|
||||||
|
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reprovisionCertificateDomain passes includeWildcard=false for wildcard domain', async () => {
|
||||||
|
const calls: Array<{ domain: string; options: any }> = [];
|
||||||
|
|
||||||
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||||
|
routes: [
|
||||||
|
{ name: 'wildcard-route', domains: ['*.task.vc'] },
|
||||||
|
],
|
||||||
|
smartAcmeStub: {
|
||||||
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||||||
|
calls.push({ domain, options });
|
||||||
|
return {
|
||||||
|
id: 'test-id',
|
||||||
|
domainName: 'task.vc',
|
||||||
|
created: Date.now(),
|
||||||
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||||
|
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||||
|
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||||
|
csr: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||||
|
|
||||||
|
const handler = new CertificateHandler(opsServerRef);
|
||||||
|
await (handler as any).reprovisionCertificateDomain('*.task.vc', true);
|
||||||
|
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(calls[0].domain).toEqual('*.task.vc');
|
||||||
|
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reprovisionCertificateDomain does not call smartAcme when forceRenew is false', async () => {
|
||||||
|
const calls: Array<{ domain: string; options: any }> = [];
|
||||||
|
|
||||||
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||||
|
routes: [{ name: 'outline-route', domains: ['outline.task.vc'] }],
|
||||||
|
smartAcmeStub: {
|
||||||
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||||||
|
calls.push({ domain, options });
|
||||||
|
return {} as any;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||||
|
|
||||||
|
const handler = new CertificateHandler(opsServerRef);
|
||||||
|
await (handler as any).reprovisionCertificateDomain('outline.task.vc', false);
|
||||||
|
|
||||||
|
// forceRenew=false should NOT call getCertificateForDomain — it just triggers
|
||||||
|
// applyRoutes and lets the cert provisioning pipeline handle it.
|
||||||
|
expect(calls.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -130,7 +130,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
contactEmail: 'test@example.com'
|
contactEmail: 'test@example.com'
|
||||||
},
|
},
|
||||||
opsServerPort: 3104,
|
opsServerPort: 3104,
|
||||||
cacheConfig: {
|
dbConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
|
|||||||
routes: []
|
routes: []
|
||||||
},
|
},
|
||||||
opsServerPort: 3100,
|
opsServerPort: 3100,
|
||||||
cacheConfig: { enabled: false }
|
dbConfig: { enabled: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
await dcRouter.start();
|
await dcRouter.start();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
opsServerPort: 3102,
|
opsServerPort: 3102,
|
||||||
cacheConfig: { enabled: false },
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
opsServerPort: 3101,
|
opsServerPort: 3101,
|
||||||
cacheConfig: { enabled: false },
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
|||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
opsServerPort: 3103,
|
opsServerPort: 3103,
|
||||||
cacheConfig: { enabled: false },
|
dbConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
|
|||||||
371
test/test.reference-resolver.ts
Normal file
371
test/test.reference-resolver.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||||
|
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers: access private maps for direct unit testing without DB
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||||
|
(resolver as any).profiles.set(profile.id, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void {
|
||||||
|
(resolver as any).targets.set(target.id, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProfile(overrides: Partial<ISourceProfile> = {}): ISourceProfile {
|
||||||
|
return {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: 'STANDARD',
|
||||||
|
description: 'Test profile',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8'],
|
||||||
|
maxConnections: 1000,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: 'test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTarget(overrides: Partial<INetworkTarget> = {}): INetworkTarget {
|
||||||
|
return {
|
||||||
|
id: 'target-1',
|
||||||
|
name: 'INFRA',
|
||||||
|
description: 'Test target',
|
||||||
|
host: '192.168.5.247',
|
||||||
|
port: 443,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: 'test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
||||||
|
return {
|
||||||
|
name: 'test-route',
|
||||||
|
match: { ports: 443, domains: 'test.example.com' },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'placeholder', port: 80 }] },
|
||||||
|
...overrides,
|
||||||
|
} as IRouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Resolution tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let resolver: ReferenceResolver;
|
||||||
|
|
||||||
|
tap.test('should create ReferenceResolver instance', async () => {
|
||||||
|
resolver = new ReferenceResolver();
|
||||||
|
expect(resolver).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should list empty profiles and targets initially', async () => {
|
||||||
|
expect(resolver.listProfiles()).toBeArray();
|
||||||
|
expect(resolver.listProfiles().length).toEqual(0);
|
||||||
|
expect(resolver.listTargets()).toBeArray();
|
||||||
|
expect(resolver.listTargets().length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Source profile resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve source profile onto a route', async () => {
|
||||||
|
const profile = makeProfile();
|
||||||
|
injectProfile(resolver, profile);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
expect(result.route.security).toBeTruthy();
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||||
|
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||||
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should merge inline route security with profile security', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['127.0.0.1'],
|
||||||
|
maxConnections: 5000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// IP lists are unioned
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
|
||||||
|
|
||||||
|
// Inline maxConnections overrides profile
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should deduplicate IP lists during merge', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
|
||||||
|
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
|
||||||
|
expect(count).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle missing profile gracefully', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route should be unchanged
|
||||||
|
expect(result.route.security).toBeUndefined();
|
||||||
|
expect(result.metadata.sourceProfileName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Profile inheritance ----
|
||||||
|
|
||||||
|
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||||
|
const baseProfile = makeProfile({
|
||||||
|
id: 'base-profile',
|
||||||
|
name: 'BASE',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['10.0.0.0/8'],
|
||||||
|
maxConnections: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
injectProfile(resolver, baseProfile);
|
||||||
|
|
||||||
|
const extendedProfile = makeProfile({
|
||||||
|
id: 'extended-profile',
|
||||||
|
name: 'EXTENDED',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['160.79.104.0/21'],
|
||||||
|
},
|
||||||
|
extendsProfiles: ['base-profile'],
|
||||||
|
});
|
||||||
|
injectProfile(resolver, extendedProfile);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Should have IPs from both base and extended profiles
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||||
|
// maxConnections from base (extended doesn't override)
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(500);
|
||||||
|
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should detect circular profile inheritance', async () => {
|
||||||
|
const profileA = makeProfile({
|
||||||
|
id: 'circular-a',
|
||||||
|
name: 'A',
|
||||||
|
security: { ipAllowList: ['1.1.1.1'] },
|
||||||
|
extendsProfiles: ['circular-b'],
|
||||||
|
});
|
||||||
|
const profileB = makeProfile({
|
||||||
|
id: 'circular-b',
|
||||||
|
name: 'B',
|
||||||
|
security: { ipAllowList: ['2.2.2.2'] },
|
||||||
|
extendsProfiles: ['circular-a'],
|
||||||
|
});
|
||||||
|
injectProfile(resolver, profileA);
|
||||||
|
injectProfile(resolver, profileB);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
|
||||||
|
|
||||||
|
// Should not infinite loop — resolves what it can
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
expect(result.route.security).toBeTruthy();
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Network target resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve network target onto a route', async () => {
|
||||||
|
const target = makeTarget();
|
||||||
|
injectTarget(resolver, target);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { networkTargetRef: 'target-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
expect(result.route.action.targets).toBeTruthy();
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||||
|
expect(result.route.action.targets![0].port).toEqual(443);
|
||||||
|
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||||
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle missing target gracefully', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route targets should be unchanged (still the placeholder)
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||||
|
expect(result.metadata.networkTargetName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Combined resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = {
|
||||||
|
sourceProfileRef: 'profile-1',
|
||||||
|
networkTargetRef: 'target-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Security from profile
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||||
|
|
||||||
|
// Target from network target
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||||
|
expect(result.route.action.targets![0].port).toEqual(443);
|
||||||
|
|
||||||
|
// Both names recorded
|
||||||
|
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||||
|
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should skip resolution when no metadata refs', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: { ipAllowList: ['1.2.3.4'] },
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = {};
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route should be completely unchanged
|
||||||
|
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
|
||||||
|
expect(result.route.security!.ipAllowList!.length).toEqual(1);
|
||||||
|
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = {
|
||||||
|
sourceProfileRef: 'profile-1',
|
||||||
|
networkTargetRef: 'target-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = resolver.resolveRoute(route, metadata);
|
||||||
|
const second = resolver.resolveRoute(first.route, first.metadata);
|
||||||
|
|
||||||
|
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
|
||||||
|
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
|
||||||
|
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Lookup helpers ----
|
||||||
|
|
||||||
|
tap.test('should find routes by profile ref (sync)', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-a', {
|
||||||
|
id: 'route-a',
|
||||||
|
route: makeRoute({ name: 'route-a' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { sourceProfileRef: 'profile-1' },
|
||||||
|
});
|
||||||
|
storedRoutes.set('route-b', {
|
||||||
|
id: 'route-b',
|
||||||
|
route: makeRoute({ name: 'route-b' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
storedRoutes.set('route-c', {
|
||||||
|
id: 'route-c',
|
||||||
|
route: makeRoute({ name: 'route-c' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||||
|
expect(profileRefs.length).toEqual(2);
|
||||||
|
expect(profileRefs).toContain('route-a');
|
||||||
|
expect(profileRefs).toContain('route-c');
|
||||||
|
|
||||||
|
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||||
|
expect(targetRefs.length).toEqual(2);
|
||||||
|
expect(targetRefs).toContain('route-b');
|
||||||
|
expect(targetRefs).toContain('route-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-x', {
|
||||||
|
id: 'route-x',
|
||||||
|
route: makeRoute({ name: 'my-route' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { sourceProfileRef: 'profile-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||||
|
expect(usage.length).toEqual(1);
|
||||||
|
expect(usage[0].id).toEqual('route-x');
|
||||||
|
expect(usage[0].routeName).toEqual('my-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get target usage for a specific target ID', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-y', {
|
||||||
|
id: 'route-y',
|
||||||
|
route: makeRoute({ name: 'other-route' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||||
|
expect(usage.length).toEqual(1);
|
||||||
|
expect(usage[0].id).toEqual('route-y');
|
||||||
|
expect(usage[0].routeName).toEqual('other-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Profile/target getters ----
|
||||||
|
|
||||||
|
tap.test('should get profile by name', async () => {
|
||||||
|
const profile = resolver.getProfileByName('STANDARD');
|
||||||
|
expect(profile).toBeTruthy();
|
||||||
|
expect(profile!.id).toEqual('profile-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get target by name', async () => {
|
||||||
|
const target = resolver.getTargetByName('INFRA');
|
||||||
|
expect(target).toBeTruthy();
|
||||||
|
expect(target!.id).toEqual('target-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return undefined for nonexistent profile name', async () => {
|
||||||
|
const profile = resolver.getProfileByName('NONEXISTENT');
|
||||||
|
expect(profile).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return undefined for nonexistent target name', async () => {
|
||||||
|
const target = resolver.getTargetByName('NONEXISTENT');
|
||||||
|
expect(target).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
208
test/test.source-profiles-api.ts
Normal file
208
test/test.source-profiles-api.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
const TEST_PORT = 3200;
|
||||||
|
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Setup — db disabled, handlers return graceful fallbacks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
opsServerPort: TEST_PORT,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
TEST_URL,
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Source Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should return empty profiles list when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSourceProfiles'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.profiles).toBeArray();
|
||||||
|
expect(response.profiles.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return null for single profile when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfile>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSourceProfile'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.profile).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return failure for create profile when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_CreateSourceProfile>(
|
||||||
|
TEST_URL,
|
||||||
|
'createSourceProfile'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
name: 'TEST',
|
||||||
|
security: { ipAllowList: ['*'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toBeFalse();
|
||||||
|
expect(response.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return empty profile usage when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfileUsage>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSourceProfileUsage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.routes).toBeArray();
|
||||||
|
expect(response.routes.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network Target endpoints (graceful fallbacks when resolver unavailable)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should return empty targets list when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargets'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.targets).toBeArray();
|
||||||
|
expect(response.targets.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return null for single target when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTarget>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTarget'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.target).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return failure for create target when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||||
|
TEST_URL,
|
||||||
|
'createNetworkTarget'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
name: 'TEST',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 443,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toBeFalse();
|
||||||
|
expect(response.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return empty target usage when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargetUsage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.routes).toBeArray();
|
||||||
|
expect(response.routes.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth rejection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated profile requests', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSourceProfiles'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.fire({} as any);
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated target requests', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargets'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.fire({} as any);
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
import * as paths from '../ts/paths.js';
|
|
||||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// Test data
|
|
||||||
const testData = {
|
|
||||||
string: 'Hello, World!',
|
|
||||||
json: { name: 'test', value: 42, nested: { data: true } },
|
|
||||||
largeString: 'x'.repeat(10000)
|
|
||||||
};
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Memory Backend', async () => {
|
|
||||||
// Create StorageManager without config (defaults to memory)
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Test basic get/set
|
|
||||||
await storage.set('/test/key', testData.string);
|
|
||||||
const value = await storage.get('/test/key');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Test JSON helpers
|
|
||||||
await storage.setJSON('/test/json', testData.json);
|
|
||||||
const jsonValue = await storage.getJSON('/test/json');
|
|
||||||
expect(jsonValue).toEqual(testData.json);
|
|
||||||
|
|
||||||
// Test exists
|
|
||||||
expect(await storage.exists('/test/key')).toEqual(true);
|
|
||||||
expect(await storage.exists('/nonexistent')).toEqual(false);
|
|
||||||
|
|
||||||
// Test delete
|
|
||||||
await storage.delete('/test/key');
|
|
||||||
expect(await storage.exists('/test/key')).toEqual(false);
|
|
||||||
|
|
||||||
// Test list
|
|
||||||
await storage.set('/items/1', 'one');
|
|
||||||
await storage.set('/items/2', 'two');
|
|
||||||
await storage.set('/other/3', 'three');
|
|
||||||
|
|
||||||
const items = await storage.list('/items');
|
|
||||||
expect(items.length).toEqual(2);
|
|
||||||
expect(items).toContain('/items/1');
|
|
||||||
expect(items).toContain('/items/2');
|
|
||||||
|
|
||||||
// Verify memory backend
|
|
||||||
expect(storage.getBackend()).toEqual('memory');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Filesystem Backend', async () => {
|
|
||||||
const testDir = path.join(paths.dataDir, '.test-storage');
|
|
||||||
|
|
||||||
// Clean up test directory if it exists
|
|
||||||
try {
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Create StorageManager with filesystem path
|
|
||||||
const storage = new StorageManager({ fsPath: testDir });
|
|
||||||
|
|
||||||
// Test basic operations
|
|
||||||
await storage.set('/test/file', testData.string);
|
|
||||||
const value = await storage.get('/test/file');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Verify file exists on disk
|
|
||||||
const filePath = path.join(testDir, 'test', 'file');
|
|
||||||
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
|
||||||
expect(fileExists).toEqual(true);
|
|
||||||
|
|
||||||
// Test atomic writes (temp file should not exist)
|
|
||||||
const tempPath = filePath + '.tmp';
|
|
||||||
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
|
||||||
expect(tempExists).toEqual(false);
|
|
||||||
|
|
||||||
// Test nested paths
|
|
||||||
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
|
||||||
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
|
||||||
expect(nestedValue).toEqual(testData.largeString);
|
|
||||||
|
|
||||||
// Test list with filesystem
|
|
||||||
await storage.set('/fs/items/a', 'alpha');
|
|
||||||
await storage.set('/fs/items/b', 'beta');
|
|
||||||
await storage.set('/fs/other/c', 'gamma');
|
|
||||||
|
|
||||||
// Filesystem backend now properly supports list
|
|
||||||
const fsItems = await storage.list('/fs/items');
|
|
||||||
expect(fsItems.length).toEqual(2); // Should find both items
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Custom Function Backend', async () => {
|
|
||||||
// Create in-memory storage for custom functions
|
|
||||||
const customStore = new Map<string, string>();
|
|
||||||
|
|
||||||
const storage = new StorageManager({
|
|
||||||
readFunction: async (key: string) => {
|
|
||||||
return customStore.get(key) || null;
|
|
||||||
},
|
|
||||||
writeFunction: async (key: string, value: string) => {
|
|
||||||
customStore.set(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test basic operations
|
|
||||||
await storage.set('/custom/key', testData.string);
|
|
||||||
expect(customStore.has('/custom/key')).toEqual(true);
|
|
||||||
|
|
||||||
const value = await storage.get('/custom/key');
|
|
||||||
expect(value).toEqual(testData.string);
|
|
||||||
|
|
||||||
// Test that delete sets empty value (as per implementation)
|
|
||||||
await storage.delete('/custom/key');
|
|
||||||
expect(customStore.get('/custom/key')).toEqual('');
|
|
||||||
|
|
||||||
// Verify custom backend (filesystem is implemented as custom backend internally)
|
|
||||||
expect(storage.getBackend()).toEqual('custom');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Key Validation', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Test key normalization
|
|
||||||
await storage.set('test/key', 'value1'); // Missing leading slash
|
|
||||||
const value1 = await storage.get('/test/key');
|
|
||||||
expect(value1).toEqual('value1');
|
|
||||||
|
|
||||||
// Test dangerous path elements are removed
|
|
||||||
await storage.set('/test/../danger/key', 'value2');
|
|
||||||
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
|
||||||
expect(value2).toEqual('value2');
|
|
||||||
|
|
||||||
// Test multiple slashes are normalized
|
|
||||||
await storage.set('/test///multiple////slashes', 'value3');
|
|
||||||
const value3 = await storage.get('/test/multiple/slashes');
|
|
||||||
expect(value3).toEqual('value3');
|
|
||||||
|
|
||||||
// Test invalid keys throw errors
|
|
||||||
let emptyKeyError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set('', 'value');
|
|
||||||
} catch (error) {
|
|
||||||
emptyKeyError = error as Error;
|
|
||||||
}
|
|
||||||
expect(emptyKeyError).toBeTruthy();
|
|
||||||
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
|
||||||
|
|
||||||
let nullKeyError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set(null as any, 'value');
|
|
||||||
} catch (error) {
|
|
||||||
nullKeyError = error as Error;
|
|
||||||
}
|
|
||||||
expect(nullKeyError).toBeTruthy();
|
|
||||||
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Concurrent Access', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
const promises: Promise<void>[] = [];
|
|
||||||
|
|
||||||
// Simulate concurrent writes
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
// Verify all writes succeeded
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
const value = await storage.get(`/concurrent/key${i}`);
|
|
||||||
expect(value).toEqual(`value${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test concurrent reads
|
|
||||||
const readPromises: Promise<string | null>[] = [];
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
readPromises.push(storage.get(`/concurrent/key${i}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(readPromises);
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
expect(results[i]).toEqual(`value${i}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Backend Priority', async () => {
|
|
||||||
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
|
||||||
|
|
||||||
// Test that custom functions take priority over fsPath
|
|
||||||
let warningLogged = false;
|
|
||||||
const originalWarn = console.warn;
|
|
||||||
console.warn = (message: string) => {
|
|
||||||
if (message.includes('Using custom read/write functions')) {
|
|
||||||
warningLogged = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const storage = new StorageManager({
|
|
||||||
fsPath: testDir,
|
|
||||||
readFunction: async () => 'custom-value',
|
|
||||||
writeFunction: async () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.warn = originalWarn;
|
|
||||||
|
|
||||||
expect(warningLogged).toEqual(true);
|
|
||||||
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
try {
|
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - Error Handling', async () => {
|
|
||||||
// Test filesystem errors
|
|
||||||
const storage = new StorageManager({
|
|
||||||
readFunction: async () => {
|
|
||||||
throw new Error('Read error');
|
|
||||||
},
|
|
||||||
writeFunction: async () => {
|
|
||||||
throw new Error('Write error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read errors should return null
|
|
||||||
const value = await storage.get('/error/key');
|
|
||||||
expect(value).toEqual(null);
|
|
||||||
|
|
||||||
// Write errors should propagate
|
|
||||||
let writeError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await storage.set('/error/key', 'value');
|
|
||||||
} catch (error) {
|
|
||||||
writeError = error as Error;
|
|
||||||
}
|
|
||||||
expect(writeError).toBeTruthy();
|
|
||||||
expect(writeError?.message).toEqual('Write error');
|
|
||||||
|
|
||||||
// Test JSON parse errors
|
|
||||||
const jsonStorage = new StorageManager({
|
|
||||||
readFunction: async () => 'invalid json',
|
|
||||||
writeFunction: async () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test JSON parse errors
|
|
||||||
let jsonError: Error | null = null;
|
|
||||||
try {
|
|
||||||
await jsonStorage.getJSON('/invalid/json');
|
|
||||||
} catch (error) {
|
|
||||||
jsonError = error as Error;
|
|
||||||
}
|
|
||||||
expect(jsonError).toBeTruthy();
|
|
||||||
expect(jsonError?.message).toContain('JSON');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Storage Manager - List Operations', async () => {
|
|
||||||
const storage = new StorageManager();
|
|
||||||
|
|
||||||
// Populate storage with hierarchical data
|
|
||||||
await storage.set('/app/config/database', 'db-config');
|
|
||||||
await storage.set('/app/config/cache', 'cache-config');
|
|
||||||
await storage.set('/app/data/users/1', 'user1');
|
|
||||||
await storage.set('/app/data/users/2', 'user2');
|
|
||||||
await storage.set('/app/logs/error.log', 'errors');
|
|
||||||
|
|
||||||
// List root
|
|
||||||
const rootItems = await storage.list('/');
|
|
||||||
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
|
||||||
|
|
||||||
// List specific paths
|
|
||||||
const configItems = await storage.list('/app/config');
|
|
||||||
expect(configItems.length).toEqual(2);
|
|
||||||
expect(configItems).toContain('/app/config/database');
|
|
||||||
expect(configItems).toContain('/app/config/cache');
|
|
||||||
|
|
||||||
const userItems = await storage.list('/app/data/users');
|
|
||||||
expect(userItems.length).toEqual(2);
|
|
||||||
|
|
||||||
// List non-existent path
|
|
||||||
const emptyList = await storage.list('/nonexistent/path');
|
|
||||||
expect(emptyList.length).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DcRouter } from '../ts/index.js';
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
const devRouter = new DcRouter({
|
const devRouter = new DcRouter({
|
||||||
|
// Server public IP (used for VPN AllowedIPs)
|
||||||
|
publicIp: '203.0.113.1',
|
||||||
// SmartProxy routes for development/demo
|
// SmartProxy routes for development/demo
|
||||||
smartProxyConfig: {
|
smartProxyConfig: {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -23,10 +25,31 @@ const devRouter = new DcRouter({
|
|||||||
tls: { mode: 'passthrough' },
|
tls: { mode: 'passthrough' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-internal-app',
|
||||||
|
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||||
|
vpnOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-eng-dashboard',
|
||||||
|
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||||
|
vpnOnly: true,
|
||||||
|
},
|
||||||
|
] as any[],
|
||||||
|
},
|
||||||
|
// VPN with pre-defined clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.dev.local',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', description: 'Developer laptop' },
|
||||||
|
{ clientId: 'ci-runner', description: 'CI/CD pipeline' },
|
||||||
|
{ clientId: 'admin-desktop', description: 'Admin workstation' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Disable cache/mongo for dev
|
dbConfig: { enabled: true },
|
||||||
cacheConfig: { enabled: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Starting DcRouter in development mode...');
|
console.log('Starting DcRouter in development mode...');
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.8.0',
|
version: '13.8.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
1
ts/acme/index.ts
Normal file
1
ts/acme/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './manager.acme-config.js';
|
||||||
182
ts/acme/manager.acme-config.ts
Normal file
182
ts/acme/manager.acme-config.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { AcmeConfigDoc } from '../db/documents/index.js';
|
||||||
|
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||||
|
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
|
||||||
|
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
|
||||||
|
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
|
||||||
|
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
|
||||||
|
*
|
||||||
|
* Reload semantics: updates take effect on the next dcrouter restart because
|
||||||
|
* `SmartAcme` is instantiated once in `setupSmartProxy()`. `renewThresholdDays`
|
||||||
|
* applies immediately to the next renewal check. See
|
||||||
|
* `ts_web/elements/domains/ops-view-certificates.ts` for the UI warning.
|
||||||
|
*/
|
||||||
|
export class AcmeConfigManager {
|
||||||
|
private cached: IAcmeConfig | null = null;
|
||||||
|
|
||||||
|
constructor(private options: IDcRouterOptions) {}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
logger.log('info', 'AcmeConfigManager: starting');
|
||||||
|
let doc = await AcmeConfigDoc.load();
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
// First-boot path: seed from legacy constructor fields if present.
|
||||||
|
const seed = this.deriveSeedFromOptions();
|
||||||
|
if (seed) {
|
||||||
|
doc = await this.createSeedDoc(seed);
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (this.deriveSeedFromOptions()) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cached = doc ? this.toPlain(doc) : null;
|
||||||
|
if (this.cached) {
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`AcmeConfigManager: loaded ACME config (accountEmail=${this.cached.accountEmail}, enabled=${this.cached.enabled}, useProduction=${this.cached.useProduction})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
this.cached = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current ACME config, or null if not configured.
|
||||||
|
* In-memory — does not hit the DB.
|
||||||
|
*/
|
||||||
|
public getConfig(): IAcmeConfig | null {
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if there is an enabled ACME config. Used by `setupSmartProxy()` to
|
||||||
|
* decide whether to instantiate SmartAcme.
|
||||||
|
*/
|
||||||
|
public hasEnabledConfig(): boolean {
|
||||||
|
return this.cached !== null && this.cached.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert the ACME config. All fields are optional; missing fields are
|
||||||
|
* preserved from the existing row (or defaulted if there is no row yet).
|
||||||
|
*/
|
||||||
|
public async updateConfig(
|
||||||
|
args: Partial<Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>>,
|
||||||
|
updatedBy: string,
|
||||||
|
): Promise<IAcmeConfig> {
|
||||||
|
let doc = await AcmeConfigDoc.load();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
doc = new AcmeConfigDoc();
|
||||||
|
doc.configId = 'acme-config';
|
||||||
|
doc.accountEmail = args.accountEmail ?? '';
|
||||||
|
doc.enabled = args.enabled ?? true;
|
||||||
|
doc.useProduction = args.useProduction ?? true;
|
||||||
|
doc.autoRenew = args.autoRenew ?? true;
|
||||||
|
doc.renewThresholdDays = args.renewThresholdDays ?? 30;
|
||||||
|
} else {
|
||||||
|
if (args.accountEmail !== undefined) doc.accountEmail = args.accountEmail;
|
||||||
|
if (args.enabled !== undefined) doc.enabled = args.enabled;
|
||||||
|
if (args.useProduction !== undefined) doc.useProduction = args.useProduction;
|
||||||
|
if (args.autoRenew !== undefined) doc.autoRenew = args.autoRenew;
|
||||||
|
if (args.renewThresholdDays !== undefined) doc.renewThresholdDays = args.renewThresholdDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.updatedBy = updatedBy;
|
||||||
|
await doc.save();
|
||||||
|
|
||||||
|
this.cached = this.toPlain(doc);
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Internal helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a seed object from the legacy constructor fields. Returns null
|
||||||
|
* if the user has not provided any of them.
|
||||||
|
*
|
||||||
|
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
|
||||||
|
* (full form). `smartProxyConfig.acme` wins when both are present.
|
||||||
|
*/
|
||||||
|
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
|
||||||
|
const acme = this.options.smartProxyConfig?.acme;
|
||||||
|
const tls = this.options.tls;
|
||||||
|
|
||||||
|
// Prefer the explicit smartProxyConfig.acme block if present.
|
||||||
|
if (acme?.accountEmail) {
|
||||||
|
return {
|
||||||
|
accountEmail: acme.accountEmail,
|
||||||
|
enabled: acme.enabled !== false,
|
||||||
|
useProduction: acme.useProduction !== false,
|
||||||
|
autoRenew: acme.autoRenew !== false,
|
||||||
|
renewThresholdDays: acme.renewThresholdDays ?? 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the short tls.contactEmail form.
|
||||||
|
if (tls?.contactEmail) {
|
||||||
|
return {
|
||||||
|
accountEmail: tls.contactEmail,
|
||||||
|
enabled: true,
|
||||||
|
useProduction: true,
|
||||||
|
autoRenew: true,
|
||||||
|
renewThresholdDays: 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSeedDoc(
|
||||||
|
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
|
||||||
|
): Promise<AcmeConfigDoc> {
|
||||||
|
const doc = new AcmeConfigDoc();
|
||||||
|
doc.configId = 'acme-config';
|
||||||
|
doc.accountEmail = seed.accountEmail;
|
||||||
|
doc.enabled = seed.enabled;
|
||||||
|
doc.useProduction = seed.useProduction;
|
||||||
|
doc.autoRenew = seed.autoRenew;
|
||||||
|
doc.renewThresholdDays = seed.renewThresholdDays;
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
doc.updatedBy = 'seed';
|
||||||
|
await doc.save();
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
|
||||||
|
return {
|
||||||
|
accountEmail: doc.accountEmail,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
useProduction: doc.useProduction,
|
||||||
|
autoRenew: doc.autoRenew,
|
||||||
|
renewThresholdDays: doc.renewThresholdDays,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
updatedBy: doc.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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 LocalTsmDb and smartdata
|
|
||||||
*
|
|
||||||
* Provides persistent caching using smartdata as the ORM layer
|
|
||||||
* and LocalTsmDb as the embedded database engine.
|
|
||||||
*/
|
|
||||||
export class CacheDb {
|
|
||||||
private static instance: CacheDb | null = null;
|
|
||||||
|
|
||||||
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
|
||||||
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 LocalTsmDb with file persistence
|
|
||||||
* - Connects smartdata to the LocalTsmDb 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 LocalTsmDb instance
|
|
||||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
|
||||||
folderPath: this.options.storagePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start LocalTsmDb and get connection info
|
|
||||||
const connectionInfo = await this.localTsmDb.start();
|
|
||||||
|
|
||||||
if (this.options.debug) {
|
|
||||||
logger.log('debug', `LocalTsmDb 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) {
|
|
||||||
logger.log('error', `Failed to start CacheDb: ${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 LocalTsmDb
|
|
||||||
if (this.localTsmDb) {
|
|
||||||
await this.localTsmDb.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isStarted = false;
|
|
||||||
logger.log('info', 'CacheDb stopped');
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error stopping CacheDb: ${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,65 +10,86 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a domain is currently in backoff
|
* Check if a domain is currently in backoff.
|
||||||
|
* Expired entries are pruned from the cache to prevent unbounded growth.
|
||||||
*/
|
*/
|
||||||
async isInBackoff(domain: string): Promise<boolean> {
|
async isInBackoff(domain: string): Promise<boolean> {
|
||||||
const entry = await this.loadBackoff(domain);
|
const entry = await this.loadBackoff(domain);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
|
|
||||||
const retryAfter = new Date(entry.retryAfter);
|
const retryAfter = new Date(entry.retryAfter);
|
||||||
return retryAfter.getTime() > Date.now();
|
if (retryAfter.getTime() > Date.now()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff has expired — prune the stale entry
|
||||||
|
this.backoffCache.delete(domain);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +149,12 @@ export class CertProvisionScheduler {
|
|||||||
const entry = await this.loadBackoff(domain);
|
const entry = await this.loadBackoff(domain);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
// Only return if still in backoff
|
// Only return if still in backoff — prune expired entries
|
||||||
const retryAfter = new Date(entry.retryAfter);
|
const retryAfter = new Date(entry.retryAfter);
|
||||||
if (retryAfter.getTime() <= Date.now()) return null;
|
if (retryAfter.getTime() <= Date.now()) {
|
||||||
|
this.backoffCache.delete(domain);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
failures: entry.failures,
|
failures: entry.failures,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,29 +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 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 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.
|
||||||
*/
|
*/
|
||||||
@@ -64,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,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();
|
||||||
@@ -87,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,
|
||||||
@@ -94,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);
|
||||||
@@ -104,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);
|
||||||
@@ -125,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;
|
||||||
}
|
}
|
||||||
@@ -146,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();
|
||||||
}
|
}
|
||||||
@@ -154,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;
|
||||||
@@ -165,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) {
|
||||||
@@ -179,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) {
|
||||||
@@ -193,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -240,39 +360,91 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Re-resolve routes after profile/target changes
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve specific routes by ID (after a profile or target is updated).
|
||||||
|
* Persists each route and calls applyRoutes() once at the end.
|
||||||
|
*/
|
||||||
|
public async reResolveRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||||
|
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const stored = this.storedRoutes.get(routeId);
|
||||||
|
if (!stored?.metadata) continue;
|
||||||
|
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
|
stored.route = resolved.route;
|
||||||
|
stored.metadata = resolved.metadata;
|
||||||
|
stored.updatedAt = Date.now();
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.applyRoutes();
|
||||||
|
logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: apply merged routes to SmartProxy
|
// Private: apply merged routes to SmartProxy
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async applyRoutes(): Promise<void> {
|
public async applyRoutes(): Promise<void> {
|
||||||
const smartProxy = this.getSmartProxy();
|
await this.routeUpdateMutex.runExclusive(async () => {
|
||||||
if (!smartProxy) return;
|
const smartProxy = this.getSmartProxy();
|
||||||
|
if (!smartProxy) return;
|
||||||
|
|
||||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
// Add enabled hardcoded routes (respecting overrides)
|
const http3Config = this.getHttp3Config?.();
|
||||||
for (const route of this.getHardcodedRoutes()) {
|
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||||
const name = route.name || '';
|
|
||||||
const override = this.overrides.get(name);
|
// Helper: inject VPN security into a vpnOnly route
|
||||||
if (override && !override.enabled) {
|
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||||
continue; // Skip disabled hardcoded route
|
if (!vpnCallback) return route;
|
||||||
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
|
if (!dcRoute.vpnOnly) return route;
|
||||||
|
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||||
|
const existingEntries = route.security?.ipAllowList || [];
|
||||||
|
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
|
||||||
|
}
|
||||||
|
enabledRoutes.push(injectVpn(route));
|
||||||
}
|
}
|
||||||
enabledRoutes.push(route);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
|
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||||
const http3Config = this.getHttp3Config?.();
|
for (const stored of this.storedRoutes.values()) {
|
||||||
for (const stored of this.storedRoutes.values()) {
|
if (stored.enabled) {
|
||||||
if (stored.enabled) {
|
let route = stored.route;
|
||||||
if (http3Config && http3Config.enabled !== false) {
|
if (http3Config?.enabled !== false) {
|
||||||
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
|
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||||
} else {
|
}
|
||||||
enabledRoutes.push(stored.route);
|
enabledRoutes.push(injectVpn(route, stored.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
||||||
@@ -170,7 +170,7 @@ export class ConfigValidator {
|
|||||||
} else if (rules.items.schema && itemType === 'object') {
|
} else if (rules.items.schema && itemType === 'object') {
|
||||||
const itemResult = this.validate(value[i], rules.items.schema);
|
const itemResult = this.validate(value[i], rules.items.schema);
|
||||||
if (!itemResult.valid) {
|
if (!itemResult.valid) {
|
||||||
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
|
errors.push(...itemResult.errors!.map(err => `${key}[${i}].${err}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ export class ConfigValidator {
|
|||||||
if (rules.schema) {
|
if (rules.schema) {
|
||||||
const nestedResult = this.validate(value, rules.schema);
|
const nestedResult = this.validate(value, rules.schema);
|
||||||
if (!nestedResult.valid) {
|
if (!nestedResult.valid) {
|
||||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
|
||||||
}
|
}
|
||||||
validatedConfig[key] = nestedResult.config;
|
validatedConfig[key] = nestedResult.config;
|
||||||
}
|
}
|
||||||
@@ -233,8 +233,8 @@ export class ConfigValidator {
|
|||||||
|
|
||||||
// Apply defaults to array items
|
// Apply defaults to array items
|
||||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||||
result[key] = result[key].map(item =>
|
result[key] = result[key].map(item =>
|
||||||
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ export class ConfigValidator {
|
|||||||
|
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
`Configuration validation failed: ${result.errors!.join(', ')}`,
|
||||||
'CONFIG_VALIDATION_ERROR',
|
'CONFIG_VALIDATION_ERROR',
|
||||||
{ data: { errors: result.errors } }
|
{ data: { errors: result.errors } }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -48,14 +48,14 @@ export class CacheCleaner {
|
|||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
// Run cleanup immediately on start
|
// Run cleanup immediately on start
|
||||||
this.runCleanup().catch((error) => {
|
this.runCleanup().catch((error: unknown) => {
|
||||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schedule periodic cleanup
|
// Schedule periodic cleanup
|
||||||
this.cleanupInterval = setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
this.runCleanup().catch((error) => {
|
this.runCleanup().catch((error: unknown) => {
|
||||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
|
||||||
});
|
});
|
||||||
}, this.options.intervalMs);
|
}, this.options.intervalMs);
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +113,8 @@ export class CacheCleaner {
|
|||||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,14 +138,14 @@ export class CacheCleaner {
|
|||||||
try {
|
try {
|
||||||
await doc.delete();
|
await doc.delete();
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
} catch (deleteError) {
|
} catch (deleteError: unknown) {
|
||||||
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ export abstract class CachedDocument<T extends CachedDocument<T>> extends plugin
|
|||||||
* Timestamp when the document expires and should be cleaned up
|
* Timestamp when the document expires and should be cleaned up
|
||||||
* NOTE: Subclasses must add @svDb() decorator
|
* NOTE: Subclasses must add @svDb() decorator
|
||||||
*/
|
*/
|
||||||
public expiresAt: Date;
|
public expiresAt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp of last access (for LRU-style eviction if needed)
|
* Timestamp of last access (for LRU-style eviction if needed)
|
||||||
179
ts/db/classes.dcrouter-db.ts
Normal file
179
ts/db/classes.dcrouter-db.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { defaultTsmDbPath } from '../paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for the unified DCRouter database
|
||||||
|
*/
|
||||||
|
export interface IDcRouterDbConfig {
|
||||||
|
/** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */
|
||||||
|
mongoDbUrl?: string;
|
||||||
|
/** Storage path for embedded LocalSmartDb data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||||
|
storagePath?: string;
|
||||||
|
/** Database name (default: dcrouter) */
|
||||||
|
dbName?: string;
|
||||||
|
/** Enable debug logging */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DcRouterDb - Unified database layer for DCRouter
|
||||||
|
*
|
||||||
|
* Replaces both StorageManager (flat-file key-value) and CacheDb (embedded MongoDB).
|
||||||
|
* All data is stored as smartdata document classes in a single database.
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* - **Embedded** (default): Spawns a LocalSmartDb (Rust-based MongoDB-compatible engine)
|
||||||
|
* - **External**: Connects to a provided MongoDB URL
|
||||||
|
*/
|
||||||
|
export class DcRouterDb {
|
||||||
|
private static instance: DcRouterDb | null = null;
|
||||||
|
|
||||||
|
private localSmartDb: plugins.smartdb.LocalSmartDb | null = null;
|
||||||
|
private smartdataDb!: plugins.smartdata.SmartdataDb;
|
||||||
|
private options: Required<IDcRouterDbConfig>;
|
||||||
|
private isStarted: boolean = false;
|
||||||
|
|
||||||
|
constructor(options: IDcRouterDbConfig = {}) {
|
||||||
|
this.options = {
|
||||||
|
mongoDbUrl: options.mongoDbUrl || '',
|
||||||
|
storagePath: options.storagePath || defaultTsmDbPath,
|
||||||
|
dbName: options.dbName || 'dcrouter',
|
||||||
|
debug: options.debug || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(options?: IDcRouterDbConfig): DcRouterDb {
|
||||||
|
if (!DcRouterDb.instance) {
|
||||||
|
DcRouterDb.instance = new DcRouterDb(options);
|
||||||
|
}
|
||||||
|
return DcRouterDb.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (useful for testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
DcRouterDb.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the database
|
||||||
|
* - If mongoDbUrl is provided, connects directly to external MongoDB
|
||||||
|
* - Otherwise, starts an embedded LocalSmartDb instance
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.isStarted) {
|
||||||
|
logger.log('warn', 'DcRouterDb already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let connectionUri: string;
|
||||||
|
|
||||||
|
if (this.options.mongoDbUrl) {
|
||||||
|
// External MongoDB mode
|
||||||
|
connectionUri = this.options.mongoDbUrl;
|
||||||
|
logger.log('info', `DcRouterDb connecting to external MongoDB`);
|
||||||
|
} else {
|
||||||
|
// Embedded LocalSmartDb mode
|
||||||
|
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||||
|
|
||||||
|
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
|
||||||
|
folderPath: this.options.storagePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionInfo = await this.localSmartDb.start();
|
||||||
|
connectionUri = connectionInfo.connectionUri;
|
||||||
|
|
||||||
|
if (this.options.debug) {
|
||||||
|
logger.log('debug', `LocalSmartDb started with URI: ${connectionUri}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `DcRouterDb started embedded instance at ${this.options.storagePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize smartdata ORM
|
||||||
|
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||||
|
mongoDbUrl: connectionUri,
|
||||||
|
mongoDbName: this.options.dbName,
|
||||||
|
});
|
||||||
|
await this.smartdataDb.init();
|
||||||
|
|
||||||
|
this.isStarted = true;
|
||||||
|
logger.log('info', `DcRouterDb ready (db: ${this.options.dbName})`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Failed to start DcRouterDb: ${(error as Error).message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the database
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Close smartdata connection
|
||||||
|
if (this.smartdataDb) {
|
||||||
|
await this.smartdataDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop embedded LocalSmartDb if running
|
||||||
|
if (this.localSmartDb) {
|
||||||
|
await this.localSmartDb.stop();
|
||||||
|
this.localSmartDb = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarted = false;
|
||||||
|
logger.log('info', 'DcRouterDb stopped');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Error stopping DcRouterDb: ${(error as Error).message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the smartdata database instance for @Collection decorators
|
||||||
|
*/
|
||||||
|
public getDb(): plugins.smartdata.SmartdataDb {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
throw new Error('DcRouterDb not started. Call start() first.');
|
||||||
|
}
|
||||||
|
return this.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the database is ready
|
||||||
|
*/
|
||||||
|
public isReady(): boolean {
|
||||||
|
return this.isStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether running in embedded mode (LocalSmartDb) vs external MongoDB
|
||||||
|
*/
|
||||||
|
public isEmbedded(): boolean {
|
||||||
|
return !this.options.mongoDbUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage path (only relevant for embedded mode)
|
||||||
|
*/
|
||||||
|
public getStoragePath(): string {
|
||||||
|
return this.options.storagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the database name
|
||||||
|
*/
|
||||||
|
public getDbName(): string {
|
||||||
|
return this.options.dbName;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ts/db/documents/classes.accounting-session.doc.ts
Normal file
106
ts/db/documents/classes.accounting-session.doc.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class AccountingSessionDoc extends plugins.smartdata.SmartDataDbDoc<AccountingSessionDoc, AccountingSessionDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public sessionId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public username!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public macAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasIpAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasPort!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasPortType!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nasIdentifier!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public vlanId!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public framedIpAddress!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public calledStationId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public callingStationId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public startTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public endTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastUpdateTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.index()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public status!: 'active' | 'stopped' | 'terminated';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public terminateCause!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public inputOctets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public outputOctets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public inputPackets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public outputPackets!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public sessionTime!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public serviceType!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findBySessionId(sessionId: string): Promise<AccountingSessionDoc | null> {
|
||||||
|
return await AccountingSessionDoc.getInstance({ sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findActive(): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ status: 'active' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByUsername(username: string): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ username });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByNas(nasIpAddress: string): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ nasIpAddress });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByVlan(vlanId: number): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({ vlanId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findStoppedBefore(cutoffTime: number): Promise<AccountingSessionDoc[]> {
|
||||||
|
return await AccountingSessionDoc.getInstances({
|
||||||
|
status: { $in: ['stopped', 'terminated'] } as any,
|
||||||
|
endTime: { $lt: cutoffTime, $gt: 0 } as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ts/db/documents/classes.acme-cert.doc.ts
Normal file
41
ts/db/documents/classes.acme-cert.doc.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class AcmeCertDoc extends plugins.smartdata.SmartDataDbDoc<AcmeCertDoc, AcmeCertDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domainName!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public created!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public privateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public publicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public csr!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validUntil!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domainName: string): Promise<AcmeCertDoc | null> {
|
||||||
|
return await AcmeCertDoc.getInstance({ domainName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<AcmeCertDoc[]> {
|
||||||
|
return await AcmeCertDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton ACME configuration document. One row per dcrouter instance,
|
||||||
|
* keyed on the fixed `configId = 'acme-config'` following the
|
||||||
|
* `VpnServerKeysDoc` pattern.
|
||||||
|
*
|
||||||
|
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
|
||||||
|
* constructor fields. Managed via the OpsServer UI at
|
||||||
|
* **Domains > Certificates > Settings**.
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public configId: string = 'acme-config';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public accountEmail: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled: boolean = true;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public useProduction: boolean = true;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public autoRenew: boolean = true;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public renewThresholdDays: number = 30;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt: number = 0;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedBy: string = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async load(): Promise<AcmeConfigDoc | null> {
|
||||||
|
return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
|
||||||
|
}
|
||||||
|
}
|
||||||
56
ts/db/documents/classes.api-token.doc.ts
Normal file
56
ts/db/documents/classes.api-token.doc.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, ApiTokenDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public tokenHash!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public scopes!: TApiTokenScope[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt!: number | null;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastUsedAt!: number | null;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<ApiTokenDoc | null> {
|
||||||
|
return await ApiTokenDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByTokenHash(tokenHash: string): Promise<ApiTokenDoc | null> {
|
||||||
|
return await ApiTokenDoc.getInstance({ tokenHash });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<ApiTokenDoc[]> {
|
||||||
|
return await ApiTokenDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findEnabled(): Promise<ApiTokenDoc[]> {
|
||||||
|
return await ApiTokenDoc.getInstances({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||||
import { CacheDb } from '../classes.cachedb.js';
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email status in the cache
|
* Email status in the cache
|
||||||
@@ -10,7 +10,7 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'faile
|
|||||||
/**
|
/**
|
||||||
* Helper to get the smartdata database instance
|
* Helper to get the smartdata database instance
|
||||||
*/
|
*/
|
||||||
const getDb = () => CacheDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CachedEmail - Stores email queue items in the cache
|
* CachedEmail - Stores email queue items in the cache
|
||||||
@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
*/
|
*/
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public id: string;
|
public id!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email message ID (RFC 822 Message-ID header)
|
* Email message ID (RFC 822 Message-ID header)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public messageId: string;
|
public messageId!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sender email address (envelope from)
|
* Sender email address (envelope from)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public from: string;
|
public from!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recipient email addresses
|
* Recipient email addresses
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public to: string[];
|
public to!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CC recipients
|
* CC recipients
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public cc: string[];
|
public cc!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BCC recipients
|
* BCC recipients
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public bcc: string[];
|
public bcc!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email subject
|
* Email subject
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public subject: string;
|
public subject!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw RFC822 email content
|
* Raw RFC822 email content
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public rawContent: string;
|
public rawContent!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current status of the email
|
* Current status of the email
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public status: TCachedEmailStatus;
|
public status!: TCachedEmailStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of delivery attempts
|
* Number of delivery attempts
|
||||||
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
* Timestamp for next delivery attempt
|
* Timestamp for next delivery attempt
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public nextAttempt: Date;
|
public nextAttempt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last error message if delivery failed
|
* Last error message if delivery failed
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public lastError: string;
|
public lastError!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp when the email was successfully delivered
|
* Timestamp when the email was successfully delivered
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public deliveredAt: Date;
|
public deliveredAt!: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sender domain (for querying/filtering)
|
* Sender domain (for querying/filtering)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public senderDomain: string;
|
public senderDomain!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Priority level (higher = more important)
|
* Priority level (higher = more important)
|
||||||
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
|||||||
* JSON-serialized route data
|
* JSON-serialized route data
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public routeData: string;
|
public routeData!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DKIM signature status
|
* DKIM signature status
|
||||||
@@ -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
|
||||||
@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
|||||||
*/
|
*/
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public ipAddress: string;
|
public ipAddress!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reputation score (0-100, higher = better)
|
* Reputation score (0-100, higher = better)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public score: number;
|
public score!: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is flagged as spam source
|
* Whether the IP is flagged as spam source
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isSpam: boolean;
|
public isSpam!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a known proxy
|
* Whether the IP is a known proxy
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isProxy: boolean;
|
public isProxy!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a Tor exit node
|
* Whether the IP is a Tor exit node
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isTor: boolean;
|
public isTor!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the IP is a VPN endpoint
|
* Whether the IP is a VPN endpoint
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public isVPN: boolean;
|
public isVPN!: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Country code (ISO 3166-1 alpha-2)
|
* Country code (ISO 3166-1 alpha-2)
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public country: string;
|
public country!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autonomous System Number
|
* Autonomous System Number
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public asn: string;
|
public asn!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organization name
|
* Organization name
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public org: string;
|
public org!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of blacklists the IP appears on
|
* List of blacklists the IP appears on
|
||||||
*/
|
*/
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public blacklists: string[];
|
public blacklists!: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of times this IP has been checked
|
* Number of times this IP has been checked
|
||||||
35
ts/db/documents/classes.cert-backoff.doc.ts
Normal file
35
ts/db/documents/classes.cert-backoff.doc.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class CertBackoffDoc extends plugins.smartdata.SmartDataDbDoc<CertBackoffDoc, CertBackoffDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domain!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public failures!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastFailure!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public retryAfter!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastError!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domain: string): Promise<CertBackoffDoc | null> {
|
||||||
|
return await CertBackoffDoc.getInstance({ domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<CertBackoffDoc[]> {
|
||||||
|
return await CertBackoffDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type {
|
||||||
|
TDnsProviderType,
|
||||||
|
TDnsProviderStatus,
|
||||||
|
TDnsProviderCredentials,
|
||||||
|
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc<DnsProviderDoc, DnsProviderDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public type!: TDnsProviderType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider credentials, persisted as an opaque object. Shape varies by `type`.
|
||||||
|
* Never returned to the UI — handlers map to IDnsProviderPublic before sending.
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public credentials!: TDnsProviderCredentials;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public status: TDnsProviderStatus = 'untested';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastTestedAt?: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastError?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<DnsProviderDoc | null> {
|
||||||
|
return await DnsProviderDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<DnsProviderDoc[]> {
|
||||||
|
return await DnsProviderDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByType(type: TDnsProviderType): Promise<DnsProviderDoc[]> {
|
||||||
|
return await DnsProviderDoc.getInstances({ type });
|
||||||
|
}
|
||||||
|
}
|
||||||
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc, DnsRecordDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domainId!: string;
|
||||||
|
|
||||||
|
/** FQDN of the record (e.g. 'www.example.com'). */
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public type!: TDnsRecordType;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public value!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public ttl: number = 300;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public proxied?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public source!: TDnsRecordSource;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public providerRecordId?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<DnsRecordDoc | null> {
|
||||||
|
return await DnsRecordDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<DnsRecordDoc[]> {
|
||||||
|
return await DnsRecordDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomainId(domainId: string): Promise<DnsRecordDoc[]> {
|
||||||
|
return await DnsRecordDoc.getInstances({ domainId });
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ts/db/documents/classes.domain.doc.ts
Normal file
66
ts/db/documents/classes.domain.doc.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { TDomainSource } from '../../../ts_interfaces/data/domain.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class DomainDoc extends plugins.smartdata.SmartDataDbDoc<DomainDoc, DomainDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
/** FQDN — kept lowercased on save. */
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public source!: TDomainSource;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public providerId?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public authoritative: boolean = false;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nameservers?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public externalZoneId?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastSyncedAt?: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<DomainDoc | null> {
|
||||||
|
return await DomainDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<DomainDoc | null> {
|
||||||
|
return await DomainDoc.getInstance({ name: name.toLowerCase() });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<DomainDoc[]> {
|
||||||
|
return await DomainDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByProviderId(providerId: string): Promise<DomainDoc[]> {
|
||||||
|
return await DomainDoc.getInstances({ providerId });
|
||||||
|
}
|
||||||
|
}
|
||||||
48
ts/db/documents/classes.network-target.doc.ts
Normal file
48
ts/db/documents/classes.network-target.doc.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class NetworkTargetDoc extends plugins.smartdata.SmartDataDbDoc<NetworkTargetDoc, NetworkTargetDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public host!: string | string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public port!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<NetworkTargetDoc | null> {
|
||||||
|
return await NetworkTargetDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<NetworkTargetDoc | null> {
|
||||||
|
return await NetworkTargetDoc.getInstance({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<NetworkTargetDoc[]> {
|
||||||
|
return await NetworkTargetDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
38
ts/db/documents/classes.proxy-cert.doc.ts
Normal file
38
ts/db/documents/classes.proxy-cert.doc.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class ProxyCertDoc extends plugins.smartdata.SmartDataDbDoc<ProxyCertDoc, ProxyCertDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domain!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public publicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public privateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public ca!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validUntil!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public validFrom!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domain: string): Promise<ProxyCertDoc | null> {
|
||||||
|
return await ProxyCertDoc.getInstance({ domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<ProxyCertDoc[]> {
|
||||||
|
return await ProxyCertDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
ts/db/documents/classes.remote-ingress-edge.doc.ts
Normal file
54
ts/db/documents/classes.remote-ingress-edge.doc.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressEdgeDoc, RemoteIngressEdgeDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public secret!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public listenPorts!: number[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public listenPortsUdp!: number[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public autoDerivePorts!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public tags!: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<RemoteIngressEdgeDoc | null> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<RemoteIngressEdgeDoc[]> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findEnabled(): Promise<RemoteIngressEdgeDoc[]> {
|
||||||
|
return await RemoteIngressEdgeDoc.getInstances({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ts/db/documents/classes.route-override.doc.ts
Normal file
32
ts/db/documents/classes.route-override.doc.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public routeName!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
|
||||||
|
return await RouteOverrideDoc.getInstance({ routeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<RouteOverrideDoc[]> {
|
||||||
|
return await RouteOverrideDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
45
ts/db/documents/classes.source-profile.doc.ts
Normal file
45
ts/db/documents/classes.source-profile.doc.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteSecurity } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourceProfileDoc, SourceProfileDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public security!: IRouteSecurity;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public extendsProfiles?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<SourceProfileDoc | null> {
|
||||||
|
return await SourceProfileDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<SourceProfileDoc[]> {
|
||||||
|
return await SourceProfileDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ts/db/documents/classes.stored-route.doc.ts
Normal file
43
ts/db/documents/classes.stored-route.doc.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public route!: IDcRouterRouteConfig;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public metadata?: IRouteMetadata;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<StoredRouteDoc | null> {
|
||||||
|
return await StoredRouteDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<StoredRouteDoc[]> {
|
||||||
|
return await StoredRouteDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
48
ts/db/documents/classes.target-profile.doc.ts
Normal file
48
ts/db/documents/classes.target-profile.doc.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { ITargetProfileTarget } from '../../../ts_interfaces/data/target-profile.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetProfileDoc, TargetProfileDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domains?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public targets?: ITargetProfileTarget[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public routeRefs?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<TargetProfileDoc | null> {
|
||||||
|
return await TargetProfileDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<TargetProfileDoc[]> {
|
||||||
|
return await TargetProfileDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ts/db/documents/classes.vlan-mappings.doc.ts
Normal file
32
ts/db/documents/classes.vlan-mappings.doc.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
export interface IMacVlanMapping {
|
||||||
|
mac: string;
|
||||||
|
vlan: number;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VlanMappingsDoc extends plugins.smartdata.SmartDataDbDoc<VlanMappingsDoc, VlanMappingsDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public configId: string = 'vlan-mappings';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public mappings!: IMacVlanMapping[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.mappings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async load(): Promise<VlanMappingsDoc | null> {
|
||||||
|
return await VlanMappingsDoc.getInstance({ configId: 'vlan-mappings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
70
ts/db/documents/classes.vpn-client.doc.ts
Normal file
70
ts/db/documents/classes.vpn-client.doc.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc, VpnClientDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public clientId!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public targetProfileIds?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public assignedIp?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPrivateKey?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public destinationAllowList?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public destinationBlockList?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public useHostIp?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public useDhcp?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public staticIp?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public forceVlan?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public vlanId?: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<VpnClientDoc[]> {
|
||||||
|
return await VpnClientDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ts/db/documents/classes.vpn-server-keys.doc.ts
Normal file
31
ts/db/documents/classes.vpn-server-keys.doc.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class VpnServerKeysDoc extends plugins.smartdata.SmartDataDbDoc<VpnServerKeysDoc, VpnServerKeysDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public configId: string = 'vpn-server-keys';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePrivateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public noisePublicKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPrivateKey!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public wgPublicKey!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async load(): Promise<VpnServerKeysDoc | null> {
|
||||||
|
return await VpnServerKeysDoc.getInstance({ configId: 'vpn-server-keys' });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
ts/db/documents/index.ts
Normal file
35
ts/db/documents/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Cached/TTL document classes
|
||||||
|
export * from './classes.cached.email.js';
|
||||||
|
export * from './classes.cached.ip.reputation.js';
|
||||||
|
|
||||||
|
// Config document classes
|
||||||
|
export * from './classes.stored-route.doc.js';
|
||||||
|
export * from './classes.route-override.doc.js';
|
||||||
|
export * from './classes.api-token.doc.js';
|
||||||
|
export * from './classes.source-profile.doc.js';
|
||||||
|
export * from './classes.target-profile.doc.js';
|
||||||
|
export * from './classes.network-target.doc.js';
|
||||||
|
|
||||||
|
// VPN document classes
|
||||||
|
export * from './classes.vpn-server-keys.doc.js';
|
||||||
|
export * from './classes.vpn-client.doc.js';
|
||||||
|
|
||||||
|
// Certificate document classes
|
||||||
|
export * from './classes.acme-cert.doc.js';
|
||||||
|
export * from './classes.proxy-cert.doc.js';
|
||||||
|
export * from './classes.cert-backoff.doc.js';
|
||||||
|
|
||||||
|
// Remote ingress document classes
|
||||||
|
export * from './classes.remote-ingress-edge.doc.js';
|
||||||
|
|
||||||
|
// RADIUS document classes
|
||||||
|
export * from './classes.vlan-mappings.doc.js';
|
||||||
|
export * from './classes.accounting-session.doc.js';
|
||||||
|
|
||||||
|
// DNS / Domain management document classes
|
||||||
|
export * from './classes.dns-provider.doc.js';
|
||||||
|
export * from './classes.domain.doc.js';
|
||||||
|
export * from './classes.dns-record.doc.js';
|
||||||
|
|
||||||
|
// ACME configuration (singleton)
|
||||||
|
export * from './classes.acme-config.doc.js';
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
// Core cache infrastructure
|
// Unified database manager
|
||||||
export * from './classes.cachedb.js';
|
export * from './classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
// TTL base class and constants
|
||||||
export * from './classes.cached.document.js';
|
export * from './classes.cached.document.js';
|
||||||
|
|
||||||
|
// Cache cleaner
|
||||||
export * from './classes.cache.cleaner.js';
|
export * from './classes.cache.cleaner.js';
|
||||||
|
|
||||||
// Document classes
|
// Document classes
|
||||||
2
ts/dns/index.ts
Normal file
2
ts/dns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './manager.dns.js';
|
||||||
|
export * from './providers/index.js';
|
||||||
869
ts/dns/manager.dns.ts
Normal file
869
ts/dns/manager.dns.ts
Normal file
@@ -0,0 +1,869 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import {
|
||||||
|
DnsProviderDoc,
|
||||||
|
DomainDoc,
|
||||||
|
DnsRecordDoc,
|
||||||
|
} from '../db/documents/index.js';
|
||||||
|
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||||
|
import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js';
|
||||||
|
import { createDnsProvider } from './providers/factory.js';
|
||||||
|
import type {
|
||||||
|
TDnsRecordType,
|
||||||
|
TDnsRecordSource,
|
||||||
|
} from '../../ts_interfaces/data/dns-record.js';
|
||||||
|
import type {
|
||||||
|
TDnsProviderType,
|
||||||
|
TDnsProviderCredentials,
|
||||||
|
IDnsProviderPublic,
|
||||||
|
IProviderDomainListing,
|
||||||
|
} from '../../ts_interfaces/data/dns-provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DnsManager — owns runtime DNS state on top of the embedded DnsServer.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Load Domain/DnsRecord docs from the DB on start
|
||||||
|
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
|
||||||
|
* - Register manual-domain records with smartdns.DnsServer at startup
|
||||||
|
* - Provide CRUD methods used by OpsServer handlers (manual domains hit smartdns,
|
||||||
|
* provider domains hit the provider API)
|
||||||
|
* - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy()
|
||||||
|
*
|
||||||
|
* Provider-managed domains are NEVER served from the embedded DnsServer — the
|
||||||
|
* provider stays authoritative. We only mirror their records locally for the UI
|
||||||
|
* and to track providerRecordIds for updates / deletes.
|
||||||
|
*/
|
||||||
|
export class DnsManager {
|
||||||
|
/**
|
||||||
|
* Reference to the active smartdns DnsServer (set by DcRouter once it exists).
|
||||||
|
* May be undefined if dnsScopes/dnsNsDomains aren't configured.
|
||||||
|
*/
|
||||||
|
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached provider clients, keyed by DnsProviderDoc.id.
|
||||||
|
* Created lazily when a provider is first needed.
|
||||||
|
*/
|
||||||
|
private providerClients = new Map<string, IDnsProviderClient>();
|
||||||
|
|
||||||
|
constructor(private options: IDcRouterOptions) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Lifecycle
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from DcRouter after DcRouterDb is up. Performs first-boot seeding
|
||||||
|
* from legacy constructor config if (and only if) the DB is empty.
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
logger.log('info', 'DnsManager: starting');
|
||||||
|
await this.seedFromConstructorConfigIfEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
this.providerClients.clear();
|
||||||
|
this.dnsServer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire the embedded DnsServer instance after it has been created by
|
||||||
|
* DcRouter.setupDnsWithSocketHandler(). After this, manual records loaded
|
||||||
|
* from the DB are registered with the server.
|
||||||
|
*/
|
||||||
|
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
|
||||||
|
this.dnsServer = dnsServer;
|
||||||
|
await this.applyManualDomainsToDnsServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// First-boot seeding
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
|
||||||
|
* seed them as `source: 'manual'` records. On subsequent boots (DB has
|
||||||
|
* entries), constructor config is ignored with a warning.
|
||||||
|
*/
|
||||||
|
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
|
||||||
|
const existingDomains = await DomainDoc.findAll();
|
||||||
|
const hasLegacyConfig =
|
||||||
|
(this.options.dnsScopes && this.options.dnsScopes.length > 0) ||
|
||||||
|
(this.options.dnsRecords && this.options.dnsRecords.length > 0);
|
||||||
|
|
||||||
|
if (existingDomains.length > 0) {
|
||||||
|
if (hasLegacyConfig) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
|
||||||
|
'Manage DNS via the Domains UI instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasLegacyConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const seededDomains = new Map<string, DomainDoc>();
|
||||||
|
|
||||||
|
// Create one DomainDoc per dnsScope (these are the authoritative zones)
|
||||||
|
for (const scope of this.options.dnsScopes ?? []) {
|
||||||
|
const domain = new DomainDoc();
|
||||||
|
domain.id = plugins.uuid.v4();
|
||||||
|
domain.name = scope.toLowerCase();
|
||||||
|
domain.source = 'manual';
|
||||||
|
domain.authoritative = true;
|
||||||
|
domain.createdAt = now;
|
||||||
|
domain.updatedAt = now;
|
||||||
|
domain.createdBy = 'seed';
|
||||||
|
await domain.save();
|
||||||
|
seededDomains.set(domain.name, domain);
|
||||||
|
logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map each legacy dnsRecord to its parent DomainDoc
|
||||||
|
for (const rec of this.options.dnsRecords ?? []) {
|
||||||
|
const parent = this.findParentDomain(rec.name, seededDomains);
|
||||||
|
if (!parent) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = new DnsRecordDoc();
|
||||||
|
record.id = plugins.uuid.v4();
|
||||||
|
record.domainId = parent.id;
|
||||||
|
record.name = rec.name.toLowerCase();
|
||||||
|
record.type = rec.type as TDnsRecordType;
|
||||||
|
record.value = rec.value;
|
||||||
|
record.ttl = rec.ttl ?? 300;
|
||||||
|
record.source = 'manual';
|
||||||
|
record.createdAt = now;
|
||||||
|
record.updatedAt = now;
|
||||||
|
record.createdBy = 'seed';
|
||||||
|
await record.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findParentDomain(
|
||||||
|
recordName: string,
|
||||||
|
domains: Map<string, DomainDoc>,
|
||||||
|
): DomainDoc | null {
|
||||||
|
const lower = recordName.toLowerCase().replace(/^\*\./, '');
|
||||||
|
let candidate: DomainDoc | null = null;
|
||||||
|
for (const [name, doc] of domains) {
|
||||||
|
if (lower === name || lower.endsWith(`.${name}`)) {
|
||||||
|
if (!candidate || name.length > candidate.name.length) {
|
||||||
|
candidate = doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Manual-domain DnsServer wiring
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all manual-domain records from the DB with the embedded DnsServer.
|
||||||
|
* Called once after attachDnsServer().
|
||||||
|
*/
|
||||||
|
private async applyManualDomainsToDnsServer(): Promise<void> {
|
||||||
|
if (!this.dnsServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allDomains = await DomainDoc.findAll();
|
||||||
|
const manualDomains = allDomains.filter((d) => d.source === 'manual');
|
||||||
|
let registered = 0;
|
||||||
|
for (const domain of manualDomains) {
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(domain.id);
|
||||||
|
for (const rec of records) {
|
||||||
|
this.registerRecordWithDnsServer(rec);
|
||||||
|
registered++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.log('info', `DnsManager: registered ${registered} manual DNS record(s) from DB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a single record with the embedded DnsServer. The handler closure
|
||||||
|
* captures the record fields, so updates require a re-register cycle.
|
||||||
|
*/
|
||||||
|
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
|
||||||
|
if (!this.dnsServer) return;
|
||||||
|
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
|
||||||
|
if (question.name === rec.name && question.type === rec.type) {
|
||||||
|
return {
|
||||||
|
name: rec.name,
|
||||||
|
type: rec.type,
|
||||||
|
class: 'IN',
|
||||||
|
ttl: rec.ttl,
|
||||||
|
data: this.parseRecordData(rec.type, rec.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRecordData(type: TDnsRecordType, value: string): any {
|
||||||
|
switch (type) {
|
||||||
|
case 'A':
|
||||||
|
case 'AAAA':
|
||||||
|
case 'CNAME':
|
||||||
|
case 'TXT':
|
||||||
|
case 'NS':
|
||||||
|
case 'CAA':
|
||||||
|
return value;
|
||||||
|
case 'MX': {
|
||||||
|
const [priorityStr, exchange] = value.split(' ');
|
||||||
|
return { priority: parseInt(priorityStr, 10), exchange };
|
||||||
|
}
|
||||||
|
case 'SOA': {
|
||||||
|
const parts = value.split(' ');
|
||||||
|
return {
|
||||||
|
mname: parts[0],
|
||||||
|
rname: parts[1],
|
||||||
|
serial: parseInt(parts[2], 10),
|
||||||
|
refresh: parseInt(parts[3], 10),
|
||||||
|
retry: parseInt(parts[4], 10),
|
||||||
|
expire: parseInt(parts[5], 10),
|
||||||
|
minimum: parseInt(parts[6], 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Provider lookup (used by ACME DNS-01 + record CRUD)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the provider client for a given DnsProviderDoc id, instantiating
|
||||||
|
* (and caching) it on first use.
|
||||||
|
*/
|
||||||
|
public async getProviderClientById(providerId: string): Promise<IDnsProviderClient | null> {
|
||||||
|
const cached = this.providerClients.get(providerId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const doc = await DnsProviderDoc.findById(providerId);
|
||||||
|
if (!doc) return null;
|
||||||
|
const client = createDnsProvider(doc.type, doc.credentials);
|
||||||
|
this.providerClients.set(providerId, client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the IDnsProviderClient that owns the given FQDN (by walking up its
|
||||||
|
* labels to find a matching DomainDoc with `source === 'provider'`).
|
||||||
|
* Returns null if no provider claims this FQDN.
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - SmartAcme DNS-01 wiring in setupSmartProxy()
|
||||||
|
* - DnsRecordHandler when creating provider records
|
||||||
|
*/
|
||||||
|
public async getProviderClientForDomain(fqdn: string): Promise<IDnsProviderClient | null> {
|
||||||
|
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
|
||||||
|
const allDomains = await DomainDoc.findAll();
|
||||||
|
const providerDomains = allDomains
|
||||||
|
.filter((d) => d.source === 'provider' && d.providerId)
|
||||||
|
// longest-match wins
|
||||||
|
.sort((a, b) => b.name.length - a.name.length);
|
||||||
|
|
||||||
|
for (const domain of providerDomains) {
|
||||||
|
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
|
||||||
|
return this.getProviderClientById(domain.providerId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
|
||||||
|
* to decide whether to wire SmartAcme with a DNS-01 handler.
|
||||||
|
*/
|
||||||
|
public async hasAcmeCapableProvider(): Promise<boolean> {
|
||||||
|
const providers = await DnsProviderDoc.findAll();
|
||||||
|
return providers.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an IConvenientDnsProvider that dispatches each ACME challenge to
|
||||||
|
* the right provider client (whichever provider type owns the parent zone),
|
||||||
|
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
|
||||||
|
* interface, so any registered provider implementation works.
|
||||||
|
* Returned object plugs directly into smartacme's Dns01Handler.
|
||||||
|
*/
|
||||||
|
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
|
||||||
|
const self = this;
|
||||||
|
const adapter = {
|
||||||
|
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||||
|
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(
|
||||||
|
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
|
||||||
|
'Add one in the Domains > Providers UI before issuing certificates.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Clean any leftover challenge records first to avoid duplicates.
|
||||||
|
try {
|
||||||
|
const existing = await client.listRecords(dnsChallenge.hostName);
|
||||||
|
for (const r of existing) {
|
||||||
|
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
||||||
|
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
await client.createRecord(dnsChallenge.hostName, {
|
||||||
|
name: dnsChallenge.hostName,
|
||||||
|
type: 'TXT',
|
||||||
|
value: dnsChallenge.challenge,
|
||||||
|
ttl: 120,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||||
|
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
||||||
|
if (!client) {
|
||||||
|
// The domain may have been removed; nothing to clean up.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const existing = await client.listRecords(dnsChallenge.hostName);
|
||||||
|
for (const r of existing) {
|
||||||
|
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
||||||
|
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async isDomainSupported(domain: string): Promise<boolean> {
|
||||||
|
const client = await self.getProviderClientForDomain(domain);
|
||||||
|
return !!client;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Provider CRUD (used by DnsProviderHandler)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
public async listProviders(): Promise<IDnsProviderPublic[]> {
|
||||||
|
const docs = await DnsProviderDoc.findAll();
|
||||||
|
return docs.map((d) => this.toPublicProvider(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProvider(id: string): Promise<IDnsProviderPublic | null> {
|
||||||
|
const doc = await DnsProviderDoc.findById(id);
|
||||||
|
return doc ? this.toPublicProvider(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createProvider(args: {
|
||||||
|
name: string;
|
||||||
|
type: TDnsProviderType;
|
||||||
|
credentials: TDnsProviderCredentials;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
const doc = new DnsProviderDoc();
|
||||||
|
doc.id = plugins.uuid.v4();
|
||||||
|
doc.name = args.name;
|
||||||
|
doc.type = args.type;
|
||||||
|
doc.credentials = args.credentials;
|
||||||
|
doc.status = 'untested';
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.createdBy = args.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
return doc.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProvider(
|
||||||
|
id: string,
|
||||||
|
args: { name?: string; credentials?: TDnsProviderCredentials },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const doc = await DnsProviderDoc.findById(id);
|
||||||
|
if (!doc) return false;
|
||||||
|
if (args.name !== undefined) doc.name = args.name;
|
||||||
|
if (args.credentials !== undefined) {
|
||||||
|
doc.credentials = args.credentials;
|
||||||
|
doc.status = 'untested';
|
||||||
|
doc.lastError = undefined;
|
||||||
|
// Invalidate cached client so the next use re-instantiates with the new credentials.
|
||||||
|
this.providerClients.delete(id);
|
||||||
|
}
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteProvider(id: string, force: boolean): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const doc = await DnsProviderDoc.findById(id);
|
||||||
|
if (!doc) return { success: false, message: 'Provider not found' };
|
||||||
|
const linkedDomains = await DomainDoc.findByProviderId(id);
|
||||||
|
if (linkedDomains.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Provider is referenced by ${linkedDomains.length} domain(s). Pass force: true to delete anyway.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// If forcing, also delete the linked domains and their records.
|
||||||
|
if (force) {
|
||||||
|
for (const domain of linkedDomains) {
|
||||||
|
await this.deleteDomain(domain.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await doc.delete();
|
||||||
|
this.providerClients.delete(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testProvider(id: string): Promise<{ ok: boolean; error?: string; testedAt: number }> {
|
||||||
|
const doc = await DnsProviderDoc.findById(id);
|
||||||
|
if (!doc) {
|
||||||
|
return { ok: false, error: 'Provider not found', testedAt: Date.now() };
|
||||||
|
}
|
||||||
|
const client = createDnsProvider(doc.type, doc.credentials);
|
||||||
|
const result = await client.testConnection();
|
||||||
|
doc.status = result.ok ? 'ok' : 'error';
|
||||||
|
doc.lastTestedAt = Date.now();
|
||||||
|
doc.lastError = result.ok ? undefined : result.error;
|
||||||
|
await doc.save();
|
||||||
|
if (result.ok) {
|
||||||
|
this.providerClients.set(id, client); // cache the working client
|
||||||
|
}
|
||||||
|
return { ok: result.ok, error: result.error, testedAt: doc.lastTestedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listProviderDomains(providerId: string): Promise<IProviderDomainListing[]> {
|
||||||
|
const client = await this.getProviderClientById(providerId);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Provider not found');
|
||||||
|
}
|
||||||
|
return await client.listDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Domain CRUD (used by DomainHandler)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
public async listDomains(): Promise<DomainDoc[]> {
|
||||||
|
return await DomainDoc.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDomain(id: string): Promise<DomainDoc | null> {
|
||||||
|
return await DomainDoc.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a manual (authoritative) domain. dcrouter will serve DNS records
|
||||||
|
* for this domain via the embedded smartdns.DnsServer.
|
||||||
|
*/
|
||||||
|
public async createManualDomain(args: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
const doc = new DomainDoc();
|
||||||
|
doc.id = plugins.uuid.v4();
|
||||||
|
doc.name = args.name.toLowerCase();
|
||||||
|
doc.source = 'manual';
|
||||||
|
doc.authoritative = true;
|
||||||
|
doc.description = args.description;
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.createdBy = args.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
return doc.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import one or more domains from a provider, pulling all of their DNS
|
||||||
|
* records into local DnsRecordDocs.
|
||||||
|
*/
|
||||||
|
public async importDomainsFromProvider(args: {
|
||||||
|
providerId: string;
|
||||||
|
domainNames: string[];
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const provider = await DnsProviderDoc.findById(args.providerId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Provider not found');
|
||||||
|
}
|
||||||
|
const client = await this.getProviderClientById(args.providerId);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Failed to instantiate provider client');
|
||||||
|
}
|
||||||
|
const allProviderDomains = await client.listDomains();
|
||||||
|
const importedIds: string[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const wantedName of args.domainNames) {
|
||||||
|
const lower = wantedName.toLowerCase();
|
||||||
|
const listing = allProviderDomains.find((d) => d.name.toLowerCase() === lower);
|
||||||
|
if (!listing) {
|
||||||
|
logger.log('warn', `DnsManager: import skipped — provider does not list domain ${wantedName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip if already imported
|
||||||
|
const existing = await DomainDoc.findByName(lower);
|
||||||
|
if (existing) {
|
||||||
|
logger.log('warn', `DnsManager: domain ${wantedName} already imported — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = new DomainDoc();
|
||||||
|
domain.id = plugins.uuid.v4();
|
||||||
|
domain.name = lower;
|
||||||
|
domain.source = 'provider';
|
||||||
|
domain.providerId = args.providerId;
|
||||||
|
domain.authoritative = false;
|
||||||
|
domain.nameservers = listing.nameservers;
|
||||||
|
domain.externalZoneId = listing.externalId;
|
||||||
|
domain.lastSyncedAt = now;
|
||||||
|
domain.createdAt = now;
|
||||||
|
domain.updatedAt = now;
|
||||||
|
domain.createdBy = args.createdBy;
|
||||||
|
await domain.save();
|
||||||
|
importedIds.push(domain.id);
|
||||||
|
|
||||||
|
// Pull records for the imported domain
|
||||||
|
try {
|
||||||
|
const providerRecords = await client.listRecords(lower);
|
||||||
|
for (const pr of providerRecords) {
|
||||||
|
await this.createSyncedRecord(domain.id, pr, args.createdBy);
|
||||||
|
}
|
||||||
|
logger.log('info', `DnsManager: imported ${providerRecords.length} record(s) for ${lower}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `DnsManager: failed to import records for ${lower}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return importedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateDomain(id: string, args: { description?: string }): Promise<boolean> {
|
||||||
|
const doc = await DomainDoc.findById(id);
|
||||||
|
if (!doc) return false;
|
||||||
|
if (args.description !== undefined) doc.description = args.description;
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a domain and all of its DNS records. For provider domains, only
|
||||||
|
* removes the local mirror — does NOT touch the provider.
|
||||||
|
* For manual domains, also unregisters records from the embedded DnsServer.
|
||||||
|
*
|
||||||
|
* Note: smartdns has no public unregister-by-name API in the version pinned
|
||||||
|
* here, so manual record deletes only take effect after a restart. The DB
|
||||||
|
* is the source of truth and the next start will not register the deleted
|
||||||
|
* record.
|
||||||
|
*/
|
||||||
|
public async deleteDomain(id: string): Promise<boolean> {
|
||||||
|
const doc = await DomainDoc.findById(id);
|
||||||
|
if (!doc) return false;
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(id);
|
||||||
|
for (const r of records) {
|
||||||
|
await r.delete();
|
||||||
|
}
|
||||||
|
await doc.delete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-resync a provider-managed domain: re-pull all records from the
|
||||||
|
* provider API, replacing the cached DnsRecordDocs.
|
||||||
|
*/
|
||||||
|
public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; message?: string }> {
|
||||||
|
const doc = await DomainDoc.findById(id);
|
||||||
|
if (!doc) return { success: false, message: 'Domain not found' };
|
||||||
|
if (doc.source !== 'provider' || !doc.providerId) {
|
||||||
|
return { success: false, message: 'Domain is not provider-managed' };
|
||||||
|
}
|
||||||
|
const client = await this.getProviderClientById(doc.providerId);
|
||||||
|
if (!client) {
|
||||||
|
return { success: false, message: 'Provider client unavailable' };
|
||||||
|
}
|
||||||
|
const providerRecords = await client.listRecords(doc.name);
|
||||||
|
|
||||||
|
// Drop existing records and replace
|
||||||
|
const existing = await DnsRecordDoc.findByDomainId(id);
|
||||||
|
for (const r of existing) {
|
||||||
|
await r.delete();
|
||||||
|
}
|
||||||
|
for (const pr of providerRecords) {
|
||||||
|
await this.createSyncedRecord(id, pr, doc.createdBy);
|
||||||
|
}
|
||||||
|
doc.lastSyncedAt = Date.now();
|
||||||
|
doc.updatedAt = doc.lastSyncedAt;
|
||||||
|
await doc.save();
|
||||||
|
return { success: true, recordCount: providerRecords.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Record CRUD (used by DnsRecordHandler)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
public async listRecordsForDomain(domainId: string): Promise<DnsRecordDoc[]> {
|
||||||
|
return await DnsRecordDoc.findByDomainId(domainId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecord(id: string): Promise<DnsRecordDoc | null> {
|
||||||
|
return await DnsRecordDoc.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createRecord(args: {
|
||||||
|
domainId: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<{ success: boolean; id?: string; message?: string }> {
|
||||||
|
const domain = await DomainDoc.findById(args.domainId);
|
||||||
|
if (!domain) return { success: false, message: 'Domain not found' };
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const doc = new DnsRecordDoc();
|
||||||
|
doc.id = plugins.uuid.v4();
|
||||||
|
doc.domainId = args.domainId;
|
||||||
|
doc.name = args.name.toLowerCase();
|
||||||
|
doc.type = args.type;
|
||||||
|
doc.value = args.value;
|
||||||
|
doc.ttl = args.ttl ?? 300;
|
||||||
|
if (args.proxied !== undefined) doc.proxied = args.proxied;
|
||||||
|
doc.source = 'manual';
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.createdBy = args.createdBy;
|
||||||
|
|
||||||
|
if (domain.source === 'provider') {
|
||||||
|
// Push to provider first; only persist locally on success
|
||||||
|
if (!domain.providerId) {
|
||||||
|
return { success: false, message: 'Provider domain has no providerId' };
|
||||||
|
}
|
||||||
|
const client = await this.getProviderClientById(domain.providerId);
|
||||||
|
if (!client) return { success: false, message: 'Provider client unavailable' };
|
||||||
|
try {
|
||||||
|
const created = await client.createRecord(domain.name, {
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
value: doc.value,
|
||||||
|
ttl: doc.ttl,
|
||||||
|
proxied: doc.proxied,
|
||||||
|
});
|
||||||
|
doc.providerRecordId = created.providerRecordId;
|
||||||
|
doc.source = 'synced';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Manual / authoritative — register with embedded DnsServer immediately
|
||||||
|
this.registerRecordWithDnsServer(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
return { success: true, id: doc.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRecord(args: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const doc = await DnsRecordDoc.findById(args.id);
|
||||||
|
if (!doc) return { success: false, message: 'Record not found' };
|
||||||
|
const domain = await DomainDoc.findById(doc.domainId);
|
||||||
|
if (!domain) return { success: false, message: 'Parent domain not found' };
|
||||||
|
|
||||||
|
if (args.name !== undefined) doc.name = args.name.toLowerCase();
|
||||||
|
if (args.value !== undefined) doc.value = args.value;
|
||||||
|
if (args.ttl !== undefined) doc.ttl = args.ttl;
|
||||||
|
if (args.proxied !== undefined) doc.proxied = args.proxied;
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
|
||||||
|
if (domain.source === 'provider') {
|
||||||
|
if (!domain.providerId || !doc.providerRecordId) {
|
||||||
|
return { success: false, message: 'Provider record metadata missing' };
|
||||||
|
}
|
||||||
|
const client = await this.getProviderClientById(domain.providerId);
|
||||||
|
if (!client) return { success: false, message: 'Provider client unavailable' };
|
||||||
|
try {
|
||||||
|
await client.updateRecord(domain.name, doc.providerRecordId, {
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
value: doc.value,
|
||||||
|
ttl: doc.ttl,
|
||||||
|
proxied: doc.proxied,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Re-register the manual record so the new closure picks up the updated fields
|
||||||
|
this.registerRecordWithDnsServer(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const doc = await DnsRecordDoc.findById(id);
|
||||||
|
if (!doc) return { success: false, message: 'Record not found' };
|
||||||
|
const domain = await DomainDoc.findById(doc.domainId);
|
||||||
|
if (!domain) return { success: false, message: 'Parent domain not found' };
|
||||||
|
|
||||||
|
if (domain.source === 'provider') {
|
||||||
|
if (domain.providerId && doc.providerRecordId) {
|
||||||
|
const client = await this.getProviderClientById(domain.providerId);
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.deleteRecord(domain.name, doc.providerRecordId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: `Provider rejected delete: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For manual records: smartdns has no unregister API in the pinned version,
|
||||||
|
// so the record stays served until the next restart. The DB delete still
|
||||||
|
// takes effect — on restart, the record will not be re-registered.
|
||||||
|
|
||||||
|
await doc.delete();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Internal helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
private async createSyncedRecord(
|
||||||
|
domainId: string,
|
||||||
|
pr: IProviderRecord,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const doc = new DnsRecordDoc();
|
||||||
|
doc.id = plugins.uuid.v4();
|
||||||
|
doc.domainId = domainId;
|
||||||
|
doc.name = pr.name.toLowerCase();
|
||||||
|
doc.type = pr.type;
|
||||||
|
doc.value = pr.value;
|
||||||
|
doc.ttl = pr.ttl;
|
||||||
|
if (pr.proxied !== undefined) doc.proxied = pr.proxied;
|
||||||
|
doc.source = 'synced';
|
||||||
|
doc.providerRecordId = pr.providerRecordId;
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.createdBy = createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DnsProviderDoc to its public, secret-stripped representation
|
||||||
|
* for the OpsServer API.
|
||||||
|
*/
|
||||||
|
public toPublicProvider(doc: DnsProviderDoc): IDnsProviderPublic {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
status: doc.status,
|
||||||
|
lastTestedAt: doc.lastTestedAt,
|
||||||
|
lastError: doc.lastError,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
hasCredentials: !!doc.credentials,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DomainDoc to its plain interface representation.
|
||||||
|
*/
|
||||||
|
public toPublicDomain(doc: DomainDoc): {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source: 'manual' | 'provider';
|
||||||
|
providerId?: string;
|
||||||
|
authoritative: boolean;
|
||||||
|
nameservers?: string[];
|
||||||
|
externalZoneId?: string;
|
||||||
|
lastSyncedAt?: number;
|
||||||
|
description?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
source: doc.source,
|
||||||
|
providerId: doc.providerId,
|
||||||
|
authoritative: doc.authoritative,
|
||||||
|
nameservers: doc.nameservers,
|
||||||
|
externalZoneId: doc.externalZoneId,
|
||||||
|
lastSyncedAt: doc.lastSyncedAt,
|
||||||
|
description: doc.description,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DnsRecordDoc to its plain interface representation.
|
||||||
|
*/
|
||||||
|
public toPublicRecord(doc: DnsRecordDoc): {
|
||||||
|
id: string;
|
||||||
|
domainId: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
source: TDnsRecordSource;
|
||||||
|
providerRecordId?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
domainId: doc.domainId,
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
value: doc.value,
|
||||||
|
ttl: doc.ttl,
|
||||||
|
proxied: doc.proxied,
|
||||||
|
source: doc.source,
|
||||||
|
providerRecordId: doc.providerRecordId,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
131
ts/dns/providers/cloudflare.provider.ts
Normal file
131
ts/dns/providers/cloudflare.provider.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import type {
|
||||||
|
IDnsProviderClient,
|
||||||
|
IConnectionTestResult,
|
||||||
|
IProviderRecord,
|
||||||
|
IProviderRecordInput,
|
||||||
|
} from './interfaces.js';
|
||||||
|
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare implementation of IDnsProviderClient.
|
||||||
|
*
|
||||||
|
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
|
||||||
|
* an internal record id, which we surface as `providerRecordId` so the rest
|
||||||
|
* of the system can issue updates and deletes without ambiguity (Cloudflare
|
||||||
|
* can have multiple records of the same name+type).
|
||||||
|
*/
|
||||||
|
export class CloudflareDnsProvider implements IDnsProviderClient {
|
||||||
|
private cfAccount: plugins.cloudflare.CloudflareAccount;
|
||||||
|
|
||||||
|
constructor(apiToken: string) {
|
||||||
|
if (!apiToken) {
|
||||||
|
throw new Error('CloudflareDnsProvider: apiToken is required');
|
||||||
|
}
|
||||||
|
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testConnection(): Promise<IConnectionTestResult> {
|
||||||
|
try {
|
||||||
|
// Listing zones is the lightest-weight call that proves the token works.
|
||||||
|
await this.cfAccount.zoneManager.listZones();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listDomains(): Promise<IProviderDomainListing[]> {
|
||||||
|
const zones = await this.cfAccount.zoneManager.listZones();
|
||||||
|
return zones.map((zone) => ({
|
||||||
|
name: zone.name,
|
||||||
|
externalId: zone.id,
|
||||||
|
nameservers: zone.name_servers ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listRecords(domain: string): Promise<IProviderRecord[]> {
|
||||||
|
const records = await this.cfAccount.recordManager.listRecords(domain);
|
||||||
|
return records
|
||||||
|
.filter((r) => this.isSupportedType(r.type))
|
||||||
|
.map((r) => ({
|
||||||
|
providerRecordId: r.id,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type as TDnsRecordType,
|
||||||
|
value: r.content,
|
||||||
|
ttl: r.ttl,
|
||||||
|
proxied: r.proxied,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createRecord(
|
||||||
|
domain: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
const apiRecord: any = {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.value,
|
||||||
|
ttl: record.ttl ?? 1, // 1 = automatic
|
||||||
|
};
|
||||||
|
if (record.proxied !== undefined) {
|
||||||
|
apiRecord.proxied = record.proxied;
|
||||||
|
}
|
||||||
|
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
|
||||||
|
return {
|
||||||
|
providerRecordId: created.id,
|
||||||
|
name: created.name,
|
||||||
|
type: created.type as TDnsRecordType,
|
||||||
|
value: created.content,
|
||||||
|
ttl: created.ttl,
|
||||||
|
proxied: created.proxied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRecord(
|
||||||
|
domain: string,
|
||||||
|
providerRecordId: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
const apiRecord: any = {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.value,
|
||||||
|
ttl: record.ttl ?? 1,
|
||||||
|
};
|
||||||
|
if (record.proxied !== undefined) {
|
||||||
|
apiRecord.proxied = record.proxied;
|
||||||
|
}
|
||||||
|
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
|
||||||
|
providerRecordId,
|
||||||
|
apiRecord,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
providerRecordId: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
type: updated.type as TDnsRecordType,
|
||||||
|
value: updated.content,
|
||||||
|
ttl: updated.ttl,
|
||||||
|
proxied: updated.proxied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
|
||||||
|
zone_id: zoneId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSupportedType(type: string): boolean {
|
||||||
|
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
ts/dns/providers/factory.ts
Normal file
48
ts/dns/providers/factory.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { IDnsProviderClient } from './interfaces.js';
|
||||||
|
import type {
|
||||||
|
TDnsProviderType,
|
||||||
|
TDnsProviderCredentials,
|
||||||
|
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
import { CloudflareDnsProvider } from './cloudflare.provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
|
||||||
|
*
|
||||||
|
* @throws if the provider type is not supported.
|
||||||
|
*
|
||||||
|
* ## Adding a new provider (e.g. Route53)
|
||||||
|
*
|
||||||
|
* 1. **Type union** — extend `TDnsProviderType` in
|
||||||
|
* `ts_interfaces/data/dns-provider.ts` (e.g. `'cloudflare' | 'route53'`).
|
||||||
|
* 2. **Credentials interface** — add `IRoute53Credentials` and append it to
|
||||||
|
* the `TDnsProviderCredentials` discriminated union.
|
||||||
|
* 3. **Descriptor** — append a new entry to `dnsProviderTypeDescriptors` so
|
||||||
|
* the OpsServer UI picks up the new type and renders the right credential
|
||||||
|
* form fields automatically.
|
||||||
|
* 4. **Provider class** — create `ts/dns/providers/route53.provider.ts`
|
||||||
|
* implementing `IDnsProviderClient`.
|
||||||
|
* 5. **Factory case** — add a new `case 'route53':` below. The
|
||||||
|
* `_exhaustive: never` line will fail to compile until you do.
|
||||||
|
* 6. **Index** — re-export the new class from `ts/dns/providers/index.ts`.
|
||||||
|
*/
|
||||||
|
export function createDnsProvider(
|
||||||
|
type: TDnsProviderType,
|
||||||
|
credentials: TDnsProviderCredentials,
|
||||||
|
): IDnsProviderClient {
|
||||||
|
switch (type) {
|
||||||
|
case 'cloudflare': {
|
||||||
|
if (credentials.type !== 'cloudflare') {
|
||||||
|
throw new Error(
|
||||||
|
`createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new CloudflareDnsProvider(credentials.apiToken);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// If you see a TypeScript error here after extending TDnsProviderType,
|
||||||
|
// add a `case` for the new type above. The `never` enforces exhaustiveness.
|
||||||
|
const _exhaustive: never = type;
|
||||||
|
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
ts/dns/providers/index.ts
Normal file
3
ts/dns/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './interfaces.js';
|
||||||
|
export * from './cloudflare.provider.js';
|
||||||
|
export * from './factory.js';
|
||||||
67
ts/dns/providers/interfaces.ts
Normal file
67
ts/dns/providers/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DNS record as seen at a provider's API. The `providerRecordId` field
|
||||||
|
* is the provider's internal identifier, used for subsequent updates and
|
||||||
|
* deletes (since providers can have multiple records of the same name+type).
|
||||||
|
*/
|
||||||
|
export interface IProviderRecord {
|
||||||
|
providerRecordId: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input shape for creating / updating a DNS record at a provider.
|
||||||
|
*/
|
||||||
|
export interface IProviderRecordInput {
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a connection test against a provider's API.
|
||||||
|
*/
|
||||||
|
export interface IConnectionTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluggable DNS provider client interface. One implementation per provider type
|
||||||
|
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
|
||||||
|
* instantiated by `createDnsProvider()` in factory.ts.
|
||||||
|
*
|
||||||
|
* NOT a smartdata interface — this is the *runtime* client. The persisted
|
||||||
|
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
|
||||||
|
*/
|
||||||
|
export interface IDnsProviderClient {
|
||||||
|
/** Lightweight check that credentials are valid and the API is reachable. */
|
||||||
|
testConnection(): Promise<IConnectionTestResult>;
|
||||||
|
|
||||||
|
/** List all DNS zones visible to this provider account. */
|
||||||
|
listDomains(): Promise<IProviderDomainListing[]>;
|
||||||
|
|
||||||
|
/** List all DNS records for a zone (FQDN). */
|
||||||
|
listRecords(domain: string): Promise<IProviderRecord[]>;
|
||||||
|
|
||||||
|
/** Create a new DNS record at the provider; returns the created record (with id). */
|
||||||
|
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
|
||||||
|
|
||||||
|
/** Update an existing record by provider id; returns the updated record. */
|
||||||
|
updateRecord(
|
||||||
|
domain: string,
|
||||||
|
providerRecordId: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord>;
|
||||||
|
|
||||||
|
/** Delete a record by provider id. */
|
||||||
|
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -227,7 +227,7 @@ export class PlatformError extends Error {
|
|||||||
const { retry } = this.context;
|
const { retry } = this.context;
|
||||||
if (!retry) return false;
|
if (!retry) return false;
|
||||||
|
|
||||||
return retry.currentRetry < retry.maxRetries;
|
return (retry.currentRetry ?? 0) < (retry.maxRetries ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,6 +35,6 @@ export const runCli = async () => {
|
|||||||
await dcRouter.stop();
|
await dcRouter.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
process.on('SIGINT', shutdown);
|
process.once('SIGINT', shutdown);
|
||||||
process.on('SIGTERM', shutdown);
|
process.once('SIGTERM', shutdown);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -296,11 +296,11 @@ export class MetricsManager {
|
|||||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||||
|
|
||||||
if (!proxyMetrics) {
|
if (!proxyMetrics) {
|
||||||
return [];
|
return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||||
const connectionInfo = [];
|
const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
|
||||||
|
|
||||||
for (const [routeName, count] of connectionsByRoute) {
|
for (const [routeName, count] of connectionsByRoute) {
|
||||||
connectionInfo.push({
|
connectionInfo.push({
|
||||||
@@ -558,6 +558,7 @@ export class MetricsManager {
|
|||||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
requestsTotal: 0,
|
requestsTotal: 0,
|
||||||
|
backends: [] as Array<any>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,6 +591,114 @@ 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
|
||||||
|
const backendMetrics = proxyMetrics.backends.byBackend();
|
||||||
|
const protocolCache = proxyMetrics.backends.detectedProtocols();
|
||||||
|
|
||||||
|
// Group protocol cache entries by host:port so we can match them to backend metrics.
|
||||||
|
// The protocol cache is keyed by (host, port, domain) in Rust, so the same host:port
|
||||||
|
// can have multiple entries for different domains.
|
||||||
|
const cacheByBackend = new Map<string, (typeof protocolCache)[number][]>();
|
||||||
|
for (const entry of protocolCache) {
|
||||||
|
const backendKey = `${entry.host}:${entry.port}`;
|
||||||
|
let entries = cacheByBackend.get(backendKey);
|
||||||
|
if (!entries) {
|
||||||
|
entries = [];
|
||||||
|
cacheByBackend.set(backendKey, entries);
|
||||||
|
}
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backends: Array<any> = [];
|
||||||
|
const seenCacheKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, bm] of backendMetrics) {
|
||||||
|
const cacheEntries = cacheByBackend.get(key);
|
||||||
|
if (!cacheEntries || cacheEntries.length === 0) {
|
||||||
|
// No protocol cache entry — emit one row with backend metrics only
|
||||||
|
backends.push({
|
||||||
|
backend: key,
|
||||||
|
domain: null,
|
||||||
|
protocol: bm.protocol,
|
||||||
|
activeConnections: bm.activeConnections,
|
||||||
|
totalConnections: bm.totalConnections,
|
||||||
|
connectErrors: bm.connectErrors,
|
||||||
|
handshakeErrors: bm.handshakeErrors,
|
||||||
|
requestErrors: bm.requestErrors,
|
||||||
|
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||||
|
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||||
|
h2Failures: bm.h2Failures,
|
||||||
|
h2Suppressed: false,
|
||||||
|
h3Suppressed: false,
|
||||||
|
h2CooldownRemainingSecs: null,
|
||||||
|
h3CooldownRemainingSecs: null,
|
||||||
|
h2ConsecutiveFailures: null,
|
||||||
|
h3ConsecutiveFailures: null,
|
||||||
|
h3Port: null,
|
||||||
|
cacheAgeSecs: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// One row per domain, each enriched with the shared backend metrics
|
||||||
|
for (const cache of cacheEntries) {
|
||||||
|
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
|
||||||
|
seenCacheKeys.add(compositeKey);
|
||||||
|
backends.push({
|
||||||
|
backend: key,
|
||||||
|
domain: cache.domain ?? null,
|
||||||
|
protocol: cache.protocol ?? bm.protocol,
|
||||||
|
activeConnections: bm.activeConnections,
|
||||||
|
totalConnections: bm.totalConnections,
|
||||||
|
connectErrors: bm.connectErrors,
|
||||||
|
handshakeErrors: bm.handshakeErrors,
|
||||||
|
requestErrors: bm.requestErrors,
|
||||||
|
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||||
|
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||||
|
h2Failures: bm.h2Failures,
|
||||||
|
h2Suppressed: cache.h2Suppressed,
|
||||||
|
h3Suppressed: cache.h3Suppressed,
|
||||||
|
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
|
||||||
|
h3CooldownRemainingSecs: cache.h3CooldownRemainingSecs,
|
||||||
|
h2ConsecutiveFailures: cache.h2ConsecutiveFailures,
|
||||||
|
h3ConsecutiveFailures: cache.h3ConsecutiveFailures,
|
||||||
|
h3Port: cache.h3Port,
|
||||||
|
cacheAgeSecs: cache.ageSecs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include protocol cache entries with no matching backend metric
|
||||||
|
for (const entry of protocolCache) {
|
||||||
|
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
|
||||||
|
if (!seenCacheKeys.has(compositeKey)) {
|
||||||
|
backends.push({
|
||||||
|
backend: `${entry.host}:${entry.port}`,
|
||||||
|
domain: entry.domain,
|
||||||
|
protocol: entry.protocol,
|
||||||
|
activeConnections: 0,
|
||||||
|
totalConnections: 0,
|
||||||
|
connectErrors: 0,
|
||||||
|
handshakeErrors: 0,
|
||||||
|
requestErrors: 0,
|
||||||
|
avgConnectTimeMs: 0,
|
||||||
|
poolHitRate: 0,
|
||||||
|
h2Failures: 0,
|
||||||
|
h2Suppressed: entry.h2Suppressed,
|
||||||
|
h3Suppressed: entry.h3Suppressed,
|
||||||
|
h2CooldownRemainingSecs: entry.h2CooldownRemainingSecs,
|
||||||
|
h3CooldownRemainingSecs: entry.h3CooldownRemainingSecs,
|
||||||
|
h2ConsecutiveFailures: entry.h2ConsecutiveFailures,
|
||||||
|
h3ConsecutiveFailures: entry.h3ConsecutiveFailures,
|
||||||
|
h3Port: entry.h3Port,
|
||||||
|
cacheAgeSecs: entry.ageSecs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionsByIP,
|
connectionsByIP,
|
||||||
throughputRate,
|
throughputRate,
|
||||||
@@ -599,6 +708,9 @@ export class MetricsManager {
|
|||||||
throughputByIP,
|
throughputByIP,
|
||||||
requestsPerSecond,
|
requestsPerSecond,
|
||||||
requestsTotal,
|
requestsTotal,
|
||||||
|
backends,
|
||||||
|
frontendProtocols,
|
||||||
|
backendProtocols,
|
||||||
};
|
};
|
||||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js'
|
|||||||
|
|
||||||
export class OpsServer {
|
export class OpsServer {
|
||||||
public dcRouterRef: DcRouter;
|
public dcRouterRef: DcRouter;
|
||||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||||
|
|
||||||
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
@@ -17,17 +17,26 @@ export class OpsServer {
|
|||||||
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||||
|
|
||||||
// Handler instances
|
// Handler instances
|
||||||
public adminHandler: handlers.AdminHandler;
|
public adminHandler!: handlers.AdminHandler;
|
||||||
private configHandler: handlers.ConfigHandler;
|
private configHandler!: handlers.ConfigHandler;
|
||||||
private logsHandler: handlers.LogsHandler;
|
private logsHandler!: handlers.LogsHandler;
|
||||||
private securityHandler: handlers.SecurityHandler;
|
private securityHandler!: handlers.SecurityHandler;
|
||||||
private statsHandler: handlers.StatsHandler;
|
private statsHandler!: handlers.StatsHandler;
|
||||||
private radiusHandler: handlers.RadiusHandler;
|
private radiusHandler!: handlers.RadiusHandler;
|
||||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
private emailOpsHandler!: handlers.EmailOpsHandler;
|
||||||
private certificateHandler: handlers.CertificateHandler;
|
private certificateHandler!: handlers.CertificateHandler;
|
||||||
private remoteIngressHandler: handlers.RemoteIngressHandler;
|
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||||
private routeManagementHandler: handlers.RouteManagementHandler;
|
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||||
private apiTokenHandler: handlers.ApiTokenHandler;
|
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||||
|
private vpnHandler!: handlers.VpnHandler;
|
||||||
|
private sourceProfileHandler!: handlers.SourceProfileHandler;
|
||||||
|
private targetProfileHandler!: handlers.TargetProfileHandler;
|
||||||
|
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||||
|
private usersHandler!: handlers.UsersHandler;
|
||||||
|
private dnsProviderHandler!: handlers.DnsProviderHandler;
|
||||||
|
private domainHandler!: handlers.DomainHandler;
|
||||||
|
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||||
|
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -39,7 +48,7 @@ export class OpsServer {
|
|||||||
public async start() {
|
public async start() {
|
||||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||||
domain: 'localhost',
|
domain: 'localhost',
|
||||||
feedMetadata: null,
|
feedMetadata: undefined,
|
||||||
serveDir: paths.distServe,
|
serveDir: paths.distServe,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,6 +95,15 @@ export class OpsServer {
|
|||||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||||
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.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
||||||
|
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
||||||
|
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||||
|
this.usersHandler = new handlers.UsersHandler(this);
|
||||||
|
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
|
||||||
|
this.domainHandler = new handlers.DomainHandler(this);
|
||||||
|
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||||
|
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD handler for the singleton `AcmeConfigDoc`.
|
||||||
|
*
|
||||||
|
* Auth: same dual-mode pattern as other handlers — admin JWT or API token
|
||||||
|
* with `acme-config:read` / `acme-config:write` scope.
|
||||||
|
*/
|
||||||
|
export class AcmeConfigHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get current ACME config
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAcmeConfig>(
|
||||||
|
'getAcmeConfig',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'acme-config:read');
|
||||||
|
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
||||||
|
if (!mgr) return { config: null };
|
||||||
|
return { config: mgr.getConfig() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update (upsert) the ACME config
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAcmeConfig>(
|
||||||
|
'updateAcmeConfig',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'acme-config:write');
|
||||||
|
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
||||||
|
if (!mgr) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'AcmeConfigManager not initialized (DB disabled?)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await mgr.updateConfig(
|
||||||
|
{
|
||||||
|
accountEmail: dataArg.accountEmail,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
useProduction: dataArg.useProduction,
|
||||||
|
autoRenew: dataArg.autoRenew,
|
||||||
|
renewThresholdDays: dataArg.renewThresholdDays,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return { success: true, config: updated };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export class AdminHandler {
|
|||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
// JWT instance
|
// JWT instance
|
||||||
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
|
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||||
|
|
||||||
// Simple in-memory user storage (in production, use proper database)
|
// Simple in-memory user storage (in production, use proper database)
|
||||||
private users = new Map<string, {
|
private users = new Map<string, {
|
||||||
@@ -52,6 +52,18 @@ export class AdminHandler {
|
|||||||
role: 'admin',
|
role: 'admin',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a safe projection of the users Map — excludes password fields.
|
||||||
|
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
||||||
|
*/
|
||||||
|
public listUsers(): Array<{ id: string; username: string; role: string }> {
|
||||||
|
return Array.from(this.users.values()).map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
// Admin Login Handler
|
// Admin Login Handler
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
|
||||||
|
* @push.rocks/smartacme. Inlined here because the original is `private` on
|
||||||
|
* SmartAcme. The cert identity ('task.vc' for both 'outline.task.vc' and
|
||||||
|
* '*.task.vc') is what AcmeCertDoc is keyed by, so two route domains with
|
||||||
|
* the same identity share the same underlying ACME cert.
|
||||||
|
*
|
||||||
|
* Returns undefined for domains with 4+ levels (matching smartacme's
|
||||||
|
* "deeper domains not supported" behavior) and for malformed inputs.
|
||||||
|
*
|
||||||
|
* Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export function deriveCertDomainName(domain: string): string | undefined {
|
||||||
|
if (domain.startsWith('*.')) {
|
||||||
|
return domain.slice(2);
|
||||||
|
}
|
||||||
|
const parts = domain.split('.');
|
||||||
|
if (parts.length < 2 || parts.length > 3) return undefined;
|
||||||
|
return parts.slice(-2).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
export class CertificateHandler {
|
export class CertificateHandler {
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
@@ -42,7 +65,7 @@ export class CertificateHandler {
|
|||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||||
'reprovisionCertificateDomain',
|
'reprovisionCertificateDomain',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
return this.reprovisionCertificateDomain(dataArg.domain);
|
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -187,30 +210,32 @@ export class CertificateHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check persisted cert data from StorageManager
|
// Check persisted cert data from smartdata document classes
|
||||||
if (status === 'unknown') {
|
if (status === 'unknown') {
|
||||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
// SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
|
||||||
if (!certData) {
|
const parts = cleanDomain.split('.');
|
||||||
// Also check certStore path (proxy-certs)
|
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
|
||||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||||
}
|
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||||
if (certData?.validUntil) {
|
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
|
||||||
expiryDate = new Date(certData.validUntil).toISOString();
|
|
||||||
if (certData.created) {
|
if (acmeDoc?.validUntil) {
|
||||||
issuedAt = new Date(certData.created).toISOString();
|
expiryDate = new Date(acmeDoc.validUntil).toISOString();
|
||||||
|
if (acmeDoc.created) {
|
||||||
|
issuedAt = new Date(acmeDoc.created).toISOString();
|
||||||
}
|
}
|
||||||
issuer = 'smartacme-dns-01';
|
issuer = 'smartacme-dns-01';
|
||||||
} else if (certData?.publicKey) {
|
} else if (proxyDoc?.publicKey) {
|
||||||
// certStore has the cert — parse PEM for expiry
|
// certStore has the cert — parse PEM for expiry
|
||||||
try {
|
try {
|
||||||
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
|
const x509 = new plugins.crypto.X509Certificate(proxyDoc.publicKey);
|
||||||
expiryDate = new Date(x509.validTo).toISOString();
|
expiryDate = new Date(x509.validTo).toISOString();
|
||||||
issuedAt = new Date(x509.validFrom).toISOString();
|
issuedAt = new Date(x509.validFrom).toISOString();
|
||||||
} catch { /* PEM parsing failed */ }
|
} catch { /* PEM parsing failed */ }
|
||||||
status = 'valid';
|
status = 'valid';
|
||||||
issuer = 'cert-store';
|
issuer = 'cert-store';
|
||||||
} else if (certData) {
|
} else if (acmeDoc || proxyDoc) {
|
||||||
status = 'valid';
|
status = 'valid';
|
||||||
issuer = 'cert-store';
|
issuer = 'cert-store';
|
||||||
}
|
}
|
||||||
@@ -292,7 +317,12 @@ export class CertificateHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy route-based reprovisioning
|
* Legacy route-based reprovisioning. Kept for backward compatibility with
|
||||||
|
* older clients that send `reprovisionCertificate` typed-requests.
|
||||||
|
*
|
||||||
|
* Like reprovisionCertificateDomain, this triggers the full route apply
|
||||||
|
* pipeline rather than smartProxy.provisionCertificate(routeName) — which
|
||||||
|
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
|
||||||
*/
|
*/
|
||||||
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
|
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
|
||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
@@ -302,24 +332,39 @@ 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) {
|
} catch (err: unknown) {
|
||||||
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
* Domain-based reprovisioning — clears backoff first, refreshes the smartacme
|
||||||
|
* cert (when forceRenew is set), then re-applies routes so the running Rust
|
||||||
|
* proxy actually picks up the new cert.
|
||||||
|
*
|
||||||
|
* Why applyRoutes (not smartProxy.provisionCertificate)?
|
||||||
|
* smartProxy.provisionCertificate(routeName) routes through the Rust ACME
|
||||||
|
* path, which is forcibly disabled whenever certProvisionFunction is set
|
||||||
|
* (smart-proxy.ts:168-171). The only path that re-invokes
|
||||||
|
* certProvisionFunction → bridge.loadCertificate is updateRoutes(), which
|
||||||
|
* we trigger via routeConfigManager.applyRoutes().
|
||||||
*/
|
*/
|
||||||
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
|
private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> {
|
||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
const smartProxy = dcRouter.smartProxy;
|
const smartProxy = dcRouter.smartProxy;
|
||||||
|
|
||||||
@@ -332,31 +377,143 @@ export class CertificateHandler {
|
|||||||
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear status map entry so it gets refreshed
|
// Find routes matching this domain — fail early if none exist
|
||||||
|
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
||||||
|
if (routeNames.length === 0) {
|
||||||
|
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If forceRenew, order a fresh cert from ACME now so it's already in
|
||||||
|
// AcmeCertDoc by the time certProvisionFunction is invoked below.
|
||||||
|
//
|
||||||
|
// includeWildcard: when forcing a non-wildcard subdomain renewal, we still
|
||||||
|
// want the wildcard SAN in the order so the new cert keeps covering every
|
||||||
|
// sibling. Without this, smartacme defaults to includeWildcard: false and
|
||||||
|
// the re-issued cert would have only the base domain as SAN, breaking every
|
||||||
|
// sibling subdomain that was previously covered by the same wildcard cert.
|
||||||
|
if (forceRenew && dcRouter.smartAcme) {
|
||||||
|
let newCert: plugins.smartacme.Cert;
|
||||||
|
try {
|
||||||
|
newCert = await dcRouter.smartAcme.getCertificateForDomain(domain, {
|
||||||
|
forceRenew: true,
|
||||||
|
includeWildcard: !domain.startsWith('*.'),
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate the freshly-issued cert PEM to every sibling route domain that
|
||||||
|
// shares the same cert identity. Without this, the rust hot-swap (keyed by
|
||||||
|
// exact domain in `loaded_certs`) only fires for the clicked route via the
|
||||||
|
// fire-and-forget cert provisioning path, leaving siblings serving the
|
||||||
|
// stale in-memory cert until the next background reload completes.
|
||||||
|
try {
|
||||||
|
await this.propagateCertToSiblings(domain, newCert);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Best-effort: failure here doesn't undo the cert issuance, just log.
|
||||||
|
logger.log('warn', `Failed to propagate force-renewed cert to siblings of ${domain}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear status map entry so it gets refreshed by the certificate-issued event
|
||||||
dcRouter.certificateStatusMap.delete(domain);
|
dcRouter.certificateStatusMap.delete(domain);
|
||||||
|
|
||||||
// Try to provision via SmartAcme directly
|
// Trigger the full route apply pipeline:
|
||||||
if (dcRouter.smartAcme) {
|
// applyRoutes → updateRoutes → provisionCertificatesViaCallback →
|
||||||
try {
|
// certProvisionFunction(domain) → smartAcme.getCertificateForDomain →
|
||||||
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
// bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
|
||||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
// certificate-issued event → certificateStatusMap updated
|
||||||
} catch (err) {
|
try {
|
||||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
if (dcRouter.routeConfigManager) {
|
||||||
|
await dcRouter.routeConfigManager.applyRoutes();
|
||||||
|
} else {
|
||||||
|
// Fallback when DB is disabled and there is no RouteConfigManager
|
||||||
|
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
|
||||||
|
}
|
||||||
|
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a force-renew, walk every route in the smartproxy that resolves to
|
||||||
|
* the same cert identity as `forcedDomain` and write the freshly-issued cert
|
||||||
|
* PEM into ProxyCertDoc for each. This guarantees that the next applyRoutes
|
||||||
|
* → provisionCertificatesViaCallback iteration will hot-swap every sibling's
|
||||||
|
* rust loaded_certs entry with the new (correct) PEM, rather than relying on
|
||||||
|
* the in-memory cert returned by smartacme's per-domain cache.
|
||||||
|
*
|
||||||
|
* Why this is necessary:
|
||||||
|
* Rust's `loaded_certs` is a HashMap<domain, TlsCertConfig>. Each
|
||||||
|
* bridge.loadCertificate(domain, ...) only swaps that one entry. The
|
||||||
|
* fire-and-forget cert provisioning path triggered by updateRoutes does
|
||||||
|
* eventually iterate every auto-cert route, but it returns the cached
|
||||||
|
* (broken pre-fix) cert from smartacme's per-domain mutex. With this
|
||||||
|
* helper, ProxyCertDoc is updated synchronously to the correct PEM before
|
||||||
|
* applyRoutes runs, so even the transient window stays consistent.
|
||||||
|
*/
|
||||||
|
private async propagateCertToSiblings(
|
||||||
|
forcedDomain: string,
|
||||||
|
newCert: plugins.smartacme.Cert,
|
||||||
|
): Promise<void> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const smartProxy = dcRouter.smartProxy;
|
||||||
|
if (!smartProxy) return;
|
||||||
|
|
||||||
|
const certIdentity = deriveCertDomainName(forcedDomain);
|
||||||
|
if (!certIdentity) return;
|
||||||
|
|
||||||
|
// Collect every route domain whose cert identity matches.
|
||||||
|
const affected = new Set<string>();
|
||||||
|
for (const route of smartProxy.routeManager.getRoutes()) {
|
||||||
|
if (!route.match.domains) continue;
|
||||||
|
const routeDomains = Array.isArray(route.match.domains)
|
||||||
|
? route.match.domains
|
||||||
|
: [route.match.domains];
|
||||||
|
for (const routeDomain of routeDomains) {
|
||||||
|
if (deriveCertDomainName(routeDomain) === certIdentity) {
|
||||||
|
affected.add(routeDomain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try provisioning via the first matching route
|
if (affected.size === 0) return;
|
||||||
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
|
||||||
if (routeNames.length > 0) {
|
// Parse expiry from PEM (defense-in-depth — same pattern as
|
||||||
|
// ts/classes.dcrouter.ts:988-995 and the existing certStore.save callback).
|
||||||
|
let validUntil = newCert.validUntil;
|
||||||
|
let validFrom: number | undefined;
|
||||||
|
if (newCert.publicKey) {
|
||||||
try {
|
try {
|
||||||
await smartProxy.provisionCertificate(routeNames[0]);
|
const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
|
||||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
validUntil = new Date(x509.validTo).getTime();
|
||||||
} catch (err) {
|
validFrom = new Date(x509.validFrom).getTime();
|
||||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
} catch { /* fall back to smartacme's value */ }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
// Persist new cert PEM under each affected route domain
|
||||||
|
for (const routeDomain of affected) {
|
||||||
|
let doc = await ProxyCertDoc.findByDomain(routeDomain);
|
||||||
|
if (!doc) {
|
||||||
|
doc = new ProxyCertDoc();
|
||||||
|
doc.domain = routeDomain;
|
||||||
|
}
|
||||||
|
doc.publicKey = newCert.publicKey;
|
||||||
|
doc.privateKey = newCert.privateKey;
|
||||||
|
doc.ca = '';
|
||||||
|
doc.validUntil = validUntil || 0;
|
||||||
|
doc.validFrom = validFrom || 0;
|
||||||
|
await doc.save();
|
||||||
|
|
||||||
|
// Clear status so the next event refresh shows the new cert
|
||||||
|
dcRouter.certificateStatusMap.delete(routeDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`Propagated force-renewed cert for ${forcedDomain} (cert identity '${certIdentity}') to ${affected.size} sibling route domain(s): ${[...affected].join(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -365,19 +522,21 @@ export class CertificateHandler {
|
|||||||
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
const parts = cleanDomain.split('.');
|
||||||
|
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
|
||||||
|
|
||||||
// Delete from all known storage paths
|
// Delete from smartdata document classes (try base domain first, then exact)
|
||||||
const paths = [
|
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||||
`/proxy-certs/${domain}`,
|
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||||
`/proxy-certs/${cleanDomain}`,
|
if (acmeDoc) {
|
||||||
`/certs/${cleanDomain}`,
|
await acmeDoc.delete();
|
||||||
];
|
}
|
||||||
|
|
||||||
for (const path of paths) {
|
// Try both original domain and clean domain for proxy certs
|
||||||
try {
|
for (const d of [domain, cleanDomain]) {
|
||||||
await dcRouter.storageManager.delete(path);
|
const proxyDoc = await ProxyCertDoc.findByDomain(d);
|
||||||
} catch {
|
if (proxyDoc) {
|
||||||
// Path may not exist — ignore
|
await proxyDoc.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,43 +567,41 @@ export class CertificateHandler {
|
|||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
}> {
|
}> {
|
||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
||||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
// Try SmartAcme /certs/ path first (has full ICert fields)
|
// Try AcmeCertDoc first (has full ICert fields)
|
||||||
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||||
if (certData && certData.publicKey && certData.privateKey) {
|
if (acmeDoc && acmeDoc.publicKey && acmeDoc.privateKey) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
cert: {
|
cert: {
|
||||||
id: certData.id || plugins.crypto.randomUUID(),
|
id: acmeDoc.id || plugins.crypto.randomUUID(),
|
||||||
domainName: certData.domainName || domain,
|
domainName: acmeDoc.domainName || domain,
|
||||||
created: certData.created || Date.now(),
|
created: acmeDoc.created || Date.now(),
|
||||||
validUntil: certData.validUntil || 0,
|
validUntil: acmeDoc.validUntil || 0,
|
||||||
privateKey: certData.privateKey,
|
privateKey: acmeDoc.privateKey,
|
||||||
publicKey: certData.publicKey,
|
publicKey: acmeDoc.publicKey,
|
||||||
csr: certData.csr || '',
|
csr: acmeDoc.csr || '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try /proxy-certs/ with original domain
|
// Fallback: try ProxyCertDoc with original domain, then clean domain
|
||||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
let proxyDoc = await ProxyCertDoc.findByDomain(domain);
|
||||||
if (!certData || !certData.publicKey) {
|
if (!proxyDoc || !proxyDoc.publicKey) {
|
||||||
// Try with clean domain
|
proxyDoc = await ProxyCertDoc.findByDomain(cleanDomain);
|
||||||
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (certData && certData.publicKey && certData.privateKey) {
|
if (proxyDoc && proxyDoc.publicKey && proxyDoc.privateKey) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
cert: {
|
cert: {
|
||||||
id: plugins.crypto.randomUUID(),
|
id: plugins.crypto.randomUUID(),
|
||||||
domainName: domain,
|
domainName: domain,
|
||||||
created: certData.validFrom || Date.now(),
|
created: proxyDoc.validFrom || Date.now(),
|
||||||
validUntil: certData.validUntil || 0,
|
validUntil: proxyDoc.validUntil || 0,
|
||||||
privateKey: certData.privateKey,
|
privateKey: proxyDoc.privateKey,
|
||||||
publicKey: certData.publicKey,
|
publicKey: proxyDoc.publicKey,
|
||||||
csr: '',
|
csr: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -476,26 +633,32 @@ export class CertificateHandler {
|
|||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
// Save to /certs/ (SmartAcme-compatible path)
|
// Save to AcmeCertDoc (SmartAcme-compatible)
|
||||||
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
|
let acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||||
id: cert.id,
|
if (!acmeDoc) {
|
||||||
domainName: cert.domainName,
|
acmeDoc = new AcmeCertDoc();
|
||||||
created: cert.created,
|
acmeDoc.domainName = cleanDomain;
|
||||||
validUntil: cert.validUntil,
|
}
|
||||||
privateKey: cert.privateKey,
|
acmeDoc.id = cert.id;
|
||||||
publicKey: cert.publicKey,
|
acmeDoc.created = cert.created;
|
||||||
csr: cert.csr || '',
|
acmeDoc.validUntil = cert.validUntil;
|
||||||
});
|
acmeDoc.privateKey = cert.privateKey;
|
||||||
|
acmeDoc.publicKey = cert.publicKey;
|
||||||
|
acmeDoc.csr = cert.csr || '';
|
||||||
|
await acmeDoc.save();
|
||||||
|
|
||||||
// Also save to /proxy-certs/ (proxy-cert format)
|
// Also save to ProxyCertDoc (proxy-cert format)
|
||||||
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
|
let proxyDoc = await ProxyCertDoc.findByDomain(cert.domainName);
|
||||||
domain: cert.domainName,
|
if (!proxyDoc) {
|
||||||
publicKey: cert.publicKey,
|
proxyDoc = new ProxyCertDoc();
|
||||||
privateKey: cert.privateKey,
|
proxyDoc.domain = cert.domainName;
|
||||||
ca: undefined,
|
}
|
||||||
validUntil: cert.validUntil,
|
proxyDoc.publicKey = cert.publicKey;
|
||||||
validFrom: cert.created,
|
proxyDoc.privateKey = cert.privateKey;
|
||||||
});
|
proxyDoc.ca = '';
|
||||||
|
proxyDoc.validUntil = cert.validUntil;
|
||||||
|
proxyDoc.validFrom = cert.created;
|
||||||
|
await proxyDoc.save();
|
||||||
|
|
||||||
// Update in-memory status map
|
// Update in-memory status map
|
||||||
dcRouter.certificateStatusMap.set(cert.domainName, {
|
dcRouter.certificateStatusMap.set(cert.domainName, {
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ export class ConfigHandler {
|
|||||||
const resolvedPaths = dcRouter.resolvedPaths;
|
const resolvedPaths = dcRouter.resolvedPaths;
|
||||||
|
|
||||||
// --- System ---
|
// --- System ---
|
||||||
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction
|
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.dbConfig?.mongoDbUrl
|
||||||
? 'custom'
|
? 'custom'
|
||||||
: opts.storage?.fsPath
|
: 'filesystem';
|
||||||
? 'filesystem'
|
|
||||||
: 'memory';
|
|
||||||
|
|
||||||
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
||||||
let proxyIps = opts.proxyIps || [];
|
let proxyIps = opts.proxyIps || [];
|
||||||
@@ -55,7 +53,7 @@ export class ConfigHandler {
|
|||||||
proxyIps,
|
proxyIps,
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
storageBackend,
|
storageBackend,
|
||||||
storagePath: opts.storage?.fsPath || null,
|
storagePath: opts.dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- SmartProxy ---
|
// --- SmartProxy ---
|
||||||
@@ -125,6 +123,15 @@ export class ConfigHandler {
|
|||||||
ttl: r.ttl,
|
ttl: r.ttl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB
|
||||||
|
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
|
||||||
|
let dnsChallengeEnabled = false;
|
||||||
|
try {
|
||||||
|
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false;
|
||||||
|
} catch {
|
||||||
|
dnsChallengeEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
const dns: interfaces.requests.IConfigData['dns'] = {
|
const dns: interfaces.requests.IConfigData['dns'] = {
|
||||||
enabled: !!dcRouter.dnsServer,
|
enabled: !!dcRouter.dnsServer,
|
||||||
port: 53,
|
port: 53,
|
||||||
@@ -132,7 +139,7 @@ export class ConfigHandler {
|
|||||||
scopes: opts.dnsScopes || [],
|
scopes: opts.dnsScopes || [],
|
||||||
recordCount: dnsRecords.length,
|
recordCount: dnsRecords.length,
|
||||||
records: dnsRecords,
|
records: dnsRecords,
|
||||||
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
|
dnsChallenge: dnsChallengeEnabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- TLS ---
|
// --- TLS ---
|
||||||
@@ -151,15 +158,15 @@ export class ConfigHandler {
|
|||||||
keyPath: opts.tls?.keyPath || null,
|
keyPath: opts.tls?.keyPath || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Cache ---
|
// --- Database ---
|
||||||
const cacheConfig = opts.cacheConfig;
|
const dbConfig = opts.dbConfig;
|
||||||
const cache: interfaces.requests.IConfigData['cache'] = {
|
const cache: interfaces.requests.IConfigData['cache'] = {
|
||||||
enabled: cacheConfig?.enabled !== false,
|
enabled: dbConfig?.enabled !== false,
|
||||||
storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
storagePath: dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||||
dbName: cacheConfig?.dbName || 'dcrouter',
|
dbName: dbConfig?.dbName || 'dcrouter',
|
||||||
defaultTTLDays: cacheConfig?.defaultTTLDays || 30,
|
defaultTTLDays: 30,
|
||||||
cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1,
|
cleanupIntervalHours: dbConfig?.cleanupIntervalHours || 1,
|
||||||
ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record<string, number> : {},
|
ttlConfig: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- RADIUS ---
|
// --- RADIUS ---
|
||||||
@@ -185,7 +192,8 @@ export class ConfigHandler {
|
|||||||
tlsMode = 'custom';
|
tlsMode = 'custom';
|
||||||
} else if (riCfg?.hubDomain) {
|
} else if (riCfg?.hubDomain) {
|
||||||
try {
|
try {
|
||||||
const stored = await dcRouter.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
|
const { ProxyCertDoc } = await import('../../db/index.js');
|
||||||
|
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
|
||||||
if (stored?.publicKey && stored?.privateKey) {
|
if (stored?.publicKey && stored?.privateKey) {
|
||||||
tlsMode = 'acme';
|
tlsMode = 'acme';
|
||||||
}
|
}
|
||||||
|
|||||||
159
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
159
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD + connection-test handlers for DnsProviderDoc.
|
||||||
|
*
|
||||||
|
* Auth: same dual-mode pattern as TargetProfileHandler — admin JWT or
|
||||||
|
* API token with the appropriate `dns-providers:read|write` scope.
|
||||||
|
*/
|
||||||
|
export class DnsProviderHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all providers
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProviders>(
|
||||||
|
'getDnsProviders',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { providers: [] };
|
||||||
|
return { providers: await dnsManager.listProviders() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProvider>(
|
||||||
|
'getDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { provider: null };
|
||||||
|
return { provider: await dnsManager.getProvider(dataArg.id) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsProvider>(
|
||||||
|
'createDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'dns-providers:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) {
|
||||||
|
return { success: false, message: 'DnsManager not initialized (DB disabled?)' };
|
||||||
|
}
|
||||||
|
const id = await dnsManager.createProvider({
|
||||||
|
name: dataArg.name,
|
||||||
|
type: dataArg.type,
|
||||||
|
credentials: dataArg.credentials,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsProvider>(
|
||||||
|
'updateDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
const ok = await dnsManager.updateProvider(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
credentials: dataArg.credentials,
|
||||||
|
});
|
||||||
|
return ok ? { success: true } : { success: false, message: 'Provider not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsProvider>(
|
||||||
|
'deleteDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test provider connection
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestDnsProvider>(
|
||||||
|
'testDnsProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) {
|
||||||
|
return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() };
|
||||||
|
}
|
||||||
|
return await dnsManager.testProvider(dataArg.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// List domains visible to a provider's account (without importing them)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListProviderDomains>(
|
||||||
|
'listProviderDomains',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
try {
|
||||||
|
const domains = await dnsManager.listProviderDomains(dataArg.providerId);
|
||||||
|
return { success: true, domains };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD handlers for DnsRecordDoc.
|
||||||
|
*/
|
||||||
|
export class DnsRecordHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get records by domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
|
||||||
|
'getDnsRecords',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { records: [] };
|
||||||
|
const docs = await dnsManager.listRecordsForDomain(dataArg.domainId);
|
||||||
|
return { records: docs.map((d) => dnsManager.toPublicRecord(d)) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecord>(
|
||||||
|
'getDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { record: null };
|
||||||
|
const doc = await dnsManager.getRecord(dataArg.id);
|
||||||
|
return { record: doc ? dnsManager.toPublicRecord(doc) : null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
|
||||||
|
'createDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.createRecord({
|
||||||
|
domainId: dataArg.domainId,
|
||||||
|
name: dataArg.name,
|
||||||
|
type: dataArg.type,
|
||||||
|
value: dataArg.value,
|
||||||
|
ttl: dataArg.ttl,
|
||||||
|
proxied: dataArg.proxied,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
|
||||||
|
'updateDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.updateRecord({
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
value: dataArg.value,
|
||||||
|
ttl: dataArg.ttl,
|
||||||
|
proxied: dataArg.proxied,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
|
||||||
|
'deleteDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.deleteRecord(dataArg.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
ts/opsserver/handlers/domain.handler.ts
Normal file
161
ts/opsserver/handlers/domain.handler.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD handlers for DomainDoc.
|
||||||
|
*/
|
||||||
|
export class DomainHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all domains
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
|
||||||
|
'getDomains',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { domains: [] };
|
||||||
|
const docs = await dnsManager.listDomains();
|
||||||
|
return { domains: docs.map((d) => dnsManager.toPublicDomain(d)) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
|
||||||
|
'getDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { domain: null };
|
||||||
|
const doc = await dnsManager.getDomain(dataArg.id);
|
||||||
|
return { domain: doc ? dnsManager.toPublicDomain(doc) : null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create manual domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDomain>(
|
||||||
|
'createDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
try {
|
||||||
|
const id = await dnsManager.createManualDomain({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import domains from a provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportDomain>(
|
||||||
|
'importDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
try {
|
||||||
|
const importedIds = await dnsManager.importDomainsFromProvider({
|
||||||
|
providerId: dataArg.providerId,
|
||||||
|
domainNames: dataArg.domainNames,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, importedIds };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update domain metadata
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDomain>(
|
||||||
|
'updateDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
const ok = await dnsManager.updateDomain(dataArg.id, {
|
||||||
|
description: dataArg.description,
|
||||||
|
});
|
||||||
|
return ok ? { success: true } : { success: false, message: 'Domain not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDomain>(
|
||||||
|
'deleteDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
const ok = await dnsManager.deleteDomain(dataArg.id);
|
||||||
|
return ok ? { success: true } : { success: false, message: 'Domain not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Force-resync provider domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomain>(
|
||||||
|
'syncDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.syncDomain(dataArg.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,13 @@ export * from './email-ops.handler.js';
|
|||||||
export * from './certificate.handler.js';
|
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 './source-profile.handler.js';
|
||||||
|
export * from './target-profile.handler.js';
|
||||||
|
export * from './network-target.handler.js';
|
||||||
|
export * from './users.handler.js';
|
||||||
|
export * from './dns-provider.handler.js';
|
||||||
|
export * from './domain.handler.js';
|
||||||
|
export * from './dns-record.handler.js';
|
||||||
|
export * from './acme-config.handler.js';
|
||||||
@@ -255,7 +255,7 @@ export class LogsHandler {
|
|||||||
} {
|
} {
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
let intervalId: NodeJS.Timeout | null = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let logIndex = 0;
|
let lastTimestamp = Date.now();
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -284,53 +284,65 @@ export class LogsHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow mode, simulate real-time log streaming
|
// For follow mode, tail real log entries from the in-memory buffer
|
||||||
intervalId = setInterval(async () => {
|
intervalId = setInterval(async () => {
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
// Guard: clear interval if stop() was called between ticks
|
|
||||||
clearInterval(intervalId!);
|
clearInterval(intervalId!);
|
||||||
intervalId = null;
|
intervalId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
// Fetch new entries since last poll
|
||||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
const rawEntries = logBuffer.getEntries({
|
||||||
|
since: lastTimestamp,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
if (rawEntries.length === 0) return;
|
||||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
|
||||||
|
|
||||||
// Filter by requested criteria
|
for (const raw of rawEntries) {
|
||||||
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
const mappedLevel = LogsHandler.mapLogLevel(raw.level);
|
||||||
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
const mappedCategory = LogsHandler.deriveCategory(
|
||||||
|
(raw as any).context?.zone,
|
||||||
|
raw.message,
|
||||||
|
);
|
||||||
|
|
||||||
const logEntry = {
|
// Apply filters
|
||||||
timestamp: Date.now(),
|
if (levelFilter && !levelFilter.includes(mappedLevel)) continue;
|
||||||
level: mockLevel,
|
if (categoryFilter && !categoryFilter.includes(mappedCategory)) continue;
|
||||||
category: mockCategory,
|
|
||||||
message: `Real-time log ${logIndex++} from ${mockCategory}`,
|
|
||||||
metadata: {
|
|
||||||
requestId: plugins.uuid.v4(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const logData = JSON.stringify(logEntry);
|
const logEntry = {
|
||||||
const encoder = new TextEncoder();
|
timestamp: raw.timestamp || Date.now(),
|
||||||
try {
|
level: mappedLevel,
|
||||||
// Use a timeout to detect hung streams (sendData can hang if the
|
category: mappedCategory,
|
||||||
// VirtualStream's keepAlive loop has ended)
|
message: raw.message,
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout>;
|
metadata: (raw as any).data,
|
||||||
await Promise.race([
|
};
|
||||||
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
|
||||||
clearTimeout(timeoutHandle);
|
const logData = JSON.stringify(logEntry);
|
||||||
return result;
|
const encoder = new TextEncoder();
|
||||||
}),
|
try {
|
||||||
new Promise<never>((_, reject) => {
|
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||||
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
await Promise.race([
|
||||||
}),
|
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||||
]);
|
clearTimeout(timeoutHandle);
|
||||||
} catch {
|
return result;
|
||||||
// Stream closed, errored, or timed out — clean up
|
}),
|
||||||
stop();
|
new Promise<never>((_, reject) => {
|
||||||
|
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// Stream closed, errored, or timed out — clean up
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the watermark past all entries we just processed
|
||||||
|
const newest = rawEntries[rawEntries.length - 1];
|
||||||
|
if (newest.timestamp && newest.timestamp >= lastTimestamp) {
|
||||||
|
lastTimestamp = newest.timestamp + 1;
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|||||||
167
ts/opsserver/handlers/network-target.handler.ts
Normal file
167
ts/opsserver/handlers/network-target.handler.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class NetworkTargetHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all network targets
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
'getNetworkTargets',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { targets: [] };
|
||||||
|
}
|
||||||
|
return { targets: resolver.listTargets() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTarget>(
|
||||||
|
'getNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { target: null };
|
||||||
|
}
|
||||||
|
return { target: resolver.getTarget(dataArg.id) || null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||||
|
'createNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { success: false, message: 'Reference resolver not initialized' };
|
||||||
|
}
|
||||||
|
const id = await resolver.createTarget({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateNetworkTarget>(
|
||||||
|
'updateNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { affectedRouteIds } = await resolver.updateTarget(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (affectedRouteIds.length > 0) {
|
||||||
|
await manager.reResolveRoutes(affectedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, affectedRouteCount: affectedRouteIds.length };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteNetworkTarget>(
|
||||||
|
'deleteNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolver.deleteTarget(
|
||||||
|
dataArg.id,
|
||||||
|
dataArg.force ?? false,
|
||||||
|
manager.getStoredRoutes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && dataArg.force) {
|
||||||
|
await manager.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get routes using a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||||
|
'getNetworkTargetUsage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { routes: [] };
|
||||||
|
}
|
||||||
|
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||||
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,8 +52,8 @@ export class RadiusHandler {
|
|||||||
try {
|
try {
|
||||||
await radiusServer.addClient(dataArg.client);
|
await radiusServer.addClient(dataArg.client);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: (error as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -144,8 +144,8 @@ export class RadiusHandler {
|
|||||||
updatedAt: mapping.updatedAt,
|
updatedAt: mapping.updatedAt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: (error as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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' };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export class SecurityHandler {
|
|||||||
throughputByIP,
|
throughputByIP,
|
||||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||||
requestsTotal: networkStats.requestsTotal || 0,
|
requestsTotal: networkStats.requestsTotal || 0,
|
||||||
|
backends: networkStats.backends || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ export class SecurityHandler {
|
|||||||
throughputByIP: [],
|
throughputByIP: [],
|
||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
requestsTotal: 0,
|
requestsTotal: 0,
|
||||||
|
backends: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -279,7 +280,7 @@ export class StatsHandler {
|
|||||||
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||||
promises.push(
|
promises.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
const stats = await this.opsServerRef.dcRouterRef.metricsManager!.getNetworkStats();
|
||||||
const serverStats = await this.collectServerStats();
|
const serverStats = await this.collectServerStats();
|
||||||
|
|
||||||
// Build per-IP bandwidth lookup from throughputByIP
|
// Build per-IP bandwidth lookup from throughputByIP
|
||||||
@@ -309,11 +310,54 @@ export class StatsHandler {
|
|||||||
throughputHistory: stats.throughputHistory || [],
|
throughputHistory: stats.throughputHistory || [],
|
||||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||||
requestsTotal: stats.requestsTotal || 0,
|
requestsTotal: stats.requestsTotal || 0,
|
||||||
|
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 {
|
||||||
@@ -489,44 +533,41 @@ export class StatsHandler {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
const services: Array<{
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
name: string;
|
const health = dcRouter.serviceManager.getHealth();
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
||||||
message?: string;
|
const services = health.services.map((svc) => {
|
||||||
}> = [];
|
let status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
|
switch (svc.state) {
|
||||||
// Check HTTP Proxy
|
case 'running':
|
||||||
if (this.opsServerRef.dcRouterRef.smartProxy) {
|
status = 'healthy';
|
||||||
services.push({
|
break;
|
||||||
name: 'HTTP/HTTPS Proxy',
|
case 'starting':
|
||||||
status: 'healthy',
|
case 'degraded':
|
||||||
});
|
status = 'degraded';
|
||||||
}
|
break;
|
||||||
|
case 'failed':
|
||||||
// Check Email Server
|
status = svc.criticality === 'critical' ? 'unhealthy' : 'degraded';
|
||||||
if (this.opsServerRef.dcRouterRef.emailServer) {
|
break;
|
||||||
services.push({
|
case 'stopped':
|
||||||
name: 'Email Server',
|
case 'stopping':
|
||||||
status: 'healthy',
|
default:
|
||||||
});
|
status = 'degraded';
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
// Check DNS Server
|
|
||||||
if (this.opsServerRef.dcRouterRef.dnsServer) {
|
let message: string | undefined;
|
||||||
services.push({
|
if (svc.state === 'failed' && svc.lastError) {
|
||||||
name: 'DNS Server',
|
message = svc.lastError;
|
||||||
status: 'healthy',
|
} else if (svc.retryCount > 0 && svc.state !== 'running') {
|
||||||
});
|
message = `Retry attempt ${svc.retryCount}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check OpsServer
|
return { name: svc.name, status, message };
|
||||||
services.push({
|
|
||||||
name: 'OpsServer',
|
|
||||||
status: 'healthy',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const healthy = services.every(s => s.status === 'healthy');
|
const healthy = health.overall === 'healthy';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
healthy,
|
healthy,
|
||||||
services,
|
services,
|
||||||
|
|||||||
157
ts/opsserver/handlers/target-profile.handler.ts
Normal file
157
ts/opsserver/handlers/target-profile.handler.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class TargetProfileHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all target profiles
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfiles>(
|
||||||
|
'getTargetProfiles',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { profiles: [] };
|
||||||
|
}
|
||||||
|
return { profiles: manager.listProfiles() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single target profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfile>(
|
||||||
|
'getTargetProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { profile: null };
|
||||||
|
}
|
||||||
|
return { profile: manager.getProfile(dataArg.id) || null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a target profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateTargetProfile>(
|
||||||
|
'createTargetProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'target-profiles:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Target profile manager not initialized' };
|
||||||
|
}
|
||||||
|
const id = await manager.createProfile({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
domains: dataArg.domains,
|
||||||
|
targets: dataArg.targets,
|
||||||
|
routeRefs: dataArg.routeRefs,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a target profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateTargetProfile>(
|
||||||
|
'updateTargetProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'target-profiles:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
await manager.updateProfile(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
domains: dataArg.domains,
|
||||||
|
targets: dataArg.targets,
|
||||||
|
routeRefs: dataArg.routeRefs,
|
||||||
|
});
|
||||||
|
// Re-apply routes and refresh VPN client security to update access
|
||||||
|
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||||
|
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a target profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteTargetProfile>(
|
||||||
|
'deleteTargetProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'target-profiles:write');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
const result = await manager.deleteProfile(dataArg.id, dataArg.force);
|
||||||
|
if (result.success) {
|
||||||
|
// Re-apply routes and refresh VPN client security to update access
|
||||||
|
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||||
|
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get VPN clients using a target profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfileUsage>(
|
||||||
|
'getTargetProfileUsage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { clients: [] };
|
||||||
|
}
|
||||||
|
return { clients: await manager.getProfileUsage(dataArg.id) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
ts/opsserver/handlers/users.handler.ts
Normal file
30
ts/opsserver/handlers/users.handler.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
|
||||||
|
* so admin middleware enforces auth + role check before the handler runs.
|
||||||
|
* User data is owned by AdminHandler; this handler just exposes a safe
|
||||||
|
* projection of it via TypedRequest.
|
||||||
|
*/
|
||||||
|
export class UsersHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const router = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// List users (admin-only, read-only)
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||||
|
'listUsers',
|
||||||
|
async (_dataArg) => {
|
||||||
|
const users = this.opsServerRef.adminHandler.listUsers();
|
||||||
|
return { users };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
336
ts/opsserver/handlers/vpn.handler.ts
Normal file
336
ts/opsserver/handlers/vpn.handler.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class VpnHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
|
// Get all registered VPN clients
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
|
||||||
|
'getVpnClients',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { clients: [] };
|
||||||
|
}
|
||||||
|
const clients = manager.listClients().map((c) => ({
|
||||||
|
clientId: c.clientId,
|
||||||
|
enabled: c.enabled,
|
||||||
|
targetProfileIds: c.targetProfileIds,
|
||||||
|
description: c.description,
|
||||||
|
assignedIp: c.assignedIp,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get VPN server status
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
|
||||||
|
'getVpnStatus',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
|
||||||
|
if (!manager) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
running: false,
|
||||||
|
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
||||||
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
|
serverPublicKeys: null,
|
||||||
|
registeredClients: 0,
|
||||||
|
connectedClients: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await manager.getConnectedClients();
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
running: manager.running,
|
||||||
|
subnet: manager.getSubnet(),
|
||||||
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
|
serverPublicKeys: manager.getServerPublicKeys(),
|
||||||
|
registeredClients: manager.listClients().length,
|
||||||
|
connectedClients: connected.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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) ----
|
||||||
|
|
||||||
|
// Create a new VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
|
||||||
|
'createVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundle = await manager.createClient({
|
||||||
|
clientId: dataArg.clientId,
|
||||||
|
targetProfileIds: dataArg.targetProfileIds,
|
||||||
|
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 {
|
||||||
|
success: true,
|
||||||
|
client: {
|
||||||
|
clientId: bundle.entry.clientId,
|
||||||
|
enabled: bundle.entry.enabled ?? true,
|
||||||
|
targetProfileIds: persistedClient?.targetProfileIds,
|
||||||
|
description: bundle.entry.description,
|
||||||
|
assignedIp: bundle.entry.assignedIp,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
|
||||||
|
'deleteVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.removeClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable a VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
|
||||||
|
'enableVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.enableClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Disable a VPN client
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
|
||||||
|
'disableVpnClient',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.disableClient(dataArg.clientId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rotate a VPN client's keys
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
|
||||||
|
'rotateVpnClientKey',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundle = await manager.rotateClientKey(dataArg.clientId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
wireguardConfig: bundle.wireguardConfig,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export a VPN client config
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
|
||||||
|
'exportVpnClientConfig',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await manager.exportClientConfig(dataArg.clientId, dataArg.format);
|
||||||
|
return { success: true, config };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get telemetry for a specific VPN client
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
|
||||||
|
'getVpnClientTelemetry',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'VPN not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const telemetry = await manager.getClientTelemetry(dataArg.clientId);
|
||||||
|
if (!telemetry) {
|
||||||
|
return { success: false, message: 'Client not found or not connected' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
telemetry: {
|
||||||
|
clientId: telemetry.clientId,
|
||||||
|
assignedIp: telemetry.assignedIp,
|
||||||
|
bytesSent: telemetry.bytesSent,
|
||||||
|
bytesReceived: telemetry.bytesReceived,
|
||||||
|
packetsDropped: telemetry.packetsDropped,
|
||||||
|
bytesDropped: telemetry.bytesDropped,
|
||||||
|
lastKeepaliveAt: telemetry.lastKeepaliveAt,
|
||||||
|
keepalivesReceived: telemetry.keepalivesReceived,
|
||||||
|
rateLimitBytesPerSec: telemetry.rateLimitBytesPerSec,
|
||||||
|
burstBytes: telemetry.burstBytes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,23 +47,25 @@ import * as qenv from '@push.rocks/qenv';
|
|||||||
import * as smartacme from '@push.rocks/smartacme';
|
import * as smartacme from '@push.rocks/smartacme';
|
||||||
import * as smartdata from '@push.rocks/smartdata';
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
import * as smartdns from '@push.rocks/smartdns';
|
import * as smartdns from '@push.rocks/smartdns';
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfs from '@push.rocks/smartfs';
|
||||||
import * as smartguard from '@push.rocks/smartguard';
|
import * as smartguard from '@push.rocks/smartguard';
|
||||||
import * as smartjwt from '@push.rocks/smartjwt';
|
import * as smartjwt from '@push.rocks/smartjwt';
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||||
import * as smartmta from '@push.rocks/smartmta';
|
import * as smartmta from '@push.rocks/smartmta';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartdb from '@push.rocks/smartdb';
|
||||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartproxy from '@push.rocks/smartproxy';
|
import * as smartproxy from '@push.rocks/smartproxy';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
import * as smartvpn from '@push.rocks/smartvpn';
|
||||||
import * as smartradius from '@push.rocks/smartradius';
|
import * as smartradius from '@push.rocks/smartradius';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
|
||||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique };
|
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
|
||||||
|
|
||||||
// Define SmartLog types for use in error handling
|
// Define SmartLog types for use in error handling
|
||||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||||
@@ -89,7 +91,7 @@ export {
|
|||||||
uuid,
|
uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filesystem utilities (compatibility helpers for smartfile v13+)
|
// Filesystem utilities
|
||||||
export const fsUtils = {
|
export const fsUtils = {
|
||||||
/**
|
/**
|
||||||
* Ensure a directory exists, creating it recursively if needed (sync)
|
* Ensure a directory exists, creating it recursively if needed (sync)
|
||||||
|
|||||||
@@ -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,14 +84,14 @@ 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 */
|
||||||
detailedLogging?: boolean;
|
detailedLogging?: boolean;
|
||||||
/** Maximum active sessions to track in memory */
|
/** Maximum active sessions to track in memory */
|
||||||
maxActiveSessions?: number;
|
maxActiveSessions?: number;
|
||||||
|
/** Stale session timeout in hours — sessions with no update for this long are evicted (default: 24) */
|
||||||
|
staleSessionTimeoutHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,7 +104,7 @@ 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>;
|
||||||
|
|
||||||
// Counters for statistics
|
// Counters for statistics
|
||||||
private stats = {
|
private stats = {
|
||||||
@@ -115,26 +115,72 @@ 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,
|
||||||
};
|
};
|
||||||
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)
|
||||||
|
this.staleSessionSweepTimer = setInterval(() => {
|
||||||
|
this.sweepStaleSessions();
|
||||||
|
}, 15 * 60 * 1000);
|
||||||
|
// Allow the process to exit even if the timer is pending
|
||||||
|
if (this.staleSessionSweepTimer.unref) {
|
||||||
|
this.staleSessionSweepTimer.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
|
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the accounting manager and clean up timers
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
if (this.staleSessionSweepTimer) {
|
||||||
|
clearInterval(this.staleSessionSweepTimer);
|
||||||
|
this.staleSessionSweepTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep stale active sessions that have not received any update
|
||||||
|
* within the configured timeout. These are orphaned sessions where
|
||||||
|
* the Stop packet was never received.
|
||||||
|
*/
|
||||||
|
private sweepStaleSessions(): void {
|
||||||
|
const timeoutMs = this.config.staleSessionTimeoutHours * 60 * 60 * 1000;
|
||||||
|
const cutoff = Date.now() - timeoutMs;
|
||||||
|
let swept = 0;
|
||||||
|
|
||||||
|
for (const [sessionId, session] of this.activeSessions) {
|
||||||
|
if (session.lastUpdateTime < cutoff) {
|
||||||
|
session.status = 'terminated';
|
||||||
|
session.terminateCause = 'StaleSessionTimeout';
|
||||||
|
session.endTime = Date.now();
|
||||||
|
session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000);
|
||||||
|
|
||||||
|
this.persistSession(session).catch(() => {});
|
||||||
|
|
||||||
|
this.activeSessions.delete(sessionId);
|
||||||
|
swept++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swept > 0) {
|
||||||
|
logger.log('info', `Swept ${swept} stale RADIUS sessions (no update for ${this.config.staleSessionTimeoutHours}h)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle accounting start request
|
* Handle accounting start request
|
||||||
*/
|
*/
|
||||||
@@ -195,9 +241,7 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist session
|
// Persist session
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.persistSession(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,9 +287,7 @@ export class AccountingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update persisted session
|
// Update persisted session
|
||||||
if (this.storageManager) {
|
await this.persistSession(session);
|
||||||
await this.persistSession(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,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);
|
||||||
@@ -438,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
|
||||||
}
|
}
|
||||||
@@ -463,8 +496,8 @@ export class AccountingManager {
|
|||||||
if (deletedCount > 0) {
|
if (deletedCount > 0) {
|
||||||
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
|
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to cleanup old sessions: ${error.message}`);
|
logger.log('error', `Failed to cleanup old sessions: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
@@ -497,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`);
|
||||||
@@ -507,99 +538,101 @@ 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) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to load active sessions: ${error.message}`);
|
logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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);
|
||||||
} catch (error) {
|
if (!doc) {
|
||||||
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`);
|
doc = new AccountingSessionDoc();
|
||||||
|
}
|
||||||
|
Object.assign(doc, session);
|
||||||
|
await doc.save();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
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) {
|
|
||||||
logger.log('error', `Failed to archive session ${session.sessionId}: ${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) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to get archived sessions: ${error.message}`);
|
logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions;
|
return sessions;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,6 +180,8 @@ export class RadiusServer {
|
|||||||
this.radiusServer = undefined;
|
this.radiusServer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.accountingManager.stop();
|
||||||
|
|
||||||
this.running = false;
|
this.running = false;
|
||||||
logger.log('info', 'RADIUS server stopped');
|
logger.log('info', 'RADIUS server stopped');
|
||||||
}
|
}
|
||||||
@@ -308,8 +307,8 @@ export class RadiusServer {
|
|||||||
default:
|
default:
|
||||||
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `RADIUS accounting error: ${error.message}`);
|
logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +97,7 @@ export class VlanManager {
|
|||||||
if (this.normalizedMacCache.size > 10000) {
|
if (this.normalizedMacCache.size > 10000) {
|
||||||
const iterator = this.normalizedMacCache.keys();
|
const iterator = this.normalizedMacCache.keys();
|
||||||
for (let i = 0; i < 1000; i++) {
|
for (let i = 0; i < 1000; i++) {
|
||||||
this.normalizedMacCache.delete(iterator.next().value);
|
this.normalizedMacCache.delete(iterator.next().value!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${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();
|
||||||
} catch (error) {
|
if (!doc) {
|
||||||
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`);
|
doc = new VlanMappingsDoc();
|
||||||
|
}
|
||||||
|
doc.mappings = mappings;
|
||||||
|
await doc.save();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
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';
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks c
|
|||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export enum ThreatCategory {
|
|||||||
* Content Scanner for detecting malicious email content
|
* Content Scanner for detecting malicious email content
|
||||||
*/
|
*/
|
||||||
export class ContentScanner {
|
export class ContentScanner {
|
||||||
private static instance: ContentScanner;
|
private static instance: ContentScanner | undefined;
|
||||||
private scanCache: LRUCache<string, IScanResult>;
|
private scanCache: LRUCache<string, IScanResult>;
|
||||||
private options: Required<IContentScannerOptions>;
|
private options: Required<IContentScannerOptions>;
|
||||||
|
|
||||||
@@ -258,12 +258,12 @@ export class ContentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error scanning email: ${error.message}`, {
|
logger.log('error', `Error scanning email: ${(error as Error).message}`, {
|
||||||
messageId: email.getMessageId(),
|
messageId: email.getMessageId(),
|
||||||
error: error.stack
|
error: (error as Error).stack
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return a safe default with error indication
|
// Return a safe default with error indication
|
||||||
return {
|
return {
|
||||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
isClean: true, // Let it pass if scanner fails (configure as desired)
|
||||||
@@ -271,7 +271,7 @@ export class ContentScanner {
|
|||||||
scannedElements: ['error'],
|
scannedElements: ['error'],
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
threatType: 'scan_error',
|
threatType: 'scan_error',
|
||||||
threatDetails: `Scan error: ${error.message}`
|
threatDetails: `Scan error: ${(error as Error).message}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,8 +625,8 @@ export class ContentScanner {
|
|||||||
return sample.toString('utf8')
|
return sample.toString('utf8')
|
||||||
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
||||||
.replace(/\uFFFD/g, ''); // Remove replacement char
|
.replace(/\uFFFD/g, ''); // Remove replacement char
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('warn', `Error extracting text from buffer: ${error.message}`);
|
logger.log('warn', `Error extracting text from buffer: ${(error as Error).message}`);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,10 +699,10 @@ export class ContentScanner {
|
|||||||
subject: email.subject
|
subject: email.subject
|
||||||
},
|
},
|
||||||
success: false,
|
success: false,
|
||||||
domain: email.getFromDomain()
|
domain: email.getFromDomain() ?? undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a threat finding to the security logger
|
* Log a threat finding to the security logger
|
||||||
* @param email The email containing the threat
|
* @param email The email containing the threat
|
||||||
@@ -722,10 +722,10 @@ export class ContentScanner {
|
|||||||
subject: email.subject
|
subject: email.subject
|
||||||
},
|
},
|
||||||
success: false,
|
success: false,
|
||||||
domain: email.getFromDomain()
|
domain: email.getFromDomain() ?? undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get threat level description based on score
|
* Get threat level description based on score
|
||||||
* @param score Threat score
|
* @param score Threat score
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -61,13 +61,10 @@ export interface IIPReputationOptions {
|
|||||||
* Class for checking IP reputation of inbound email senders
|
* Class for checking IP reputation of inbound email senders
|
||||||
*/
|
*/
|
||||||
export class IPReputationChecker {
|
export class IPReputationChecker {
|
||||||
private static instance: IPReputationChecker;
|
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 => {
|
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.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,62 +168,64 @@ 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) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
|
||||||
ip,
|
ip,
|
||||||
stack: error.stack
|
stack: (error as Error).stack
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.createErrorResult(ip, 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,42 +238,42 @@ 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 {
|
||||||
const lookupDomain = `${reversedIP}.${server}`;
|
const lookupDomain = `${reversedIP}.${server}`;
|
||||||
await plugins.dns.promises.resolve(lookupDomain);
|
await plugins.dns.promises.resolve(lookupDomain);
|
||||||
return server; // IP is listed in this DNSBL
|
return server; // IP is listed in this DNSBL
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
if (error.code === 'ENOTFOUND') {
|
if ((error as any).code === 'ENOTFOUND') {
|
||||||
return null; // IP is not listed in this DNSBL
|
return null; // IP is not listed in this DNSBL
|
||||||
}
|
}
|
||||||
throw error; // Other error
|
throw error; // Other error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
|
logger.log('error', `Error checking DNSBL for ${ip}: ${(error as Error).message}`);
|
||||||
return {
|
return {
|
||||||
listCount: 0,
|
listCount: 0,
|
||||||
lists: []
|
lists: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -349,14 +328,14 @@ export class IPReputationChecker {
|
|||||||
org: this.determineOrg(ip), // Simplified, would use real org data
|
org: this.determineOrg(ip), // Simplified, would use real org data
|
||||||
type
|
type
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
|
logger.log('error', `Error getting IP info for ${ip}: ${(error as Error).message}`);
|
||||||
return {
|
return {
|
||||||
type: IPType.UNKNOWN
|
type: IPType.UNKNOWN
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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)
|
* Persist a single IP reputation result to the database via CachedIPReputation
|
||||||
*/
|
*/
|
||||||
private debouncedSaveCache(): void {
|
private async persistReputationToDb(ip: string, result: IReputationResult): Promise<void> {
|
||||||
if (this.saveCacheTimer) {
|
try {
|
||||||
return; // already scheduled
|
const data = {
|
||||||
|
score: result.score,
|
||||||
|
isSpam: result.isSpam,
|
||||||
|
isProxy: result.isProxy,
|
||||||
|
isTor: result.isTor,
|
||||||
|
isVPN: result.isVPN,
|
||||||
|
country: result.country,
|
||||||
|
asn: result.asn,
|
||||||
|
org: result.org,
|
||||||
|
blacklists: result.blacklists,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await CachedIPReputation.findByIP(ip);
|
||||||
|
if (existing) {
|
||||||
|
existing.updateReputation(data);
|
||||||
|
await existing.save();
|
||||||
|
} else {
|
||||||
|
const doc = CachedIPReputation.fromReputationData(ip, data);
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
this.saveCacheTimer = setTimeout(() => {
|
|
||||||
this.saveCacheTimer = null;
|
|
||||||
this.saveCache().catch(error => {
|
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
|
||||||
});
|
|
||||||
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save cache to disk or storage manager
|
* Load persisted reputations from CachedIPReputation documents into the in-memory LRU cache
|
||||||
*/
|
*/
|
||||||
private async saveCache(): Promise<void> {
|
private async loadCacheFromDb(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Convert cache entries to serializable array
|
const docs = await CachedIPReputation.getInstances({});
|
||||||
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
let loadedCount = 0;
|
||||||
ip,
|
|
||||||
data
|
for (const doc of docs) {
|
||||||
}));
|
// Skip expired documents
|
||||||
|
if (doc.isExpired()) {
|
||||||
// Only save if we have entries
|
continue;
|
||||||
if (entries.length === 0) {
|
}
|
||||||
return;
|
|
||||||
|
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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheData = JSON.stringify(entries);
|
if (loadedCount > 0) {
|
||||||
|
logger.log('info', `Loaded ${loadedCount} IP reputation cache entries from database`);
|
||||||
// Save to storage manager if available
|
|
||||||
if (this.storageManager) {
|
|
||||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
|
||||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
|
||||||
} else {
|
|
||||||
// Fall back to filesystem
|
|
||||||
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
|
||||||
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) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
logger.log('error', `Failed to load IP reputation cache from database: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load cache from disk or storage manager
|
|
||||||
*/
|
|
||||||
private async loadCache(): Promise<void> {
|
|
||||||
try {
|
|
||||||
let cacheData: string | null = null;
|
|
||||||
let fromFilesystem = false;
|
|
||||||
|
|
||||||
// Try to load from storage manager first
|
|
||||||
if (this.storageManager) {
|
|
||||||
try {
|
|
||||||
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
|
||||||
|
|
||||||
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.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error loading from StorageManager: ${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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and restore cache if data was found
|
|
||||||
if (cacheData) {
|
|
||||||
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) {
|
|
||||||
logger.log('error', `Failed to load IP reputation cache: ${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 => {
|
|
||||||
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface ISecurityEvent {
|
|||||||
* Security logger for enhanced security monitoring
|
* Security logger for enhanced security monitoring
|
||||||
*/
|
*/
|
||||||
export class SecurityLogger {
|
export class SecurityLogger {
|
||||||
private static instance: SecurityLogger;
|
private static instance: SecurityLogger | undefined;
|
||||||
private securityEvents: ISecurityEvent[] = [];
|
private securityEvents: ISecurityEvent[] = [];
|
||||||
private maxEventHistory: number;
|
private maxEventHistory: number;
|
||||||
private enableNotifications: boolean;
|
private enableNotifications: boolean;
|
||||||
@@ -154,11 +154,13 @@ export class SecurityLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filter.fromTimestamp) {
|
if (filter.fromTimestamp) {
|
||||||
filteredEvents = filteredEvents.filter(event => event.timestamp >= filter.fromTimestamp);
|
const fromTs = filter.fromTimestamp;
|
||||||
|
filteredEvents = filteredEvents.filter(event => event.timestamp >= fromTs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.toTimestamp) {
|
if (filter.toTimestamp) {
|
||||||
filteredEvents = filteredEvents.filter(event => event.timestamp <= filter.toTimestamp);
|
const toTs = filter.toTimestamp;
|
||||||
|
filteredEvents = filteredEvents.filter(event => event.timestamp <= toTs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user