Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0386beb15 | |||
| 1d7e5495fa | |||
| 9a378ae87f | |||
| 58fbc2b1e4 | |||
| 20ea0ce683 | |||
| bcea93753b | |||
| 848515e424 | |||
| 38c9978969 | |||
| ee863b8178 | |||
| 9bb5a8bcc1 | |||
| 5aa07e81c7 | |||
| aec8b72ca3 | |||
| 466654ee4c | |||
| f1a11e3f6a | |||
| e193b3a8eb | |||
| 1bbf31605c | |||
| f2cfa923a0 | |||
| cdc77305e5 | |||
| 835537f789 | |||
| 754b223f62 | |||
| 0a39d50d20 | |||
| de7b9f7ec5 | |||
| bd959464c7 | |||
| 36b629676f | |||
| 19398ea836 | |||
| 4aba8cc353 | |||
| 5fd036eeb6 | |||
| cfcb66f1ee | |||
| 501f4f9de6 | |||
| fa926eb10b | |||
| f2d0a9ec1b | |||
| 035173702d | |||
| 07a3365496 | |||
| 1c4f7dbb11 | |||
| 1fdff79dd0 | |||
| 59b52d08fa | |||
| 2cdc392a40 | |||
| 433047bbf1 | |||
| 0b81c95de2 | |||
| 196e5dfc1b | |||
| 60d095cd78 | |||
| 2861511d20 | |||
| b582d44502 | |||
| 36a2ebc94e | |||
| ed52a3188d | |||
| 93cc5c7b06 | |||
| 5689e93665 | |||
| c224028495 | |||
| 4fbe01823b | |||
| 34ba2c9f02 | |||
| 52aed0e96e | |||
| ea2e618990 | |||
| 140637a307 | |||
| 21c80e173d | |||
| e77fe9451e | |||
| 7971bd249e | |||
| 6099563acd | |||
| bf4c181026 | |||
| d9d12427d3 | |||
| 91aa9a7228 | |||
| 877356b247 | |||
| 2325f01cde | |||
| 00fdadb088 | |||
| 2b76e05a40 | |||
| 1b37944aab | |||
| 35a01a6981 | |||
| 3058706d2a | |||
| 0e4d6a3c0c | |||
| 2bc2475878 | |||
| 37eab7c7b1 | |||
| 8ab7343606 | |||
| f04feec273 | |||
| d320590ce2 | |||
| 0ee57f433b | |||
| b28b5eea84 | |||
| 27d7489af9 | |||
| 940c7dc92e | |||
| 7fa6d82e58 | |||
| f29ed9757e | |||
| ad45d1b8b9 | |||
| 68473f8550 | |||
| 07cfe76cac | |||
| 3775957bf2 | |||
| 31ce18a025 | |||
| 0cccec5526 | |||
| 0373f02f86 | |||
| 52dac0339f | |||
| b6f7f5f63f | |||
| 6271bb1079 | |||
| 0fa65f31c3 | |||
| 93d6c7d341 | |||
| b2ccd54079 | |||
| 4e9b09616d | |||
| ddb420835e | |||
| 505fd044c0 | |||
| 7711204fef | |||
| d7b6fbb241 | |||
| a670b27a1c | |||
| c2f57b086f | |||
| 083f16d7b4 | |||
| 2994b6e686 | |||
| ba15c169d7 | |||
| bbd5707711 | |||
| 1ddf83b28d | |||
| 25365678e0 | |||
| 96d215fc66 | |||
| 648ba9e61d | |||
| fcc1d9fede | |||
| 336e8aa4cc | |||
| c8f19cf783 | |||
| 12b2cc11da | |||
| ffcc35be64 | |||
| 59e0d41bdb | |||
| 9509d87b1e | |||
| b835e2d0eb | |||
| 6c3d8714a2 | |||
| 94f53f0259 | |||
| 1004f8579f | |||
| a77ec6884a | |||
| 6112e4e884 | |||
| 4a6913d4bb | |||
| f6a9e344e5 | |||
| b3296c6522 | |||
| 10a2b922d3 | |||
| ee5cdde225 | |||
| d2e9efccd0 | |||
| a07901a28a | |||
| a3954d6eb5 | |||
| 9685fcd89d | |||
| 74c23ce5ff | |||
| 746fbb15e6 | |||
| 415065b246 | |||
| 30aeef7bbd | |||
| dba1c70fa7 | |||
| f9cfb3d36b | |||
| 43b92b784d | |||
| b62a322c54 | |||
| a3a64e9a02 | |||
| 491e51f40b | |||
| b46247d9cb | |||
| 9c0e46ff4e | |||
| f62bc4a526 | |||
| 8f23600ec1 | |||
| 141f185fbf | |||
| 6f4a5f19e7 | |||
| 9d8354e58f | |||
| 947637eed7 | |||
| 5202c2ea27 | |||
| 6684dc43da | |||
| 04ec387ce5 |
17
AGENTS.md
Normal file
17
AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Agent Instructions for dcrouter
|
||||
|
||||
## Database & Migrations
|
||||
|
||||
### Collection Names
|
||||
smartdata uses the **exact class name** as the MongoDB collection name. No lowercasing.
|
||||
- `StoredRouteDoc` → collection `StoredRouteDoc`
|
||||
- `TargetProfileDoc` → collection `TargetProfileDoc`
|
||||
- `RouteDoc` → collection `RouteDoc`
|
||||
|
||||
When writing migrations in `ts_migrations/index.ts`, use the exact class name casing in `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
|
||||
|
||||
### Migration Rules
|
||||
- All DB schema migrations go EXCLUSIVELY in `ts_migrations/index.ts` as smartmigration steps.
|
||||
- NEVER put migration logic in application code (services, managers, startup hooks).
|
||||
- Migration step `.to()` version must match the release version so smartmigration can plan the step.
|
||||
- Steps must be idempotent — smartmigration may re-run them in skip-forward resume mode.
|
||||
480
changelog.md
480
changelog.md
@@ -1,5 +1,485 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-14 - 13.18.0 - feat(email)
|
||||
add persistent smartmta storage and runtime-managed email domain syncing
|
||||
|
||||
- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence
|
||||
- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart
|
||||
- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation
|
||||
|
||||
## 2026-04-14 - 13.17.9 - fix(monitoring)
|
||||
align domain activity metrics with id-keyed route data
|
||||
|
||||
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
|
||||
- Add a regression test covering domain activity aggregation for routes identified only by id.
|
||||
- Update the network activity UI to show formatted total connection counts in the active connections card.
|
||||
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
|
||||
|
||||
## 2026-04-14 - 13.17.8 - fix(opsserver)
|
||||
align certificate status handling with the updated smartproxy response format
|
||||
|
||||
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
|
||||
- bump @push.rocks/smartproxy to ^27.7.3
|
||||
- enable verbose output for the test script
|
||||
|
||||
## 2026-04-14 - 13.17.7 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-14 - 13.17.6 - fix(dns,routes)
|
||||
keep DoH socket-handler routes runtime-only and prune stale persisted entries
|
||||
|
||||
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
|
||||
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
|
||||
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
|
||||
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
|
||||
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
|
||||
|
||||
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
|
||||
normalize target profile route references and stabilize VPN host-IP client routing behavior
|
||||
|
||||
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
|
||||
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
|
||||
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
|
||||
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
|
||||
|
||||
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
|
||||
sync route filter toggle selection via component changeSubject
|
||||
|
||||
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
|
||||
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
|
||||
|
||||
## 2026-04-13 - 13.17.2 - fix(monitoring)
|
||||
exclude unconfigured routes from domain activity aggregation
|
||||
|
||||
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
|
||||
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
|
||||
|
||||
## 2026-04-13 - 13.17.1 - fix(monitoring)
|
||||
stop allocating route metrics to domains when no request data exists
|
||||
|
||||
- Removes the equal-split fallback for shared routes in MetricsManager.
|
||||
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
|
||||
|
||||
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
|
||||
add request-based domain activity metrics and split routes into user and system views
|
||||
|
||||
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
|
||||
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
|
||||
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
|
||||
|
||||
## 2026-04-13 - 13.16.2 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^27.6.0
|
||||
|
||||
- updates @push.rocks/smartproxy from ^27.5.0 to ^27.6.0 in package.json
|
||||
|
||||
## 2026-04-13 - 13.16.1 - fix(migrations)
|
||||
use exact smartdata collection names in route unification migration
|
||||
|
||||
- Update the 13.16.0 migration to rename StoredRouteDoc to RouteDoc using case-sensitive collection names
|
||||
- Apply the origin backfill against the RouteDoc collection and drop RouteOverrideDoc with matching class-name casing
|
||||
- Clarify migration description and comments to reflect smartdata's exact class-name collection mapping
|
||||
|
||||
## 2026-04-13 - 13.16.0 - feat(routes)
|
||||
unify route storage and management across config, email, dns, and API origins
|
||||
|
||||
- Persist config-, email-, and dns-seeded routes in the database alongside API-created routes using a single RouteDoc model with origin tracking
|
||||
- Remove hardcoded-route override handling in favor of direct route CRUD and toggle operations by route id across the API client, handlers, and web UI
|
||||
- Add a migration that renames stored route storage, sets migrated routes to origin="api", and drops obsolete route override data
|
||||
|
||||
## 2026-04-13 - 13.15.1 - fix(monitoring)
|
||||
improve domain activity aggregation for multi-domain and wildcard routes
|
||||
|
||||
- map route metrics across all configured domains instead of only the first domain
|
||||
- resolve wildcard domain patterns against active protocol cache entries
|
||||
- distribute shared route traffic across matched domains and preserve fallback reporting for routes without domain configuration
|
||||
|
||||
## 2026-04-13 - 13.15.0 - feat(stats)
|
||||
add typed network stats response fields for bandwidth, domain activity, and protocol distribution
|
||||
|
||||
- extends the network stats request interface with top IP bandwidth, domain activity, and frontend/backend protocol distribution data
|
||||
- updates app state to use a typed getNetworkStats request instead of casting the response to any
|
||||
|
||||
## 2026-04-13 - 13.14.0 - feat(network)
|
||||
add bandwidth-ranked IP and domain activity metrics to network monitoring
|
||||
|
||||
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
|
||||
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
|
||||
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
|
||||
|
||||
## 2026-04-13 - 13.13.0 - feat(dns)
|
||||
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
|
||||
|
||||
- adds domain migration support in DnsManager, API handlers, request interfaces, app state, and domains UI
|
||||
- routes ACME DNS-01 challenges through managed domains using createRecord/deleteRecord for both dcrouter-hosted and provider-managed zones
|
||||
- enables immediate unregister of deleted dcrouter-hosted DNS records from the embedded DNS server
|
||||
|
||||
## 2026-04-12 - 13.12.0 - feat(email-domains)
|
||||
support creating email domains on optional subdomains
|
||||
|
||||
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
|
||||
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
|
||||
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
|
||||
|
||||
## 2026-04-12 - 13.11.0 - feat(email-domains)
|
||||
add email domain management with DNS provisioning, validation, and ops dashboard support
|
||||
|
||||
- Introduce EmailDomainManager with persisted email domain records, DKIM configuration, DNS record generation, provisioning, and validation.
|
||||
- Add opsserver typed request handlers and shared interfaces for listing, creating, updating, deleting, validating, and provisioning email domains.
|
||||
- Add ops dashboard email domains view and app state integration for managing domains and inspecting required DNS records.
|
||||
|
||||
## 2026-04-12 - 13.10.0 - feat(web-ui)
|
||||
standardize settings views for ACME and email security panels
|
||||
|
||||
- replace custom ACME settings layouts with the reusable dees-settings component for configured and empty states
|
||||
- update the email security view to present settings through dees-settings and open a modal-based read-only edit dialog
|
||||
- bump @design.estate/dees-catalog to ^3.78.0 to support the updated UI components
|
||||
|
||||
## 2026-04-12 - 13.9.2 - fix(web-ui)
|
||||
improve form field descriptions and align certificate settings with tile components
|
||||
|
||||
- Refines labels and adds descriptive helper text across API token, DNS, domain, route, edge, target profile, and VPN forms for clearer operator input
|
||||
- Updates the DNS provider form to surface provider and credential guidance through built-in input metadata instead of custom help blocks
|
||||
- Restyles the certificates ACME settings section to use tile-based layout and improves related form wording and file upload metadata
|
||||
- Refreshes the Cloudflare DNS provider description and bumps UI-related dependencies
|
||||
|
||||
## 2026-04-08 - 13.9.1 - fix(network-ui)
|
||||
enable flashing table updates for network activity, remote ingress, and VPN views
|
||||
|
||||
- adds stable row keys to dees-table instances so existing rows can be diffed correctly
|
||||
- enables flash highlighting for changed rows and cells across network activity, top IPs, backends, remote ingress edges, and VPN clients
|
||||
- updates network activity request data on every refresh so live metrics like duration and byte counts visibly refresh
|
||||
|
||||
## 2026-04-08 - 13.9.0 - feat(dns)
|
||||
add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local
|
||||
|
||||
- Expose a synthetic built-in "DcRouter" provider in provider listings and block create, edit, delete, test, and external domain listing operations for it
|
||||
- Rename domain and record source semantics from "manual" to "dcrouter" and "local" across backend, interfaces, and UI
|
||||
- Add database migrations to convert existing DomainDoc.source and DnsRecordDoc.source values to the new naming
|
||||
- Update domain creation flows and provider UI labels to reflect dcrouter-hosted authoritative domains
|
||||
|
||||
## 2026-04-08 - 13.8.0 - feat(acme)
|
||||
add DB-backed ACME configuration management and OpsServer certificate settings UI
|
||||
|
||||
- introduces a singleton AcmeConfig manager and document persisted in the database, with first-boot seeding from legacy tls.contactEmail and smartProxyConfig.acme options
|
||||
- updates SmartProxy startup to read live ACME settings from the database and only enable DNS-01 challenge wiring when ACME is configured and enabled
|
||||
- adds authenticated OpsServer typed request endpoints and API token scopes for reading and updating ACME configuration
|
||||
- adds web app state and a certificates view card/modal for viewing and editing ACME settings from the Domains certificate UI
|
||||
|
||||
## 2026-04-08 - 13.7.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.7.0 - feat(dns-providers)
|
||||
add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows
|
||||
|
||||
- Introduce shared DNS provider type descriptors and credential field metadata to drive provider forms dynamically.
|
||||
- Add a reusable dns-provider-form component and update provider create/edit dialogs to use typed provider selection and credential handling.
|
||||
- Remove Cloudflare-specific ACME helper exposure and clarify provider-agnostic DNS manager and factory documentation for future provider implementations.
|
||||
|
||||
## 2026-04-08 - 13.6.0 - feat(dns)
|
||||
add db-backed DNS provider, domain, and record management with ops UI support
|
||||
|
||||
- introduce DnsManager-backed persistence for DNS providers, domains, and records with Cloudflare provider support
|
||||
- replace constructor-based ACME DNS challenge configuration with provider records stored in the database
|
||||
- add opsserver typed request handlers and API token scopes for managing DNS providers, domains, and records
|
||||
- add a new Domains section in the ops UI for providers, domains, DNS records, and certificates
|
||||
|
||||
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
|
||||
add admin user listing to the access dashboard
|
||||
|
||||
- register a new admin-only typed request endpoint to list users with id, username, and role while excluding passwords
|
||||
- add users state management and a dedicated access dashboard view for browsing OpsServer user accounts
|
||||
- update access routing to include the new users subview and improve related table filtering and section headings
|
||||
|
||||
## 2026-04-08 - 13.4.2 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.4.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.4.0 - feat(web-ui)
|
||||
reorganize dashboard views into grouped navigation with new email, access, and network subviews
|
||||
|
||||
- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security.
|
||||
- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation.
|
||||
- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior.
|
||||
- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling.
|
||||
|
||||
## 2026-04-08 - 13.3.0 - feat(web-ui)
|
||||
reorganize network and security views into tabbed subviews with route-aware navigation
|
||||
|
||||
- add URL-based subview support in app state and router for network and security sections
|
||||
- group routes, source profiles, network targets, and target profiles under the network view with tab navigation
|
||||
- split security into dedicated overview, blocked IPs, authentication, and email security subviews
|
||||
- update configuration navigation to deep-link directly to the network routes subview
|
||||
|
||||
## 2026-04-08 - 13.2.2 - fix(project)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.2.1 - fix(project)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.2.0 - feat(ops-ui)
|
||||
add column filters to operations tables across admin views
|
||||
|
||||
- Enable table column filters for API tokens, certificates, network requests, top IPs, backends, network targets, remote ingress edges, security views, source profiles, target profiles, and VPN clients.
|
||||
- Improves filtering and exploration of operational data throughout the admin interface without changing backend behavior.
|
||||
|
||||
## 2026-04-08 - 13.1.3 - fix(certificate-handler)
|
||||
preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
|
||||
|
||||
- add deriveCertDomainName helper to match shared ACME certificate identities across wildcard and subdomain routes
|
||||
- pass includeWildcard when force-renewing certificates so renewed certs keep wildcard SAN coverage for sibling subdomains
|
||||
- persist renewed certificate data to all sibling route domains that share the same cert identity and clear cached certificate status entries
|
||||
- add regression tests for certificate domain derivation and force-renew wildcard handling
|
||||
|
||||
## 2026-04-07 - 13.1.2 - fix(deps)
|
||||
bump @serve.zone/catalog to ^2.12.3
|
||||
|
||||
- Updates @serve.zone/catalog from ^2.12.0 to ^2.12.3 in package.json
|
||||
|
||||
## 2026-04-07 - 13.1.1 - fix(deps)
|
||||
bump catalog-related dependencies to newer patch and minor releases
|
||||
|
||||
- update @design.estate/dees-catalog from ^3.66.0 to ^3.67.1
|
||||
- update @serve.zone/catalog from ^2.11.2 to ^2.12.0
|
||||
|
||||
## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations)
|
||||
add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips
|
||||
|
||||
- runs smartmigration at startup before configuration is loaded and adds a migration for target profile targets from host to ip
|
||||
- changes VPN client routing to always force traffic through SmartProxy while allowing direct target bypasses from target profiles
|
||||
- supports domain-scoped VPN ipAllowList entries for vpnOnly routes based on matching target profile domains
|
||||
- updates certificate reprovisioning to reapply routes so renewed certificates are loaded into the running proxy
|
||||
- removes the forceDestinationSmartproxy VPN client option from API, persistence, manager, and web UI
|
||||
|
||||
## 2026-04-06 - 13.0.11 - fix(routing)
|
||||
serialize route updates and correct VPN-gated route application
|
||||
|
||||
- RouteConfigManager now serializes concurrent applyRoutes calls to prevent overlapping SmartProxy updates and stale route overwrites.
|
||||
- VPN-only routes deny access until VPN state is ready, then re-apply routes after VPN clients load or change to refresh ipAllowLists safely.
|
||||
- Certificate provisioning retries now go through RouteConfigManager when available so the full merged route set is reapplied consistently.
|
||||
- Reference resolution now expands network targets with multiple hosts into multiple route targets.
|
||||
- Adds rollback when VPN client persistence fails, enforces unique target profile names, and fixes maxConnections parsing in the source profiles UI.
|
||||
|
||||
## 2026-04-06 - 13.0.10 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-06 - 13.0.9 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-06 - 13.0.8 - fix(ops-view-vpn)
|
||||
show target profile names in VPN forms and load profile candidates for autocomplete
|
||||
|
||||
- fetch target profiles when the VPN operations view connects so profile data is available in the UI
|
||||
- replace comma-separated target profile ID inputs with a restricted autocomplete list based on available target profiles
|
||||
- map stored target profile IDs to profile names for table and detail displays, while resolving selected names back to IDs on save
|
||||
|
||||
## 2026-04-06 - 13.0.7 - fix(vpn,target-profiles)
|
||||
refresh VPN client security when target profiles change and include profile target IPs in direct destination allow-lists
|
||||
|
||||
- Adds direct target IP resolution from target profiles so forced SmartProxy clients can bypass rewriting for explicit profile targets.
|
||||
- Refreshes running VPN client security policies after target profile updates or deletions to keep destination access rules in sync.
|
||||
|
||||
## 2026-04-05 - 13.0.6 - fix(certificates)
|
||||
resolve base-domain certificate lookups and route profile list inputs
|
||||
|
||||
- Look up ACME certificate metadata by base domain first, with fallback to the exact domain, so subdomain certificate status and deletion work reliably.
|
||||
- Trigger certificate reprovisioning through SmartProxy routes and clear cached status before refresh, including force-renew cache invalidation handling.
|
||||
- Replace comma-separated target profile form fields with list inputs and route suggestions for domains, targets, and route references.
|
||||
|
||||
## 2026-04-05 - 13.0.5 - fix(ts_web)
|
||||
replace custom section heading component with dees-heading across ops views
|
||||
|
||||
- updates all operations dashboard views to use <dees-heading level="2"> for section titles
|
||||
- removes the unused shared ops-sectionheading component export and source file
|
||||
- bumps UI and data layer dependencies to compatible patch/minor releases
|
||||
|
||||
## 2026-04-05 - 13.0.4 - fix(deps)
|
||||
bump @push.rocks/smartdata and @push.rocks/smartdb to the latest patch releases
|
||||
|
||||
- Updates @push.rocks/smartdata from ^7.1.4 to ^7.1.5
|
||||
- Updates @push.rocks/smartdb from ^2.5.2 to ^2.5.4
|
||||
|
||||
## 2026-04-05 - 13.0.3 - fix(deps)
|
||||
bump @push.rocks/smartdb to ^2.5.2
|
||||
|
||||
- Updates @push.rocks/smartdb from ^2.5.1 to ^2.5.2 in package.json.
|
||||
|
||||
## 2026-04-05 - 13.0.2 - fix(deps)
|
||||
bump smartdata, smartdb, and catalog dependencies
|
||||
|
||||
- updates @push.rocks/smartdata from ^7.1.3 to ^7.1.4
|
||||
- updates @push.rocks/smartdb from ^2.4.1 to ^2.5.1
|
||||
- updates @serve.zone/catalog from ^2.11.1 to ^2.11.2
|
||||
|
||||
## 2026-04-05 - 13.0.1 - fix(deps)
|
||||
bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies
|
||||
|
||||
- updates @design.estate/dees-catalog from ^3.55.6 to ^3.59.1
|
||||
- updates @push.rocks/smartdb from ^2.3.1 to ^2.4.1
|
||||
|
||||
## 2026-04-05 - 13.0.0 - BREAKING CHANGE(vpn)
|
||||
replace tag-based VPN access control with source and target profiles
|
||||
|
||||
- Renames Security Profiles to Source Profiles across APIs, persistence, route metadata, tests, and UI.
|
||||
- Adds TargetProfile management, storage, API handlers, and dashboard views to define VPN-accessible domains, targets, and route references.
|
||||
- Replaces route-level vpn configuration with vpnOnly and switches VPN clients from serverDefinedClientTags to targetProfileIds for access resolution.
|
||||
- Updates route application and VPN AllowedIPs generation to derive client access from matching target profiles instead of tags.
|
||||
|
||||
## 2026-04-04 - 12.10.0 - feat(routes)
|
||||
add TLS configuration controls for route create and edit flows
|
||||
|
||||
- Adds TLS mode and certificate selection to the route create and edit dialogs, including support for custom PEM key/certificate input.
|
||||
- Allows route updates to explicitly remove nested TLS settings by treating null action properties as deletions during route patch merging.
|
||||
- Bumps @design.estate/dees-catalog to ^3.55.6 and @serve.zone/catalog to ^2.11.1.
|
||||
|
||||
## 2026-04-04 - 12.9.4 - fix(deps)
|
||||
bump @push.rocks/smartdb to ^2.3.1
|
||||
|
||||
- updates the @push.rocks/smartdb dependency from ^2.1.1 to ^2.3.1
|
||||
|
||||
## 2026-04-04 - 12.9.3 - fix(route-management)
|
||||
include stored VPN routes in domain resolution and align programmatic route types with dcrouter configs
|
||||
|
||||
- Scans enabled stored/programmatic routes for VPN domain matches when resolving client access domains.
|
||||
- Replaces generic smartproxy route typings with IDcRouterRouteConfig across route management and stored route models.
|
||||
- Updates @push.rocks/smartproxy to ^27.4.0.
|
||||
|
||||
## 2026-04-04 - 12.9.2 - fix(config-ui)
|
||||
handle missing HTTP/3 config safely and standardize overview section headings
|
||||
|
||||
- Prevents route augmentation logic from failing when HTTP/3 configuration is undefined by using optional chaining.
|
||||
- Updates the operations overview to use dees-heading components for activity, email, DNS, RADIUS, and VPN section headings.
|
||||
- Bumps @push.rocks/smartproxy from ^27.2.0 to ^27.3.1.
|
||||
|
||||
## 2026-04-04 - 12.9.1 - fix(monitoring)
|
||||
update SmartProxy and use direct connection protocol metrics access
|
||||
|
||||
- bump @push.rocks/smartproxy from ^27.1.0 to ^27.2.0
|
||||
- replace fallback any-based access with direct frontend and backend protocol metric calls in MetricsManager
|
||||
|
||||
## 2026-04-04 - 12.9.0 - feat(monitoring)
|
||||
add frontend and backend protocol distribution metrics to network stats
|
||||
|
||||
- Expose frontend and backend protocol distribution data in monitoring metrics, stats responses, and shared interfaces.
|
||||
- Render protocol distribution donut charts in the ops network view using the new stats fields.
|
||||
- Preserve existing stored certificate IDs when updating certificate records by domain.
|
||||
- Bump @design.estate/dees-catalog to ^3.55.5 for the new chart component support.
|
||||
|
||||
## 2026-04-04 - 12.8.1 - fix(ops-view-routes)
|
||||
correct route form dropdown selection handling for security profiles and network targets
|
||||
|
||||
- Update route edit and create forms to use selectedOption for dropdowns backed by the newer dees-catalog version
|
||||
- Normalize submitted dropdown values to extract option keys before storing securityProfileRef and networkTargetRef
|
||||
- Refresh documentation to reflect expanded stats coverage for network, RADIUS, and VPN metrics
|
||||
|
||||
## 2026-04-03 - 12.8.0 - feat(certificates)
|
||||
add force renew option for domain certificate reprovisioning
|
||||
|
||||
- pass an optional forceRenew flag through certificate reprovision requests from the UI to the ops handler
|
||||
- use smartacme forceRenew support and return renewal-specific success messages
|
||||
- update the SmartAcme dependency to version ^9.4.0
|
||||
|
||||
## 2026-04-03 - 12.7.0 - feat(opsserver)
|
||||
add RADIUS and VPN metrics to combined ops stats and overview dashboards, and stream live log buffer entries in follow mode
|
||||
|
||||
- Expose RADIUS and VPN sections in the combined stats API and shared TypeScript interfaces
|
||||
- Populate frontend app state and overview tiles with RADIUS authentication, session, traffic, and VPN client metrics
|
||||
- Replace simulated follow-mode log events with real log buffer tailing and timestamp-based incremental streaming
|
||||
- Use commit metadata for reported server version instead of a hardcoded value
|
||||
|
||||
## 2026-04-03 - 12.6.6 - fix(deps)
|
||||
bump @design.estate/dees-catalog to ^3.52.3
|
||||
|
||||
- Updates @design.estate/dees-catalog from ^3.52.2 to ^3.52.3 in package.json
|
||||
|
||||
## 2026-04-03 - 12.6.5 - fix(deps)
|
||||
bump @design.estate/dees-catalog to ^3.52.2
|
||||
|
||||
- Updates the @design.estate/dees-catalog dependency from ^3.52.0 to ^3.52.2 in package.json.
|
||||
|
||||
## 2026-04-03 - 12.6.4 - fix(deps)
|
||||
bump @design.estate/dees-catalog to ^3.52.0
|
||||
|
||||
- Updates the @design.estate/dees-catalog dependency from ^3.51.2 to ^3.52.0 in package.json.
|
||||
|
||||
## 2026-04-03 - 12.6.3 - fix(deps)
|
||||
bump @types/node and @design.estate/dees-catalog patch versions
|
||||
|
||||
- updates @types/node from ^25.5.1 to ^25.5.2
|
||||
- updates @design.estate/dees-catalog from ^3.51.1 to ^3.51.2
|
||||
|
||||
## 2026-04-03 - 12.6.2 - fix(deps)
|
||||
bump @design.estate/dees-catalog to ^3.51.1
|
||||
|
||||
- Updates @design.estate/dees-catalog from ^3.51.0 to ^3.51.1 in package.json
|
||||
|
||||
## 2026-04-03 - 12.6.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-03 - 12.6.0 - feat(certificates)
|
||||
add confirmation before force renewing valid certificates from the certificate actions menu
|
||||
|
||||
- Expose the Reprovision action in the certificate context menu
|
||||
- Prompt for confirmation when reprovisioning a certificate that is still valid
|
||||
- Update dees-catalog and @types/node dependencies
|
||||
|
||||
## 2026-04-03 - 12.5.2 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-03 - 12.5.1 - fix(ops-view-network)
|
||||
centralize traffic chart timing constants for consistent rolling window updates
|
||||
|
||||
- Defines shared constants for the chart window, update interval, and maximum buffered data points
|
||||
- Replaces hardcoded traffic history sizes and timer intervals with derived values across initialization, history loading, and live updates
|
||||
- Keeps the chart rolling window configuration aligned with the in-memory traffic buffer
|
||||
|
||||
## 2026-04-02 - 12.5.0 - feat(ops-view-routes)
|
||||
add priority support and list-based domain editing for routes
|
||||
|
||||
- Adds a priority field to route create and edit forms so route matching order can be configured.
|
||||
- Replaces comma-separated domain text input with a list-based domain editor and updates form handling to persist domains as arrays.
|
||||
|
||||
## 2026-04-02 - 12.4.0 - feat(routes)
|
||||
add route edit and delete actions to the ops routes view
|
||||
|
||||
- introduces an update route action in web app state and refreshes merged routes after changes
|
||||
- adds edit and delete handlers with modal-based confirmation and route form inputs for programmatic routes
|
||||
- enables realtime chart window configuration in network and overview dashboards
|
||||
- bumps @serve.zone/catalog to ^2.11.0
|
||||
|
||||
## 2026-04-02 - 12.3.0 - feat(docs,ops-dashboard)
|
||||
document unified database and reusable security profile and network target management
|
||||
|
||||
- Update project and interface documentation to replace separate storage/cache configuration with a unified database model
|
||||
- Document new security profile and network target APIs, data models, and dashboard capabilities
|
||||
- Add a global dashboard warning when the database is disabled so unavailable management features are clearly indicated
|
||||
- Bump @design.estate/dees-catalog and @serve.zone/catalog to support the updated dashboard experience
|
||||
|
||||
## 2026-04-02 - 12.2.6 - fix(ops-ui)
|
||||
improve operations table actions and modal form handling for profiles and network targets
|
||||
|
||||
- adds section headings for the Security Profiles and Network Targets views
|
||||
- updates edit and delete actions to support in-row table actions in addition to context menus
|
||||
- makes create and edit dialogs query forms safely from modal content and adds early returns when forms are unavailable
|
||||
- enables the database configuration in the development watch server
|
||||
|
||||
## 2026-04-02 - 12.2.5 - fix(dcrouter)
|
||||
sync allowed tunnel edges when merged routes change
|
||||
|
||||
- Triggers tunnelManager.syncAllowedEdges() after route updates are applied
|
||||
- Keeps derived ports in the Rust hub binary aligned with merged route changes
|
||||
|
||||
## 2026-04-02 - 12.2.4 - fix(routes)
|
||||
support profile and target metadata in route creation and refresh remote ingress routes after config initialization
|
||||
|
||||
|
||||
30
package.json
30
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "12.2.4",
|
||||
"version": "13.18.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||
"start": "(node ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
@@ -27,7 +27,8 @@
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.5.0"
|
||||
"@types/node": "^25.6.0",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
@@ -35,38 +36,39 @@
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.49.2",
|
||||
"@design.estate/dees-catalog": "^3.78.2",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.3.1",
|
||||
"@push.rocks/smartdata": "^7.1.3",
|
||||
"@push.rocks/smartdb": "^2.1.1",
|
||||
"@push.rocks/smartacme": "^9.5.0",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartdb": "^2.6.2",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmta": "^5.3.1",
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartmigration": "1.2.0",
|
||||
"@push.rocks/smartmta": "^5.3.3",
|
||||
"@push.rocks/smartnetwork": "^4.6.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.1.0",
|
||||
"@push.rocks/smartproxy": "^27.7.4",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.1",
|
||||
"@push.rocks/smartvpn": "1.19.2",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.9.1",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.2.7",
|
||||
"lru-cache": "^11.3.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
2731
pnpm-lock.yaml
generated
2731
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
142
readme.md
142
readme.md
@@ -25,7 +25,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- [Remote Ingress](#remote-ingress)
|
||||
- [VPN Access Control](#vpn-access-control)
|
||||
- [Certificate Management](#certificate-management)
|
||||
- [Storage & Caching](#storage--caching)
|
||||
- [Storage & Database](#storage--database)
|
||||
- [Security Features](#security-features)
|
||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||
- [API Client](#api-client)
|
||||
@@ -93,10 +93,11 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **Socket-handler mode** — direct socket passing eliminates internal port hops
|
||||
- **Real-time metrics** via SmartMetrics (CPU, memory, connections, throughput)
|
||||
|
||||
### 💾 Persistent Storage & Caching
|
||||
- **Multiple storage backends**: filesystem, custom functions, or in-memory
|
||||
- **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
|
||||
### 💾 Unified Database
|
||||
- **Two deployment modes**: embedded LocalSmartDb (zero-config) or external MongoDB
|
||||
- **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
|
||||
- **Reusable references** — security profiles and network targets that propagate changes to all referencing routes
|
||||
|
||||
### 🖥️ OpsServer Dashboard
|
||||
- **Web-based management interface** with real-time monitoring
|
||||
@@ -104,7 +105,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||
- **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
|
||||
@@ -269,11 +272,8 @@ const router = new DcRouter({
|
||||
],
|
||||
},
|
||||
|
||||
// Persistent storage
|
||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
||||
|
||||
// Cache database
|
||||
cacheConfig: { enabled: true, storagePath: '~/.serve.zone/dcrouter/tsmdb' },
|
||||
// Unified database (embedded LocalSmartDb or external MongoDB)
|
||||
dbConfig: { enabled: true },
|
||||
|
||||
// TLS & ACME
|
||||
tls: { contactEmail: 'admin@example.com' },
|
||||
@@ -311,8 +311,7 @@ graph TB
|
||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||
OS[OpsServer Dashboard]
|
||||
MM[Metrics Manager]
|
||||
SM[Storage Manager]
|
||||
CD[Cache Database]
|
||||
DB2[DcRouterDb<br/><i>smartdata + smartdb</i>]
|
||||
end
|
||||
|
||||
subgraph "Backend Services"
|
||||
@@ -339,8 +338,7 @@ graph TB
|
||||
DC --> CM
|
||||
DC --> OS
|
||||
DC --> MM
|
||||
DC --> SM
|
||||
DC --> CD
|
||||
DC --> DB2
|
||||
|
||||
SP --> WEB
|
||||
SP --> API
|
||||
@@ -365,8 +363,7 @@ graph TB
|
||||
| **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 |
|
||||
| **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) |
|
||||
| **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
|
||||
| **DcRouterDb** | `@push.rocks/smartdata` + `@push.rocks/smartdb` | Unified database — embedded LocalSmartDb or external MongoDB for all persistence |
|
||||
|
||||
### How It Works
|
||||
|
||||
@@ -509,24 +506,16 @@ interface IDcRouterOptions {
|
||||
};
|
||||
dnsChallenge?: { cloudflareApiKey?: string };
|
||||
|
||||
// ── Storage & Caching ─────────────────────────────────────────
|
||||
storage?: {
|
||||
fsPath?: string;
|
||||
readFunction?: (key: string) => Promise<string>;
|
||||
writeFunction?: (key: string, value: string) => Promise<void>;
|
||||
};
|
||||
cacheConfig?: {
|
||||
// ── Database ────────────────────────────────────────────────────
|
||||
/** Unified database for all persistence (routes, certs, VPN, RADIUS, etc.) */
|
||||
dbConfig?: {
|
||||
enabled?: boolean; // default: true
|
||||
mongoDbUrl?: string; // External MongoDB URL (omit for embedded LocalSmartDb)
|
||||
storagePath?: string; // default: '~/.serve.zone/dcrouter/tsmdb'
|
||||
dbName?: string; // default: 'dcrouter'
|
||||
cleanupIntervalHours?: number; // default: 1
|
||||
ttlConfig?: {
|
||||
emails?: number; // default: 30 days
|
||||
ipReputation?: number; // default: 1 day
|
||||
bounces?: number; // default: 30 days
|
||||
dkimKeys?: number; // default: 90 days
|
||||
suppression?: number; // default: 30 days
|
||||
};
|
||||
seedOnEmpty?: boolean; // Seed default profiles/targets if DB is empty
|
||||
seedData?: object; // Custom seed data
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -1213,49 +1202,55 @@ The OpsServer includes a **Certificates** view showing:
|
||||
- One-click reprovisioning per domain
|
||||
- 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
|
||||
// Filesystem backend
|
||||
storage: { fsPath: '/var/lib/dcrouter/data' }
|
||||
|
||||
// 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
|
||||
dbConfig: { enabled: true }
|
||||
// Data stored at ~/.serve.zone/dcrouter/tsmdb by default
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
|
||||
Connect to an existing MongoDB instance:
|
||||
|
||||
```typescript
|
||||
cacheConfig: {
|
||||
dbConfig: {
|
||||
enabled: true,
|
||||
storagePath: '~/.serve.zone/dcrouter/tsmdb',
|
||||
mongoDbUrl: 'mongodb://localhost:27017',
|
||||
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
|
||||
|
||||
@@ -1324,6 +1319,8 @@ The OpsServer provides a web-based management interface served on port 3000 by d
|
||||
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
||||
| 🌍 **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 |
|
||||
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
||||
@@ -1410,6 +1407,22 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
||||
'setVlanMapping' // Add/update VLAN mapping
|
||||
'removeVlanMapping' // Remove VLAN mapping
|
||||
'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
|
||||
@@ -1518,12 +1531,12 @@ const router = new DcRouter(options: IDcRouterOptions);
|
||||
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
||||
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
||||
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
|
||||
| `storageManager` | `StorageManager` | Storage backend |
|
||||
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
||||
| `metricsManager` | `MetricsManager` | Metrics collector |
|
||||
| `cacheDb` | `CacheDb` | Cache database instance |
|
||||
| `certProvisionScheduler` | `CertProvisionScheduler` | Per-domain backoff scheduler for cert provisioning |
|
||||
| `certificateStatusMap` | `Map<string, ...>` | Domain-keyed certificate status from SmartProxy events |
|
||||
| `dcRouterDb` | `DcRouterDb` | Unified database instance (smartdata + smartdb) |
|
||||
| `routeConfigManager` | `RouteConfigManager` | Programmatic route CRUD manager |
|
||||
| `apiTokenManager` | `ApiTokenManager` | API token management |
|
||||
| `referenceResolver` | `ReferenceResolver` | Security profile and network target resolver |
|
||||
|
||||
### Re-exported Types
|
||||
|
||||
@@ -1589,7 +1602,8 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
|
||||
| `test.jwt-auth.ts` | JWT login, verification, logout, invalid credentials | 8 |
|
||||
| `test.opsserver-api.ts` | Health, statistics, configuration, log APIs | 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
|
||||
|
||||
|
||||
@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
||||
},
|
||||
source: 'programmatic',
|
||||
id: 'route-123',
|
||||
enabled: true,
|
||||
overridden: false,
|
||||
storedRouteId: 'route-123',
|
||||
origin: 'api',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
});
|
||||
|
||||
expect(route.name).toEqual('test-route');
|
||||
expect(route.source).toEqual('programmatic');
|
||||
expect(route.id).toEqual('route-123');
|
||||
expect(route.enabled).toEqual(true);
|
||||
expect(route.overridden).toEqual(false);
|
||||
expect(route.storedRouteId).toEqual('route-123');
|
||||
expect(route.origin).toEqual('api');
|
||||
expect(route.routeConfig.match.ports).toEqual(443);
|
||||
});
|
||||
|
||||
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const route = new Route(client, {
|
||||
route: {
|
||||
name: 'hardcoded-route',
|
||||
match: { ports: 80 },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
|
||||
},
|
||||
source: 'hardcoded',
|
||||
enabled: true,
|
||||
overridden: false,
|
||||
// No storedRouteId for hardcoded routes
|
||||
});
|
||||
|
||||
let updateError: Error | undefined;
|
||||
try {
|
||||
await route.update({ name: 'new-name' });
|
||||
} catch (e) {
|
||||
updateError = e as Error;
|
||||
}
|
||||
expect(updateError).toBeTruthy();
|
||||
expect(updateError!.message).toInclude('hardcoded');
|
||||
|
||||
let deleteError: Error | undefined;
|
||||
try {
|
||||
await route.delete();
|
||||
} catch (e) {
|
||||
deleteError = e as Error;
|
||||
}
|
||||
expect(deleteError).toBeTruthy();
|
||||
|
||||
let toggleError: Error | undefined;
|
||||
try {
|
||||
await route.toggle(false);
|
||||
} catch (e) {
|
||||
toggleError = e as Error;
|
||||
}
|
||||
expect(toggleError).toBeTruthy();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Certificate resource class
|
||||
// =============================================================================
|
||||
|
||||
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();
|
||||
@@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
|
||||
// Verify unified email server was initialized
|
||||
expect(router.emailServer).toBeTruthy();
|
||||
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
|
||||
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
|
||||
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
|
||||
|
||||
// Stop the router
|
||||
await router.stop();
|
||||
|
||||
230
test/test.dns-runtime-routes.node.ts
Normal file
230
test/test.dns-runtime-routes.node.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { RouteConfigManager } from '../ts/config/index.js';
|
||||
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
|
||||
import { DnsManager } from '../ts/dns/manager.dns.js';
|
||||
import { logger } from '../ts/logger.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearTestState = async () => {
|
||||
for (const route of await RouteDoc.findAll()) {
|
||||
await route.delete();
|
||||
}
|
||||
for (const domain of await DomainDoc.findAll()) {
|
||||
await domain.delete();
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('RouteConfigManager applies runtime DoH routes without persisting them', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: { routes: [] },
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => (dcRouter as any).generateDnsRoutes(),
|
||||
);
|
||||
|
||||
await routeManager.initialize([], [], []);
|
||||
await routeManager.applyRoutes();
|
||||
|
||||
const persistedRoutes = await RouteDoc.findAll();
|
||||
expect(persistedRoutes.length).toEqual(0);
|
||||
expect(appliedRoutes.length).toEqual(2);
|
||||
|
||||
for (const routeSet of appliedRoutes) {
|
||||
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
|
||||
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
|
||||
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(resolveRoute).toBeDefined();
|
||||
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
|
||||
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager removes stale persisted DoH socket-handler routes on startup', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const staleDnsQueryRoute = new RouteDoc();
|
||||
staleDnsQueryRoute.id = 'stale-doh-query';
|
||||
staleDnsQueryRoute.route = {
|
||||
name: 'dns-over-https-dns-query',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['ns1.example.com'],
|
||||
path: '/dns-query',
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
} as any,
|
||||
};
|
||||
staleDnsQueryRoute.enabled = true;
|
||||
staleDnsQueryRoute.createdAt = Date.now();
|
||||
staleDnsQueryRoute.updatedAt = Date.now();
|
||||
staleDnsQueryRoute.createdBy = 'test';
|
||||
staleDnsQueryRoute.origin = 'dns';
|
||||
await staleDnsQueryRoute.save();
|
||||
|
||||
const staleResolveRoute = new RouteDoc();
|
||||
staleResolveRoute.id = 'stale-doh-resolve';
|
||||
staleResolveRoute.route = {
|
||||
name: 'dns-over-https-resolve',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['ns1.example.com'],
|
||||
path: '/resolve',
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
} as any,
|
||||
};
|
||||
staleResolveRoute.enabled = true;
|
||||
staleResolveRoute.createdAt = Date.now();
|
||||
staleResolveRoute.updatedAt = Date.now();
|
||||
staleResolveRoute.createdBy = 'test';
|
||||
staleResolveRoute.origin = 'dns';
|
||||
await staleResolveRoute.save();
|
||||
|
||||
const validRoute = new RouteDoc();
|
||||
validRoute.id = 'valid-forward-route';
|
||||
validRoute.route = {
|
||||
name: 'valid-forward-route',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['app.example.com'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
tls: { mode: 'terminate' as const },
|
||||
},
|
||||
} as any;
|
||||
validRoute.enabled = true;
|
||||
validRoute.createdAt = Date.now();
|
||||
validRoute.updatedAt = Date.now();
|
||||
validRoute.createdBy = 'test';
|
||||
validRoute.origin = 'api';
|
||||
await validRoute.save();
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(() => smartProxy as any);
|
||||
await routeManager.initialize([], [], []);
|
||||
|
||||
expect((await RouteDoc.findByName('dns-over-https-dns-query'))).toEqual(null);
|
||||
expect((await RouteDoc.findByName('dns-over-https-resolve'))).toEqual(null);
|
||||
|
||||
const remainingRoutes = await RouteDoc.findAll();
|
||||
expect(remainingRoutes.length).toEqual(1);
|
||||
expect(remainingRoutes[0].route.name).toEqual('valid-forward-route');
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
expect(appliedRoutes[0].length).toEqual(1);
|
||||
expect(appliedRoutes[0][0].name).toEqual('valid-forward-route');
|
||||
});
|
||||
|
||||
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const originalLog = logger.log.bind(logger);
|
||||
const warningMessages: string[] = [];
|
||||
|
||||
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
|
||||
if (level === 'warn') {
|
||||
warningMessages.push(message);
|
||||
}
|
||||
return originalLog(level, message, context || {});
|
||||
};
|
||||
|
||||
try {
|
||||
const existingDomain = new DomainDoc();
|
||||
existingDomain.id = 'existing-domain';
|
||||
existingDomain.name = 'example.com';
|
||||
existingDomain.source = 'dcrouter';
|
||||
existingDomain.authoritative = true;
|
||||
existingDomain.createdAt = Date.now();
|
||||
existingDomain.updatedAt = Date.now();
|
||||
existingDomain.createdBy = 'test';
|
||||
await existingDomain.save();
|
||||
|
||||
const dnsManager = new DnsManager({
|
||||
dnsNsDomains: ['ns1.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
|
||||
smartProxyConfig: { routes: [] },
|
||||
});
|
||||
|
||||
await dnsManager.start();
|
||||
|
||||
expect(
|
||||
warningMessages.some((message) =>
|
||||
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
|
||||
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
|
||||
),
|
||||
).toEqual(true);
|
||||
expect(
|
||||
warningMessages.some((message) =>
|
||||
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
|
||||
),
|
||||
).toEqual(false);
|
||||
} finally {
|
||||
(logger as any).log = originalLog;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup test db', async () => {
|
||||
await clearTestState();
|
||||
const testDb = await testDbPromise;
|
||||
await testDb.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
193
test/test.email-domain-manager.node.ts
Normal file
193
test/test.email-domain-manager.node.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { EmailDomainManager } from '../ts/email/index.js';
|
||||
import { DcRouterDb, DomainDoc } from '../ts/db/index.js';
|
||||
import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearTestState = async () => {
|
||||
for (const emailDomain of await EmailDomainDoc.findAll()) {
|
||||
await emailDomain.delete();
|
||||
}
|
||||
for (const domain of await DomainDoc.findAll()) {
|
||||
await domain.delete();
|
||||
}
|
||||
};
|
||||
|
||||
const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => {
|
||||
const doc = new DomainDoc();
|
||||
doc.id = id;
|
||||
doc.name = name;
|
||||
doc.source = source;
|
||||
doc.authoritative = source === 'dcrouter';
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
doc.createdBy = 'test';
|
||||
await doc.save();
|
||||
return doc;
|
||||
};
|
||||
|
||||
const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [
|
||||
{
|
||||
domain: 'static.example.com',
|
||||
dnsMode: 'external-dns',
|
||||
},
|
||||
],
|
||||
routes: [],
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider');
|
||||
const updateCalls: Array<{ domains?: any[] }> = [];
|
||||
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
emailServer: {
|
||||
updateOptions: (options: { domains?: any[] }) => {
|
||||
updateCalls.push(options);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
await manager.start();
|
||||
|
||||
const created = await manager.createEmailDomain({
|
||||
linkedDomainId: linkedDomain.id,
|
||||
subdomain: 'mail',
|
||||
dkimSelector: 'selector1',
|
||||
rotateKeys: true,
|
||||
rotationIntervalDays: 30,
|
||||
});
|
||||
|
||||
const domainsAfterCreate = dcRouterStub.options.emailConfig.domains;
|
||||
expect(domainsAfterCreate.length).toEqual(2);
|
||||
expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true);
|
||||
|
||||
const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com');
|
||||
expect(managedDomain).toBeTruthy();
|
||||
expect(managedDomain?.dnsMode).toEqual('external-dns');
|
||||
expect(managedDomain?.dkim?.selector).toEqual('selector1');
|
||||
expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true);
|
||||
|
||||
await manager.updateEmailDomain(created.id, {
|
||||
rotateKeys: false,
|
||||
rateLimits: {
|
||||
outbound: {
|
||||
messagesPerMinute: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains;
|
||||
const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com');
|
||||
expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false);
|
||||
expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10);
|
||||
|
||||
await manager.deleteEmailDomain(created.id);
|
||||
expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']);
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager rejects domains already present in static config', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider');
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await manager.createEmailDomain({ linkedDomainId: linkedDomain.id });
|
||||
} catch (err: unknown) {
|
||||
error = err as Error;
|
||||
}
|
||||
|
||||
expect(error?.message).toEqual('Email domain already configured for static.example.com');
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter');
|
||||
const stored = new EmailDomainDoc();
|
||||
stored.id = 'managed-email-domain';
|
||||
stored.domain = 'mail.managed.example.com';
|
||||
stored.linkedDomainId = linkedDomain.id;
|
||||
stored.subdomain = 'mail';
|
||||
stored.dkim = {
|
||||
selector: 'default',
|
||||
keySize: 2048,
|
||||
rotateKeys: false,
|
||||
rotationIntervalDays: 90,
|
||||
};
|
||||
stored.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
stored.createdAt = new Date().toISOString();
|
||||
stored.updatedAt = new Date().toISOString();
|
||||
await stored.save();
|
||||
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
await manager.start();
|
||||
|
||||
const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com');
|
||||
expect(managedDomain?.dnsMode).toEqual('internal-dns');
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await clearTestState();
|
||||
await testDb.cleanup();
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
120
test/test.metricsmanager.route-keys.node.ts
Normal file
120
test/test.metricsmanager.route-keys.node.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
|
||||
|
||||
const emptyProtocolDistribution = {
|
||||
h1Active: 0,
|
||||
h1Total: 0,
|
||||
h2Active: 0,
|
||||
h2Total: 0,
|
||||
h3Active: 0,
|
||||
h3Total: 0,
|
||||
wsActive: 0,
|
||||
wsTotal: 0,
|
||||
otherActive: 0,
|
||||
otherTotal: 0,
|
||||
};
|
||||
|
||||
function createProxyMetrics(args: {
|
||||
connectionsByRoute: Map<string, number>;
|
||||
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||
requestsTotal?: number;
|
||||
}) {
|
||||
return {
|
||||
connections: {
|
||||
active: () => 0,
|
||||
total: () => 0,
|
||||
byRoute: () => args.connectionsByRoute,
|
||||
byIP: () => new Map<string, number>(),
|
||||
topIPs: () => [],
|
||||
domainRequestsByIP: () => args.domainRequestsByIP,
|
||||
topDomainRequests: () => [],
|
||||
frontendProtocols: () => emptyProtocolDistribution,
|
||||
backendProtocols: () => emptyProtocolDistribution,
|
||||
},
|
||||
throughput: {
|
||||
instant: () => ({ in: 0, out: 0 }),
|
||||
recent: () => ({ in: 0, out: 0 }),
|
||||
average: () => ({ in: 0, out: 0 }),
|
||||
custom: () => ({ in: 0, out: 0 }),
|
||||
history: () => [],
|
||||
byRoute: () => args.throughputByRoute,
|
||||
byIP: () => new Map<string, { in: number; out: number }>(),
|
||||
},
|
||||
requests: {
|
||||
perSecond: () => 0,
|
||||
perMinute: () => 0,
|
||||
total: () => args.requestsTotal || 0,
|
||||
},
|
||||
totals: {
|
||||
bytesIn: () => 0,
|
||||
bytesOut: () => 0,
|
||||
connections: () => 0,
|
||||
},
|
||||
backends: {
|
||||
byBackend: () => new Map<string, any>(),
|
||||
protocols: () => new Map<string, string>(),
|
||||
topByErrors: () => [],
|
||||
detectedProtocols: () => [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map([
|
||||
['route-id-only', 4],
|
||||
]),
|
||||
throughputByRoute: new Map([
|
||||
['route-id-only', { in: 1200, out: 2400 }],
|
||||
]),
|
||||
domainRequestsByIP: new Map([
|
||||
['192.0.2.10', new Map([
|
||||
['alpha.example.com', 3],
|
||||
['beta.example.com', 1],
|
||||
])],
|
||||
]),
|
||||
requestsTotal: 4,
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: {
|
||||
getRoutes: () => [
|
||||
{
|
||||
id: 'route-id-only',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['alpha.example.com', 'beta.example.com'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new MetricsManager({ smartProxy } as any);
|
||||
const stats = await manager.getNetworkStats();
|
||||
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
|
||||
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
|
||||
|
||||
expect(alpha).toBeDefined();
|
||||
expect(beta).toBeDefined();
|
||||
|
||||
expect(alpha!.requestCount).toEqual(3);
|
||||
expect(alpha!.routeCount).toEqual(1);
|
||||
expect(alpha!.activeConnections).toEqual(3);
|
||||
expect(alpha!.bytesInPerSecond).toEqual(900);
|
||||
expect(alpha!.bytesOutPerSecond).toEqual(1800);
|
||||
|
||||
expect(beta!.requestCount).toEqual(1);
|
||||
expect(beta!.routeCount).toEqual(1);
|
||||
expect(beta!.activeConnections).toEqual(1);
|
||||
expect(beta!.bytesInPerSecond).toEqual(300);
|
||||
expect(beta!.bytesOutPerSecond).toEqual(600);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||
import type { ISecurityProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers: access private maps for direct unit testing without DB
|
||||
// ============================================================================
|
||||
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISecurityProfile): void {
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||
(resolver as any).profiles.set(profile.id, profile);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void
|
||||
(resolver as any).targets.set(target.id, target);
|
||||
}
|
||||
|
||||
function makeProfile(overrides: Partial<ISecurityProfile> = {}): ISecurityProfile {
|
||||
function makeProfile(overrides: Partial<ISourceProfile> = {}): ISourceProfile {
|
||||
return {
|
||||
id: 'profile-1',
|
||||
name: 'STANDARD',
|
||||
@@ -72,14 +72,14 @@ tap.test('should list empty profiles and targets initially', async () => {
|
||||
expect(resolver.listTargets().length).toEqual(0);
|
||||
});
|
||||
|
||||
// ---- Security profile resolution ----
|
||||
// ---- Source profile resolution ----
|
||||
|
||||
tap.test('should resolve security profile onto a route', async () => {
|
||||
tap.test('should resolve source profile onto a route', async () => {
|
||||
const profile = makeProfile();
|
||||
injectProfile(resolver, profile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
@@ -87,7 +87,7 @@ tap.test('should resolve security profile onto a route', async () => {
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
expect(result.metadata.securityProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ tap.test('should merge inline route security with profile security', async () =>
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
@@ -117,7 +117,7 @@ tap.test('should deduplicate IP lists during merge', async () => {
|
||||
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
@@ -128,13 +128,13 @@ tap.test('should deduplicate IP lists during merge', async () => {
|
||||
|
||||
tap.test('should handle missing profile gracefully', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be unchanged
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.securityProfileName).toBeUndefined();
|
||||
expect(result.metadata.sourceProfileName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Profile inheritance ----
|
||||
@@ -161,7 +161,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
injectProfile(resolver, extendedProfile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'extended-profile' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
@@ -170,7 +170,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||
// maxConnections from base (extended doesn't override)
|
||||
expect(result.route.security!.maxConnections).toEqual(500);
|
||||
expect(result.metadata.securityProfileName).toEqual('EXTENDED');
|
||||
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
|
||||
});
|
||||
|
||||
tap.test('should detect circular profile inheritance', async () => {
|
||||
@@ -190,7 +190,7 @@ tap.test('should detect circular profile inheritance', async () => {
|
||||
injectProfile(resolver, profileB);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'circular-a' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
|
||||
|
||||
// Should not infinite loop — resolves what it can
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
@@ -232,7 +232,7 @@ tap.test('should handle missing target gracefully', async () => {
|
||||
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
securityProfileRef: 'profile-1',
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
@@ -247,7 +247,7 @@ tap.test('should resolve both profile and target simultaneously', async () => {
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
|
||||
// Both names recorded
|
||||
expect(result.metadata.securityProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
});
|
||||
|
||||
@@ -268,7 +268,7 @@ tap.test('should skip resolution when no metadata refs', async () => {
|
||||
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
securityProfileRef: 'profile-1',
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
@@ -288,7 +288,7 @@ tap.test('should find routes by profile ref (sync)', async () => {
|
||||
id: 'route-a',
|
||||
route: makeRoute({ name: 'route-a' }),
|
||||
enabled: true,
|
||||
metadata: { securityProfileRef: 'profile-1' },
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
});
|
||||
storedRoutes.set('route-b', {
|
||||
id: 'route-b',
|
||||
@@ -300,7 +300,7 @@ tap.test('should find routes by profile ref (sync)', async () => {
|
||||
id: 'route-c',
|
||||
route: makeRoute({ name: 'route-c' }),
|
||||
enabled: true,
|
||||
metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
});
|
||||
|
||||
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||
@@ -320,7 +320,7 @@ tap.test('should get profile usage for a specific profile ID', async () => {
|
||||
id: 'route-x',
|
||||
route: makeRoute({ name: 'my-route' }),
|
||||
enabled: true,
|
||||
metadata: { securityProfileRef: 'profile-1' },
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
});
|
||||
|
||||
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||
|
||||
31
test/test.smartmta-storage-manager.node.ts
Normal file
31
test/test.smartmta-storage-manager.node.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SmartMtaStorageManager } from '../ts/email/index.js';
|
||||
|
||||
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test-smartmta-storage');
|
||||
|
||||
tap.test('SmartMtaStorageManager persists, lists, and deletes keys', async () => {
|
||||
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
const storageManager = new SmartMtaStorageManager(tempDir);
|
||||
await storageManager.set('/email/dkim/example.com/default/metadata', 'metadata');
|
||||
await storageManager.set('/email/dkim/example.com/default/public.key', 'public');
|
||||
|
||||
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toEqual('metadata');
|
||||
|
||||
const keys = await storageManager.list('/email/dkim/example.com/');
|
||||
expect(keys).toEqual([
|
||||
'/email/dkim/example.com/default/metadata',
|
||||
'/email/dkim/example.com/default/public.key',
|
||||
]);
|
||||
|
||||
await storageManager.delete('/email/dkim/example.com/default/metadata');
|
||||
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -39,13 +39,13 @@ tap.test('should login as admin', async () => {
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Security Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||
// Source Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should return empty profiles list when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||
TEST_URL,
|
||||
'getSecurityProfiles'
|
||||
'getSourceProfiles'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
@@ -57,9 +57,9 @@ tap.test('should return empty profiles list when resolver not initialized', asyn
|
||||
});
|
||||
|
||||
tap.test('should return null for single profile when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfile>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfile>(
|
||||
TEST_URL,
|
||||
'getSecurityProfile'
|
||||
'getSourceProfile'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
@@ -71,9 +71,9 @@ tap.test('should return null for single profile when resolver not initialized',
|
||||
});
|
||||
|
||||
tap.test('should return failure for create profile when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_CreateSourceProfile>(
|
||||
TEST_URL,
|
||||
'createSecurityProfile'
|
||||
'createSourceProfile'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
@@ -87,9 +87,9 @@ tap.test('should return failure for create profile when resolver not initialized
|
||||
});
|
||||
|
||||
tap.test('should return empty profile usage when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfileUsage>(
|
||||
TEST_URL,
|
||||
'getSecurityProfileUsage'
|
||||
'getSourceProfileUsage'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
@@ -170,9 +170,9 @@ tap.test('should return empty target usage when resolver not initialized', async
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should reject unauthenticated profile requests', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||
TEST_URL,
|
||||
'getSecurityProfiles'
|
||||
'getSourceProfiles'
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -29,13 +29,13 @@ const devRouter = new DcRouter({
|
||||
name: 'vpn-internal-app',
|
||||
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||
vpn: { enabled: true },
|
||||
vpnOnly: true,
|
||||
},
|
||||
{
|
||||
name: 'vpn-eng-dashboard',
|
||||
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||
vpnOnly: true,
|
||||
},
|
||||
] as any[],
|
||||
},
|
||||
@@ -44,13 +44,12 @@ const devRouter = new DcRouter({
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.dev.local',
|
||||
clients: [
|
||||
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' },
|
||||
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' },
|
||||
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' },
|
||||
{ clientId: 'dev-laptop', description: 'Developer laptop' },
|
||||
{ clientId: 'ci-runner', description: 'CI/CD pipeline' },
|
||||
{ clientId: 'admin-desktop', description: 'Admin workstation' },
|
||||
],
|
||||
},
|
||||
// Disable db/mongo for dev
|
||||
dbConfig: { enabled: false },
|
||||
dbConfig: { enabled: true },
|
||||
});
|
||||
|
||||
console.log('Starting DcRouter in development mode...');
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '12.2.4',
|
||||
version: '13.18.0',
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,28 @@ import {
|
||||
type IUnifiedEmailServerOptions,
|
||||
type IEmailRoute,
|
||||
type IEmailDomainConfig,
|
||||
type IStorageManagerLike,
|
||||
} from '@push.rocks/smartmta';
|
||||
import { logger } from './logger.js';
|
||||
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
||||
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
|
||||
// Import unified database
|
||||
import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc } from './db/index.js';
|
||||
// Import migration runner and app version
|
||||
import { createMigrationRunner } from '../ts_migrations/index.js';
|
||||
import { commitinfo } from './00_commitinfo_data.js';
|
||||
|
||||
import { OpsServer } from './opsserver/index.js';
|
||||
import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder } from './config/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager } from './email/index.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -113,13 +120,6 @@ export interface IDcRouterOptions {
|
||||
useIngressProxy?: boolean; // Whether to replace server IP with proxy IP (default: true)
|
||||
}>;
|
||||
|
||||
/** DNS challenge configuration for ACME (optional) */
|
||||
dnsChallenge?: {
|
||||
/** Cloudflare API key for DNS challenges */
|
||||
cloudflareApiKey?: string;
|
||||
/** Other DNS providers can be added here */
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified database configuration.
|
||||
* All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata.
|
||||
@@ -180,8 +180,8 @@ export interface IDcRouterOptions {
|
||||
|
||||
/**
|
||||
* VPN server configuration.
|
||||
* Enables VPN-based access control: routes with vpn.enabled are only
|
||||
* accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports.
|
||||
* Enables VPN-based access control: routes with vpnOnly are only
|
||||
* accessible from VPN clients whose TargetProfile matches. Supports WireGuard + native (WS/QUIC) transports.
|
||||
*/
|
||||
vpnConfig?: {
|
||||
/** Enable VPN server (default: false) */
|
||||
@@ -197,7 +197,7 @@ export interface IDcRouterOptions {
|
||||
/** Pre-defined VPN clients created on startup */
|
||||
clients?: Array<{
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
}>;
|
||||
/** Destination routing policy for VPN client traffic.
|
||||
@@ -249,15 +249,13 @@ export class DcRouter {
|
||||
public radiusServer?: RadiusServer;
|
||||
public opsServer!: OpsServer;
|
||||
public metricsManager?: MetricsManager;
|
||||
private emailEventSubscriptions: Array<{
|
||||
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
|
||||
eventName: string;
|
||||
listener: (...args: any[]) => void;
|
||||
}> = [];
|
||||
|
||||
// Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set()
|
||||
public storageManager: any = {
|
||||
get: async (_key: string) => null,
|
||||
set: async (_key: string, _value: string) => {
|
||||
// DKIM keys from smartmta — logged but not yet migrated to smartdata
|
||||
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
|
||||
},
|
||||
};
|
||||
public storageManager: IStorageManagerLike;
|
||||
|
||||
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
||||
public dcRouterDb?: DcRouterDb;
|
||||
@@ -274,6 +272,14 @@ export class DcRouter {
|
||||
public routeConfigManager?: RouteConfigManager;
|
||||
public apiTokenManager?: ApiTokenManager;
|
||||
public referenceResolver?: ReferenceResolver;
|
||||
public targetProfileManager?: TargetProfileManager;
|
||||
|
||||
// Domain / DNS management (DB-backed providers, domains, records)
|
||||
public dnsManager?: DnsManager;
|
||||
|
||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||
public acmeConfigManager?: AcmeConfigManager;
|
||||
public emailDomainManager?: EmailDomainManager;
|
||||
|
||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||
public detectedPublicIp: string | null = null;
|
||||
@@ -305,8 +311,11 @@ export class DcRouter {
|
||||
// TypedRouter for API endpoints
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
|
||||
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
||||
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Runtime-only DoH routes. These carry live socket handlers and must never be persisted.
|
||||
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Environment access
|
||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
@@ -319,6 +328,10 @@ export class DcRouter {
|
||||
|
||||
// Resolve all data paths from baseDir
|
||||
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
||||
paths.ensureDataDirectories(this.resolvedPaths);
|
||||
this.storageManager = new SmartMtaStorageManager(
|
||||
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
||||
);
|
||||
|
||||
// Initialize service manager and register all services
|
||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||
@@ -389,10 +402,76 @@ export class DcRouter {
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
|
||||
);
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb (if enabled)
|
||||
// DnsManager: optional, depends on DcRouterDb — owns DB-backed DNS state
|
||||
// (providers, domains, records). Must run before SmartProxy so ACME DNS-01
|
||||
// wiring can look up providers.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('DnsManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.dnsManager = new DnsManager(this.options);
|
||||
await this.dnsManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.dnsManager) {
|
||||
await this.dnsManager.stop();
|
||||
this.dnsManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// AcmeConfigManager: optional, depends on DcRouterDb — owns the singleton
|
||||
// ACME configuration (accountEmail, useProduction, etc.). Must run before
|
||||
// SmartProxy so setupSmartProxy() can read the ACME config from the DB.
|
||||
// On first boot, seeds from legacy `tls.contactEmail` / `smartProxyConfig.acme`.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('AcmeConfigManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.acmeConfigManager = new AcmeConfigManager(this.options);
|
||||
await this.acmeConfigManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.acmeConfigManager) {
|
||||
await this.acmeConfigManager.stop();
|
||||
this.acmeConfigManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// Email Domain Manager: optional, depends on DcRouterDb
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('EmailDomainManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.emailDomainManager = new EmailDomainManager(this);
|
||||
await this.emailDomainManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.emailDomainManager) {
|
||||
await this.emailDomainManager.stop();
|
||||
this.emailDomainManager = undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||
const smartProxyDeps: string[] = [];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
smartProxyDeps.push('DcRouterDb');
|
||||
smartProxyDeps.push('DnsManager');
|
||||
smartProxyDeps.push('AcmeConfigManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartProxy')
|
||||
@@ -411,9 +490,11 @@ export class DcRouter {
|
||||
.withRetry({ maxRetries: 0 }),
|
||||
);
|
||||
|
||||
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits
|
||||
// Only registered if DNS challenge is configured
|
||||
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
||||
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits.
|
||||
// Always registered when the DB is enabled; setupSmartProxy() decides whether
|
||||
// to actually instantiate SmartAcme based on whether any DnsProviderDoc exists.
|
||||
// If `this.smartAcme` is unset by the time this service starts, withStart is a no-op.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartAcme')
|
||||
.optional()
|
||||
@@ -430,7 +511,15 @@ export class DcRouter {
|
||||
// failed silently (SmartProxy doesn't emit certificate-failed for this path).
|
||||
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
|
||||
// which calls certProvisionFunction again — now with smartAcmeReady === true.
|
||||
if (this.smartProxy) {
|
||||
if (this.routeConfigManager) {
|
||||
// Go through RouteConfigManager to get the full merged route set
|
||||
// and serialize via the route-update mutex (prevents stale overwrites)
|
||||
logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
|
||||
this.routeConfigManager.applyRoutes().catch((err: any) => {
|
||||
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
|
||||
});
|
||||
} else if (this.smartProxy) {
|
||||
// No RouteConfigManager (DB disabled) — re-send current routes to trigger cert provisioning
|
||||
if (this.certProvisionScheduler) {
|
||||
this.certProvisionScheduler.clear();
|
||||
}
|
||||
@@ -465,29 +554,49 @@ export class DcRouter {
|
||||
this.referenceResolver = new ReferenceResolver();
|
||||
await this.referenceResolver.initialize();
|
||||
|
||||
// Initialize target profile manager
|
||||
this.targetProfileManager = new TargetProfileManager(
|
||||
() => this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
await this.targetProfileManager.initialize();
|
||||
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.options.vpnConfig?.enabled
|
||||
? (tags?: string[]) => {
|
||||
if (tags?.length && this.vpnManager) {
|
||||
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
|
||||
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
|
||||
if (!this.vpnManager || !this.targetProfileManager) {
|
||||
// VPN not ready yet — deny all until re-apply after VPN starts
|
||||
return [];
|
||||
}
|
||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
this.referenceResolver,
|
||||
// Sync merged routes to RemoteIngressManager whenever routes change
|
||||
// Sync routes to RemoteIngressManager whenever routes change,
|
||||
// then push updated derived ports to the Rust hub binary
|
||||
(routes) => {
|
||||
if (this.remoteIngressManager) {
|
||||
this.remoteIngressManager.setRoutes(routes as any[]);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
},
|
||||
() => this.runtimeDnsRoutes,
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
await this.apiTokenManager.initialize();
|
||||
await this.routeConfigManager.initialize();
|
||||
await this.routeConfigManager.initialize(
|
||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
);
|
||||
await this.targetProfileManager.normalizeAllRouteRefs();
|
||||
|
||||
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||
const seeder = new DbSeeder(this.referenceResolver);
|
||||
@@ -500,6 +609,7 @@ export class DcRouter {
|
||||
this.routeConfigManager = undefined;
|
||||
this.apiTokenManager = undefined;
|
||||
this.referenceResolver = undefined;
|
||||
this.targetProfileManager = undefined;
|
||||
})
|
||||
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
|
||||
);
|
||||
@@ -507,19 +617,20 @@ export class DcRouter {
|
||||
|
||||
// Email Server: optional, depends on SmartProxy
|
||||
if (this.options.emailConfig) {
|
||||
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
emailServiceDeps.push('EmailDomainManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('EmailServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn(...emailServiceDeps)
|
||||
.withStart(async () => {
|
||||
await this.setupUnifiedEmailHandling();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.emailServer) {
|
||||
if ((this.emailServer as any).deliverySystem) {
|
||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||
}
|
||||
this.emailServer.removeAllListeners();
|
||||
this.clearEmailEventSubscriptions();
|
||||
await this.emailServer.stop();
|
||||
this.emailServer = undefined;
|
||||
}
|
||||
@@ -533,7 +644,7 @@ export class DcRouter {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('DnsServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
|
||||
.withStart(async () => {
|
||||
await this.setupDnsWithSocketHandler();
|
||||
})
|
||||
@@ -754,6 +865,19 @@ export class DcRouter {
|
||||
|
||||
await this.dcRouterDb.start();
|
||||
|
||||
// Run any pending data migrations before anything else reads from the DB.
|
||||
// This must complete before ConfigManagers loads profiles.
|
||||
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version);
|
||||
const migrationResult = await migration.run();
|
||||
if (migrationResult.stepsApplied.length > 0) {
|
||||
logger.log('info',
|
||||
`smartmigration: ${migrationResult.currentVersionBefore ?? 'fresh'} → ${migrationResult.currentVersionAfter} ` +
|
||||
`(${migrationResult.stepsApplied.length} step(s) applied in ${migrationResult.totalDurationMs}ms)`,
|
||||
);
|
||||
} else if (migrationResult.wasFreshInstall) {
|
||||
logger.log('info', `smartmigration: fresh install stamped to ${migrationResult.currentVersionAfter}`);
|
||||
}
|
||||
|
||||
// Start the cache cleaner for TTL-based document cleanup
|
||||
const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
|
||||
this.cacheCleaner = new CacheCleaner(this.dcRouterDb, {
|
||||
@@ -777,47 +901,66 @@ export class DcRouter {
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
|
||||
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
|
||||
|
||||
// If user provides full SmartProxy config, use it directly
|
||||
if (this.options.smartProxyConfig) {
|
||||
routes = this.options.smartProxyConfig.routes || [];
|
||||
acmeConfig = this.options.smartProxyConfig.acme;
|
||||
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
|
||||
}
|
||||
|
||||
// If email config exists, automatically add email routes
|
||||
// Assemble serializable seed routes from constructor config — these will be seeded into DB
|
||||
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
|
||||
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
|
||||
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
|
||||
|
||||
this.seedEmailRoutes = [];
|
||||
if (this.options.emailConfig) {
|
||||
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
||||
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
||||
this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
||||
}
|
||||
|
||||
// If DNS is configured, add DNS routes
|
||||
|
||||
this.runtimeDnsRoutes = [];
|
||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||
const dnsRoutes = this.generateDnsRoutes();
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
||||
routes = [...routes, ...dnsRoutes];
|
||||
this.runtimeDnsRoutes = this.generateDnsRoutes();
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) });
|
||||
}
|
||||
|
||||
// Merge TLS/ACME configuration if provided at root level
|
||||
if (this.options.tls && !acmeConfig) {
|
||||
acmeConfig = {
|
||||
accountEmail: this.options.tls.contactEmail,
|
||||
enabled: true,
|
||||
useProduction: true,
|
||||
autoRenew: true,
|
||||
renewThresholdDays: 30
|
||||
};
|
||||
|
||||
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
||||
let routes: plugins.smartproxy.IRouteConfig[] = [
|
||||
...this.seedConfigRoutes,
|
||||
...this.seedEmailRoutes,
|
||||
...this.runtimeDnsRoutes,
|
||||
];
|
||||
|
||||
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
||||
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
|
||||
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
|
||||
const dbAcme = this.acmeConfigManager?.getConfig();
|
||||
const acmeConfig: plugins.smartproxy.IAcmeOptions | undefined =
|
||||
dbAcme && dbAcme.enabled
|
||||
? {
|
||||
accountEmail: dbAcme.accountEmail,
|
||||
enabled: true,
|
||||
useProduction: dbAcme.useProduction,
|
||||
autoRenew: dbAcme.autoRenew,
|
||||
renewThresholdDays: dbAcme.renewThresholdDays,
|
||||
}
|
||||
: undefined;
|
||||
if (acmeConfig) {
|
||||
logger.log(
|
||||
'info',
|
||||
`ACME config: accountEmail=${acmeConfig.accountEmail}, useProduction=${acmeConfig.useProduction}, autoRenew=${acmeConfig.autoRenew}`,
|
||||
);
|
||||
} else {
|
||||
logger.log('info', 'ACME config: disabled or not yet configured in DB');
|
||||
}
|
||||
|
||||
// Configure DNS challenge if available
|
||||
|
||||
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
||||
// ACME is enabled. The DnsManager dispatches each challenge through the
|
||||
// unified createRecord()/deleteRecord() path — works for both dcrouter-hosted
|
||||
// zones and provider-managed zones. Only domains under management get certs.
|
||||
let challengeHandlers: any[] = [];
|
||||
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
||||
logger.log('info', 'Configuring Cloudflare DNS challenge for ACME');
|
||||
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
|
||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
|
||||
if (
|
||||
acmeConfig &&
|
||||
this.dnsManager &&
|
||||
(await this.dnsManager.hasAnyManagedDomain())
|
||||
) {
|
||||
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (managed domains)');
|
||||
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
||||
challengeHandlers.push(dns01Handler);
|
||||
}
|
||||
|
||||
@@ -828,10 +971,6 @@ export class DcRouter {
|
||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||
}
|
||||
|
||||
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
|
||||
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
|
||||
this.constructorRoutes = [...routes];
|
||||
|
||||
// If we have routes or need a basic SmartProxy instance, create it
|
||||
if (routes.length > 0 || this.options.smartProxyConfig) {
|
||||
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||
@@ -916,10 +1055,12 @@ export class DcRouter {
|
||||
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
|
||||
);
|
||||
}
|
||||
// Safe non-null: challengeHandlers.length > 0 implies both dnsManager
|
||||
// and acmeConfig exist (enforced above).
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
|
||||
accountEmail: dbAcme!.accountEmail,
|
||||
certManager: new StorageBackedCertManager(),
|
||||
environment: 'production',
|
||||
environment: dbAcme!.useProduction ? 'production' : 'integration',
|
||||
challengeHandlers: challengeHandlers,
|
||||
challengePriority: ['dns-01'],
|
||||
});
|
||||
@@ -1021,15 +1162,9 @@ export class DcRouter {
|
||||
});
|
||||
});
|
||||
|
||||
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
||||
logger.log('info', `Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||
this.certificateStatusMap.set(event.domain, {
|
||||
status: 'valid', routeNames,
|
||||
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
||||
source: event.source,
|
||||
});
|
||||
});
|
||||
// Note: smartproxy v27.5.0 emits only 'certificate-issued' and 'certificate-failed'.
|
||||
// Renewals come through 'certificate-issued' (with optional isRenewal? in the payload).
|
||||
// The vestigial 'certificate-renewed' event from common-types.ts is never emitted.
|
||||
|
||||
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
||||
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
|
||||
@@ -1064,7 +1199,10 @@ export class DcRouter {
|
||||
if (!expiryDate) {
|
||||
try {
|
||||
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
|
||||
const certDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
const domParts = cleanDomain.split('.');
|
||||
const baseDomain = domParts.length > 2 ? domParts.slice(-2).join('.') : cleanDomain;
|
||||
const certDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||
if (certDoc?.validUntil) {
|
||||
expiryDate = new Date(certDoc.validUntil).toISOString();
|
||||
}
|
||||
@@ -1283,14 +1421,6 @@ export class DcRouter {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the routes derived from constructor config (smartProxy + email + DNS).
|
||||
* Used by RouteConfigManager as the "hardcoded" base.
|
||||
*/
|
||||
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
|
||||
return this.constructorRoutes;
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
logger.log('info', 'Stopping DcRouter services...');
|
||||
|
||||
@@ -1334,17 +1464,15 @@ export class DcRouter {
|
||||
// Update configuration
|
||||
this.options.smartProxyConfig = config;
|
||||
|
||||
// Update routes on RemoteIngressManager so derived ports stay in sync
|
||||
if (this.remoteIngressManager && config.routes) {
|
||||
this.remoteIngressManager.setRoutes(config.routes as any[]);
|
||||
}
|
||||
|
||||
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
||||
// Start new SmartProxy with updated configuration (rebuilds seed routes)
|
||||
await this.setupSmartProxy();
|
||||
|
||||
// Re-apply programmatic routes and overrides after SmartProxy restart
|
||||
// Re-seed and re-apply all routes after SmartProxy restart
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.initialize();
|
||||
await this.routeConfigManager.initialize(
|
||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
);
|
||||
}
|
||||
|
||||
logger.log('info', 'SmartProxy configuration updated');
|
||||
@@ -1391,40 +1519,74 @@ export class DcRouter {
|
||||
...this.options.emailConfig,
|
||||
domains: transformedDomains,
|
||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
||||
hostname: 'localhost' // Listen on localhost for SmartProxy forwarding
|
||||
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
|
||||
queue: {
|
||||
storageType: 'disk',
|
||||
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
||||
...this.options.emailConfig.queue,
|
||||
},
|
||||
};
|
||||
|
||||
// Create unified email server
|
||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||
this.clearEmailEventSubscriptions();
|
||||
|
||||
// Set up error handling
|
||||
this.emailServer.on('error', (err: Error) => {
|
||||
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
|
||||
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await this.emailServer.start();
|
||||
|
||||
// Wire delivery events to MetricsManager and logger
|
||||
if (this.metricsManager && this.emailServer.deliverySystem) {
|
||||
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
|
||||
this.metricsManager!.trackEmailReceived(item?.from);
|
||||
logger.log('info', `Email delivery started: ${item?.from} → ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
|
||||
this.metricsManager!.trackEmailSent(item?.to);
|
||||
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
|
||||
this.metricsManager!.trackEmailFailed(item?.to, error?.message);
|
||||
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
|
||||
});
|
||||
}
|
||||
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
|
||||
if (this.metricsManager && this.emailServer) {
|
||||
this.emailServer.on('bounceProcessed', () => {
|
||||
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
|
||||
const emailLike = item?.processingResult;
|
||||
const from = emailLike?.from || emailLike?.email?.from || '';
|
||||
const recipients = Array.isArray(emailLike?.to)
|
||||
? emailLike.to
|
||||
: Array.isArray(emailLike?.email?.to)
|
||||
? emailLike.email.to
|
||||
: [];
|
||||
return {
|
||||
from,
|
||||
recipients: recipients.filter(Boolean),
|
||||
};
|
||||
};
|
||||
const updateQueueSize = () => {
|
||||
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
|
||||
};
|
||||
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailReceived(envelope.from);
|
||||
updateQueueSize();
|
||||
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
|
||||
updateQueueSize();
|
||||
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
|
||||
updateQueueSize();
|
||||
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
|
||||
updateQueueSize();
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
|
||||
updateQueueSize();
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
|
||||
this.metricsManager!.trackEmailBounced();
|
||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||
});
|
||||
updateQueueSize();
|
||||
}
|
||||
|
||||
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
||||
@@ -1454,11 +1616,7 @@ export class DcRouter {
|
||||
try {
|
||||
// Stop the unified email server which contains all components
|
||||
if (this.emailServer) {
|
||||
// Remove listeners before stopping to prevent leaks on config update cycles
|
||||
if ((this.emailServer as any).deliverySystem) {
|
||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||
}
|
||||
this.emailServer.removeAllListeners();
|
||||
this.clearEmailEventSubscriptions();
|
||||
await this.emailServer.stop();
|
||||
logger.log('info', 'Unified email server stopped');
|
||||
this.emailServer = undefined;
|
||||
@@ -1663,14 +1821,14 @@ export class DcRouter {
|
||||
// Generate and register authoritative records
|
||||
const authoritativeRecords = await this.generateAuthoritativeRecords();
|
||||
|
||||
// Generate email DNS records
|
||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||
|
||||
// Initialize DKIM for all email domains
|
||||
await this.initializeDkimForEmailDomains();
|
||||
|
||||
// Load DKIM records from JSON files (they should now exist)
|
||||
const dkimRecords = await this.loadDkimRecords();
|
||||
// Generate email DNS records
|
||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||
|
||||
// Ensure DKIM keys exist for internal-dns domains before generating records.
|
||||
await this.initializeDkimForEmailDomains();
|
||||
|
||||
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
|
||||
const dkimRecords = await this.loadDkimRecords();
|
||||
|
||||
// Combine all records: authoritative, email, DKIM, and user-defined
|
||||
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
|
||||
@@ -1686,8 +1844,14 @@ export class DcRouter {
|
||||
this.registerDnsRecords(allRecords);
|
||||
logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`);
|
||||
}
|
||||
|
||||
// Hand the DnsServer to DnsManager so DB-backed local records on
|
||||
// dcrouter-hosted domains get registered too.
|
||||
if (this.dnsManager && this.dnsServer) {
|
||||
await this.dnsManager.attachDnsServer(this.dnsServer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create DNS socket handler for DoH
|
||||
*/
|
||||
@@ -1813,54 +1977,30 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load DKIM records from JSON files
|
||||
* Reads all *.dkimrecord.json files from the DNS records directory
|
||||
* Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
|
||||
*/
|
||||
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
||||
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
||||
|
||||
try {
|
||||
// Ensure paths are imported
|
||||
const dnsDir = this.resolvedPaths.dnsRecordsDir;
|
||||
|
||||
// Check if directory exists
|
||||
if (!plugins.fs.existsSync(dnsDir)) {
|
||||
logger.log('debug', 'No DNS records directory found, skipping DKIM record loading');
|
||||
return records;
|
||||
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
|
||||
return records;
|
||||
}
|
||||
|
||||
for (const domainConfig of this.options.emailConfig.domains) {
|
||||
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read all files in the directory
|
||||
const files = plugins.fs.readdirSync(dnsDir);
|
||||
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
|
||||
|
||||
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
|
||||
|
||||
// Load each DKIM record
|
||||
for (const file of dkimFiles) {
|
||||
try {
|
||||
const filePath = plugins.path.join(dnsDir, file);
|
||||
const fileContent = plugins.fs.readFileSync(filePath, 'utf8');
|
||||
const dkimRecord = JSON.parse(fileContent);
|
||||
|
||||
// Validate record structure
|
||||
if (dkimRecord.name && dkimRecord.type === 'TXT' && dkimRecord.value) {
|
||||
records.push({
|
||||
name: dkimRecord.name,
|
||||
type: 'TXT',
|
||||
value: dkimRecord.value,
|
||||
ttl: 3600 // Standard DKIM TTL
|
||||
});
|
||||
|
||||
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
|
||||
} else {
|
||||
logger.log('warn', `Invalid DKIM record structure in ${file}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
|
||||
}
|
||||
const selector = domainConfig.dkim?.selector || 'default';
|
||||
try {
|
||||
const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
|
||||
records.push({
|
||||
name: dkimRecord.name,
|
||||
type: 'TXT',
|
||||
value: dkimRecord.value,
|
||||
ttl: domainConfig.dns?.internal?.ttl || 3600,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to generate DKIM record for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return records;
|
||||
@@ -1887,12 +2027,17 @@ export class DcRouter {
|
||||
// Ensure necessary directories exist
|
||||
paths.ensureDataDirectories(this.resolvedPaths);
|
||||
|
||||
// Generate DKIM keys for each email domain
|
||||
// Generate DKIM keys for each internal-dns email domain using the configured selector.
|
||||
for (const domainConfig of this.options.emailConfig.domains) {
|
||||
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Generate DKIM keys for all domains, regardless of DNS mode
|
||||
// This ensures keys are ready even if DNS mode changes later
|
||||
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
|
||||
await dkimCreator.handleDKIMKeysForSelector(
|
||||
domainConfig.domain,
|
||||
domainConfig.dkim?.selector || 'default',
|
||||
domainConfig.dkim?.keySize || 2048,
|
||||
);
|
||||
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||
@@ -2022,6 +2167,25 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addEmailEventSubscription(
|
||||
emitter: {
|
||||
on(eventName: string, listener: (...args: any[]) => void): void;
|
||||
off(eventName: string, listener: (...args: any[]) => void): void;
|
||||
},
|
||||
eventName: string,
|
||||
listener: (...args: any[]) => void,
|
||||
): void {
|
||||
emitter.on(eventName, listener);
|
||||
this.emailEventSubscriptions.push({ emitter, eventName, listener });
|
||||
}
|
||||
|
||||
private clearEmailEventSubscriptions(): void {
|
||||
for (const subscription of this.emailEventSubscriptions) {
|
||||
subscription.emitter.off(subscription.eventName, subscription.listener);
|
||||
}
|
||||
this.emailEventSubscriptions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the server's public IP address
|
||||
@@ -2056,13 +2220,14 @@ export class DcRouter {
|
||||
this.remoteIngressManager = new RemoteIngressManager();
|
||||
await this.remoteIngressManager.initialize();
|
||||
|
||||
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
||||
const currentRoutes = this.constructorRoutes;
|
||||
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||
// will push the complete merged routes here.
|
||||
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
|
||||
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
||||
|
||||
// Race-condition fix: if ConfigManagers finished before us, re-apply routes
|
||||
// so the callback delivers the full merged set (including DB-stored routes)
|
||||
// to our newly-created remoteIngressManager.
|
||||
// If ConfigManagers finished before us, re-apply routes
|
||||
// so the callback delivers the full DB set to our newly-created remoteIngressManager.
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.applyRoutes();
|
||||
}
|
||||
@@ -2133,35 +2298,39 @@ export class DcRouter {
|
||||
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
|
||||
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||
onClientChanged: () => {
|
||||
// Re-apply routes so tag-based ipAllowLists get updated
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
// Re-apply routes so profile-based ipAllowLists get updated
|
||||
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
|
||||
this.routeConfigManager?.applyRoutes().catch((err) => {
|
||||
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
|
||||
});
|
||||
},
|
||||
getClientAllowedIPs: async (clientTags: string[]) => {
|
||||
getClientDirectTargets: (targetProfileIds: string[]) => {
|
||||
if (!this.targetProfileManager) return [];
|
||||
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
|
||||
},
|
||||
getClientAllowedIPs: async (targetProfileIds: string[]) => {
|
||||
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||
const ips = new Set<string>([subnet]);
|
||||
|
||||
// Check routes for VPN-gated tag match and collect domains
|
||||
const routes = this.options.smartProxyConfig?.routes || [];
|
||||
const domainsToResolve = new Set<string>();
|
||||
for (const route of routes) {
|
||||
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpn?.enabled) continue;
|
||||
if (!this.targetProfileManager) return [...ips];
|
||||
|
||||
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags;
|
||||
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) {
|
||||
// Collect domains from this route
|
||||
const domains = (route.match as any)?.domains;
|
||||
if (Array.isArray(domains)) {
|
||||
for (const d of domains) {
|
||||
// Strip wildcard prefix for DNS resolution (*.example.com → example.com)
|
||||
domainsToResolve.add(d.replace(/^\*\./, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
|
||||
|
||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||
targetProfileIds, allRoutes,
|
||||
);
|
||||
|
||||
// Add target IPs directly
|
||||
for (const ip of targetIps) {
|
||||
ips.add(`${ip}/32`);
|
||||
}
|
||||
|
||||
// Resolve DNS A records for matched domains (with caching)
|
||||
for (const domain of domainsToResolve) {
|
||||
for (const domain of domains) {
|
||||
if (this.isWildcardVpnDomain(domain)) {
|
||||
this.logSkippedWildcardAllowedIp(domain);
|
||||
continue;
|
||||
}
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||
for (const ip of resolvedIps) {
|
||||
ips.add(`${ip}/32`);
|
||||
@@ -2174,14 +2343,15 @@ export class DcRouter {
|
||||
|
||||
await this.vpnManager.start();
|
||||
|
||||
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
||||
// get correct tag-based ipAllowLists (not possible during setupSmartProxy since
|
||||
// VPN server wasn't ready yet)
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
|
||||
// get correct profile-based ipAllowLists
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
||||
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
|
||||
private warnedWildcardVpnDomains = new Set<string>();
|
||||
|
||||
/**
|
||||
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
||||
@@ -2195,6 +2365,11 @@ export class DcRouter {
|
||||
const { promises: dnsPromises } = await import('dns');
|
||||
const ips = await dnsPromises.resolve4(domain);
|
||||
this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
|
||||
// Evict oldest entries if cache exceeds 1000 entries
|
||||
if (this.vpnDomainIpCache.size > 1000) {
|
||||
const firstKey = this.vpnDomainIpCache.keys().next().value;
|
||||
if (firstKey) this.vpnDomainIpCache.delete(firstKey);
|
||||
}
|
||||
return ips;
|
||||
} catch (err) {
|
||||
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
|
||||
@@ -2202,6 +2377,19 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
|
||||
private isWildcardVpnDomain(domain: string): boolean {
|
||||
return domain.includes('*');
|
||||
}
|
||||
|
||||
private logSkippedWildcardAllowedIp(domain: string): void {
|
||||
if (this.warnedWildcardVpnDomains.has(domain)) return;
|
||||
this.warnedWildcardVpnDomains.add(domain);
|
||||
logger.log(
|
||||
'warn',
|
||||
`VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
|
||||
);
|
||||
}
|
||||
|
||||
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
||||
// via the getVpnAllowList callback — no longer a separate method here.
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ export class StorageBackedCertManager implements plugins.smartacme.ICertManager
|
||||
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
|
||||
if (!doc) {
|
||||
doc = new AcmeCertDoc();
|
||||
doc.id = cert.id;
|
||||
doc.domainName = cert.domainName;
|
||||
}
|
||||
doc.id = cert.id;
|
||||
doc.created = cert.created;
|
||||
doc.privateKey = cert.privateKey;
|
||||
doc.publicKey = cert.publicKey;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
ISecurityProfile,
|
||||
ISourceProfile,
|
||||
INetworkTarget,
|
||||
IRouteMetadata,
|
||||
IStoredRoute,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const MAX_INHERITANCE_DEPTH = 5;
|
||||
|
||||
export class ReferenceResolver {
|
||||
private profiles = new Map<string, ISecurityProfile>();
|
||||
private profiles = new Map<string, ISourceProfile>();
|
||||
private targets = new Map<string, INetworkTarget>();
|
||||
|
||||
// =========================================================================
|
||||
@@ -38,7 +38,7 @@ export class ReferenceResolver {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const profile: ISecurityProfile = {
|
||||
const profile: ISourceProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
@@ -51,17 +51,17 @@ export class ReferenceResolver {
|
||||
|
||||
this.profiles.set(id, profile);
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Created security profile '${profile.name}' (${id})`);
|
||||
logger.log('info', `Created source profile '${profile.name}' (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateProfile(
|
||||
id: string,
|
||||
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
patch: Partial<Omit<ISourceProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
): Promise<{ affectedRouteIds: string[] }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
throw new Error(`Security profile '${id}' not found`);
|
||||
throw new Error(`Source profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
@@ -71,7 +71,7 @@ export class ReferenceResolver {
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Updated security profile '${profile.name}' (${id})`);
|
||||
logger.log('info', `Updated source profile '${profile.name}' (${id})`);
|
||||
|
||||
// Find routes referencing this profile
|
||||
const affectedRouteIds = await this.findRoutesByProfileRef(id);
|
||||
@@ -81,11 +81,11 @@ export class ReferenceResolver {
|
||||
public async deleteProfile(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IStoredRoute>,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
return { success: false, message: `Security profile '${id}' not found` };
|
||||
return { success: false, message: `Source profile '${id}' not found` };
|
||||
}
|
||||
|
||||
// Check usage
|
||||
@@ -101,7 +101,7 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
const doc = await SecurityProfileDoc.findById(id);
|
||||
const doc = await SourceProfileDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
this.profiles.delete(id);
|
||||
|
||||
@@ -110,34 +110,34 @@ export class ReferenceResolver {
|
||||
await this.clearProfileRefsOnRoutes(affectedIds);
|
||||
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||
} else {
|
||||
logger.log('info', `Deleted security profile '${profile.name}' (${id})`);
|
||||
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public getProfile(id: string): ISecurityProfile | undefined {
|
||||
public getProfile(id: string): ISourceProfile | undefined {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
public getProfileByName(name: string): ISecurityProfile | undefined {
|
||||
public getProfileByName(name: string): ISourceProfile | undefined {
|
||||
for (const profile of this.profiles.values()) {
|
||||
if (profile.name === name) return profile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public listProfiles(): ISecurityProfile[] {
|
||||
public listProfiles(): ISourceProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
|
||||
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||
public getProfileUsage(storedRoutes: Map<string, IRoute>): 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?.securityProfileRef;
|
||||
const ref = stored.metadata?.sourceProfileRef;
|
||||
if (ref && usage.has(ref)) {
|
||||
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
@@ -147,11 +147,11 @@ export class ReferenceResolver {
|
||||
|
||||
public getProfileUsageForId(
|
||||
profileId: string,
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.securityProfileRef === profileId) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,7 @@ export class ReferenceResolver {
|
||||
public async deleteTarget(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IStoredRoute>,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const target = this.targets.get(id);
|
||||
if (!target) {
|
||||
@@ -263,7 +263,7 @@ export class ReferenceResolver {
|
||||
|
||||
public getTargetUsageForId(
|
||||
targetId: string,
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
@@ -280,7 +280,7 @@ export class ReferenceResolver {
|
||||
|
||||
/**
|
||||
* Resolve references for a single route.
|
||||
* Materializes security profile and/or network target into the route's fields.
|
||||
* Materializes source profile and/or network target into the route's fields.
|
||||
* Returns the resolved route and updated metadata.
|
||||
*/
|
||||
public resolveRoute(
|
||||
@@ -289,33 +289,34 @@ export class ReferenceResolver {
|
||||
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||
|
||||
if (resolvedMetadata.securityProfileRef) {
|
||||
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef);
|
||||
if (resolvedMetadata.sourceProfileRef) {
|
||||
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
|
||||
if (resolvedSecurity) {
|
||||
const profile = this.profiles.get(resolvedMetadata.securityProfileRef);
|
||||
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
|
||||
// Merge: profile provides base, route's inline values override
|
||||
route = {
|
||||
...route,
|
||||
security: this.mergeSecurityFields(resolvedSecurity, route.security),
|
||||
};
|
||||
resolvedMetadata.securityProfileName = profile?.name;
|
||||
resolvedMetadata.sourceProfileName = profile?.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
} else {
|
||||
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`);
|
||||
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedMetadata.networkTargetRef) {
|
||||
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: [{
|
||||
host: target.host as string,
|
||||
targets: hosts.map((h) => ({
|
||||
host: h,
|
||||
port: target.port,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
};
|
||||
resolvedMetadata.networkTargetName = target.name;
|
||||
@@ -333,30 +334,30 @@ export class ReferenceResolver {
|
||||
// =========================================================================
|
||||
|
||||
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.securityProfileRef === profileId)
|
||||
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.securityProfileRef === profileId) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
ids.push(routeId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.networkTargetRef === targetId) {
|
||||
@@ -367,10 +368,10 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: security profile resolution with inheritance
|
||||
// Private: source profile resolution with inheritance
|
||||
// =========================================================================
|
||||
|
||||
private resolveSecurityProfile(
|
||||
private resolveSourceProfile(
|
||||
profileId: string,
|
||||
visited: Set<string> = new Set(),
|
||||
depth: number = 0,
|
||||
@@ -396,7 +397,7 @@ export class ReferenceResolver {
|
||||
// Resolve parent profiles first (top-down, later overrides earlier)
|
||||
if (profile.extendsProfiles?.length) {
|
||||
for (const parentId of profile.extendsProfiles) {
|
||||
const parentSecurity = this.resolveSecurityProfile(parentId, new Set(visited), depth + 1);
|
||||
const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1);
|
||||
if (parentSecurity) {
|
||||
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
|
||||
}
|
||||
@@ -453,7 +454,7 @@ export class ReferenceResolver {
|
||||
// =========================================================================
|
||||
|
||||
private async loadProfiles(): Promise<void> {
|
||||
const docs = await SecurityProfileDoc.findAll();
|
||||
const docs = await SourceProfileDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.profiles.set(doc.id, {
|
||||
@@ -469,7 +470,7 @@ export class ReferenceResolver {
|
||||
}
|
||||
}
|
||||
if (this.profiles.size > 0) {
|
||||
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`);
|
||||
logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,8 +495,8 @@ export class ReferenceResolver {
|
||||
}
|
||||
}
|
||||
|
||||
private async persistProfile(profile: ISecurityProfile): Promise<void> {
|
||||
const existingDoc = await SecurityProfileDoc.findById(profile.id);
|
||||
private async persistProfile(profile: ISourceProfile): Promise<void> {
|
||||
const existingDoc = await SourceProfileDoc.findById(profile.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = profile.name;
|
||||
existingDoc.description = profile.description;
|
||||
@@ -504,7 +505,7 @@ export class ReferenceResolver {
|
||||
existingDoc.updatedAt = profile.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new SecurityProfileDoc();
|
||||
const doc = new SourceProfileDoc();
|
||||
doc.id = profile.id;
|
||||
doc.name = profile.name;
|
||||
doc.description = profile.description;
|
||||
@@ -546,12 +547,12 @@ export class ReferenceResolver {
|
||||
|
||||
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await StoredRouteDoc.findById(routeId);
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
securityProfileRef: undefined,
|
||||
securityProfileName: undefined,
|
||||
sourceProfileRef: undefined,
|
||||
sourceProfileName: undefined,
|
||||
};
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
@@ -561,7 +562,7 @@ export class ReferenceResolver {
|
||||
|
||||
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await StoredRouteDoc.findById(routeId);
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
|
||||
import { RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
IStoredRoute,
|
||||
IRouteOverride,
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
@@ -12,66 +11,92 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
||||
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
||||
|
||||
/**
|
||||
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
||||
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
||||
*/
|
||||
class RouteUpdateMutex {
|
||||
private locked = false;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
resolve();
|
||||
} else {
|
||||
this.queue.push(resolve);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.locked = false;
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
this.locked = true;
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
private overrides = new Map<string, IRouteOverride>();
|
||||
private routes = new Map<string, IRoute>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
private routeUpdateMutex = new RouteUpdateMutex();
|
||||
|
||||
constructor(
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||
) {}
|
||||
|
||||
/** Expose stored routes map for reference resolution lookups. */
|
||||
public getStoredRoutes(): Map<string, IStoredRoute> {
|
||||
return this.storedRoutes;
|
||||
/** Expose routes map for reference resolution lookups. */
|
||||
public getRoutes(): Map<string, IRoute> {
|
||||
return this.routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||
* Load persisted routes, seed serializable config/email/dns routes,
|
||||
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadStoredRoutes();
|
||||
await this.loadOverrides();
|
||||
public async initialize(
|
||||
configRoutes: IDcRouterRouteConfig[] = [],
|
||||
emailRoutes: IDcRouterRouteConfig[] = [],
|
||||
dnsRoutes: IDcRouterRouteConfig[] = [],
|
||||
): Promise<void> {
|
||||
await this.loadRoutes();
|
||||
await this.seedRoutes(configRoutes, 'config');
|
||||
await this.seedRoutes(emailRoutes, 'email');
|
||||
await this.seedRoutes(dnsRoutes, 'dns');
|
||||
this.computeWarnings();
|
||||
this.logWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Merged view
|
||||
// Route listing
|
||||
// =========================================================================
|
||||
|
||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||
const merged: IMergedRoute[] = [];
|
||||
|
||||
// Hardcoded routes
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
for (const route of this.routes.values()) {
|
||||
merged.push({
|
||||
route,
|
||||
source: 'hardcoded',
|
||||
enabled: override ? override.enabled : true,
|
||||
overridden: !!override,
|
||||
});
|
||||
}
|
||||
|
||||
// Programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
merged.push({
|
||||
route: stored.route,
|
||||
source: 'programmatic',
|
||||
enabled: stored.enabled,
|
||||
overridden: false,
|
||||
storedRouteId: stored.id,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
metadata: stored.metadata,
|
||||
route: route.route,
|
||||
id: route.id,
|
||||
enabled: route.enabled,
|
||||
origin: route.origin,
|
||||
createdAt: route.createdAt,
|
||||
updatedAt: route.updatedAt,
|
||||
metadata: route.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,11 +104,11 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Programmatic route CRUD
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
route: IDcRouterRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
metadata?: IRouteMetadata,
|
||||
@@ -93,7 +118,7 @@ export class RouteConfigManager {
|
||||
|
||||
// Ensure route has a name
|
||||
if (!route.name) {
|
||||
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||
route.name = `route-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// Resolve references if metadata has refs and resolver is available
|
||||
@@ -104,17 +129,18 @@ export class RouteConfigManager {
|
||||
resolvedMetadata = resolved.metadata;
|
||||
}
|
||||
|
||||
const stored: IStoredRoute = {
|
||||
const stored: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
origin: 'api',
|
||||
metadata: resolvedMetadata,
|
||||
};
|
||||
|
||||
this.storedRoutes.set(id, stored);
|
||||
this.routes.set(id, stored);
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return id;
|
||||
@@ -123,16 +149,27 @@ export class RouteConfigManager {
|
||||
public async updateRoute(
|
||||
id: string,
|
||||
patch: {
|
||||
route?: Partial<plugins.smartproxy.IRouteConfig>;
|
||||
route?: Partial<IDcRouterRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const stored = this.storedRoutes.get(id);
|
||||
const stored = this.routes.get(id);
|
||||
if (!stored) return false;
|
||||
|
||||
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) {
|
||||
stored.enabled = patch.enabled;
|
||||
@@ -156,9 +193,9 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
public async deleteRoute(id: string): Promise<boolean> {
|
||||
if (!this.storedRoutes.has(id)) return false;
|
||||
this.storedRoutes.delete(id);
|
||||
const doc = await StoredRouteDoc.findById(id);
|
||||
if (!this.routes.has(id)) return false;
|
||||
this.routes.delete(id);
|
||||
const doc = await RouteDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
@@ -169,103 +206,141 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hardcoded route overrides
|
||||
// Private: seed routes from constructor config
|
||||
// =========================================================================
|
||||
|
||||
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
||||
const override: IRouteOverride = {
|
||||
routeName,
|
||||
enabled,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy,
|
||||
};
|
||||
this.overrides.set(routeName, 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();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
/**
|
||||
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
|
||||
* Deletes stale DB routes whose origin matches but name is not in the seed set.
|
||||
*/
|
||||
private async seedRoutes(
|
||||
seedRoutes: IDcRouterRouteConfig[],
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
): Promise<void> {
|
||||
if (seedRoutes.length === 0) return;
|
||||
|
||||
public async removeOverride(routeName: string): Promise<boolean> {
|
||||
if (!this.overrides.has(routeName)) return false;
|
||||
this.overrides.delete(routeName);
|
||||
const doc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||
if (doc) await doc.delete();
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
const seedNames = new Set<string>();
|
||||
let seeded = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const route of seedRoutes) {
|
||||
const name = route.name || '';
|
||||
seedNames.add(name);
|
||||
|
||||
// Check if a route with this name+origin already exists in memory
|
||||
let existingId: string | undefined;
|
||||
for (const [id, r] of this.routes) {
|
||||
if (r.origin === origin && r.route.name === name) {
|
||||
existingId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingId) {
|
||||
// Update route config but preserve enabled state
|
||||
const existing = this.routes.get(existingId)!;
|
||||
existing.route = route;
|
||||
existing.updatedAt = Date.now();
|
||||
await this.persistRoute(existing);
|
||||
updated++;
|
||||
} else {
|
||||
// Insert new seed route
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
const newRoute: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'system',
|
||||
origin,
|
||||
};
|
||||
this.routes.set(id, newRoute);
|
||||
await this.persistRoute(newRoute);
|
||||
seeded++;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stale routes: same origin but name not in current seed set
|
||||
const staleIds: string[] = [];
|
||||
for (const [id, r] of this.routes) {
|
||||
if (r.origin === origin && !seedNames.has(r.route.name || '')) {
|
||||
staleIds.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of staleIds) {
|
||||
this.routes.delete(id);
|
||||
const doc = await RouteDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
}
|
||||
|
||||
if (seeded > 0 || updated > 0 || staleIds.length > 0) {
|
||||
logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadStoredRoutes(): Promise<void> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
private async loadRoutes(): Promise<void> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
let prunedRuntimeRoutes = 0;
|
||||
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.storedRoutes.set(doc.id, {
|
||||
id: doc.id,
|
||||
route: doc.route,
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
metadata: doc.metadata,
|
||||
});
|
||||
if (!doc.id) continue;
|
||||
|
||||
const storedRoute: IRoute = {
|
||||
id: doc.id,
|
||||
route: doc.route,
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
origin: doc.origin || 'api',
|
||||
metadata: doc.metadata,
|
||||
};
|
||||
|
||||
if (this.isPersistedRuntimeRoute(storedRoute)) {
|
||||
await doc.delete();
|
||||
prunedRuntimeRoutes++;
|
||||
logger.log(
|
||||
'warn',
|
||||
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.routes.set(doc.id, storedRoute);
|
||||
}
|
||||
if (this.storedRoutes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||
if (this.routes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
||||
}
|
||||
if (prunedRuntimeRoutes > 0) {
|
||||
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOverrides(): Promise<void> {
|
||||
const docs = await RouteOverrideDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.routeName) {
|
||||
this.overrides.set(doc.routeName, {
|
||||
routeName: doc.routeName,
|
||||
enabled: doc.enabled,
|
||||
updatedAt: doc.updatedAt,
|
||||
updatedBy: doc.updatedBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.overrides.size > 0) {
|
||||
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||
const existingDoc = await StoredRouteDoc.findById(stored.id);
|
||||
private async persistRoute(stored: IRoute): Promise<void> {
|
||||
const existingDoc = await RouteDoc.findById(stored.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.route = stored.route;
|
||||
existingDoc.enabled = stored.enabled;
|
||||
existingDoc.updatedAt = stored.updatedAt;
|
||||
existingDoc.createdBy = stored.createdBy;
|
||||
existingDoc.origin = stored.origin;
|
||||
existingDoc.metadata = stored.metadata;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new StoredRouteDoc();
|
||||
const doc = new RouteDoc();
|
||||
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.origin = stored.origin;
|
||||
doc.metadata = stored.metadata;
|
||||
await doc.save();
|
||||
}
|
||||
@@ -277,33 +352,14 @@ export class RouteConfigManager {
|
||||
|
||||
private computeWarnings(): void {
|
||||
this.warnings = [];
|
||||
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||
|
||||
// Check overrides
|
||||
for (const [routeName, override] of this.overrides) {
|
||||
if (!hardcodedNames.has(routeName)) {
|
||||
for (const route of this.routes.values()) {
|
||||
if (!route.enabled) {
|
||||
const name = route.route.name || route.id;
|
||||
this.warnings.push({
|
||||
type: 'orphaned-override',
|
||||
routeName,
|
||||
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
||||
});
|
||||
} else if (!override.enabled) {
|
||||
this.warnings.push({
|
||||
type: 'disabled-hardcoded',
|
||||
routeName,
|
||||
message: `Route '${routeName}' is disabled via API override`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check disabled programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (!stored.enabled) {
|
||||
const name = stored.route.name || stored.id;
|
||||
this.warnings.push({
|
||||
type: 'disabled-programmatic',
|
||||
type: 'disabled-route',
|
||||
routeName: name,
|
||||
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
||||
message: `Route '${name}' (id: ${route.id}) is disabled`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -327,7 +383,7 @@ export class RouteConfigManager {
|
||||
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||
|
||||
for (const routeId of routeIds) {
|
||||
const stored = this.storedRoutes.get(routeId);
|
||||
const stored = this.routes.get(routeId);
|
||||
if (!stored?.metadata) continue;
|
||||
|
||||
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||
@@ -342,64 +398,79 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: apply merged routes to SmartProxy
|
||||
// Apply routes to SmartProxy
|
||||
// =========================================================================
|
||||
|
||||
public async applyRoutes(): Promise<void> {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
await this.routeUpdateMutex.runExclusive(async () => {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnAllowList = this.getVpnAllowList;
|
||||
|
||||
// Helper: inject VPN security into a route if vpn.enabled is set
|
||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => {
|
||||
if (!vpnAllowList) return route;
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpn?.enabled) return route;
|
||||
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
|
||||
const mandatory = dcRoute.vpn.mandatory === true; // defaults to false
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: mandatory
|
||||
? allowList
|
||||
: [...(route.security?.ipAllowList || []), ...allowList],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config && http3Config.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
||||
});
|
||||
}
|
||||
|
||||
private prepareRouteForApply(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
let preparedRoute = route;
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
|
||||
if (http3Config?.enabled !== false) {
|
||||
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
return this.injectVpnSecurity(preparedRoute, routeId);
|
||||
}
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
private injectVpnSecurity(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
if (!vpnCallback) return route;
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
|
||||
const routeName = storedRoute.route.name || '';
|
||||
const actionType = storedRoute.route.action?.type;
|
||||
|
||||
return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
|
||||
|| (storedRoute.origin === 'dns' && actionType === 'socket-handler');
|
||||
}
|
||||
}
|
||||
|
||||
538
ts/config/classes.target-profile-manager.ts
Normal file
538
ts/config/classes.target-profile-manager.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
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 { IRoute } 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>();
|
||||
|
||||
constructor(
|
||||
private getAllRoutes?: () => Map<string, IRoute>,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// 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 routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||
const profile: ITargetProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
domains: data.domains,
|
||||
targets: data.targets,
|
||||
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 && patch.name !== profile.name) {
|
||||
for (const existing of this.profiles.values()) {
|
||||
if (existing.id !== id && existing.name === patch.name) {
|
||||
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = this.normalizeRouteRefs(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize stored route references to route IDs when they can be resolved
|
||||
* uniquely against the current route registry.
|
||||
*/
|
||||
public async normalizeAllRouteRefs(): Promise<void> {
|
||||
const allRoutes = this.getAllRoutes?.();
|
||||
if (!allRoutes?.size) return;
|
||||
|
||||
for (const profile of this.profiles.values()) {
|
||||
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
||||
profile.routeRefs,
|
||||
allRoutes,
|
||||
'bestEffort',
|
||||
);
|
||||
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
||||
|
||||
profile.routeRefs = normalizedRouteRefs;
|
||||
profile.updatedAt = Date.now();
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.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[],
|
||||
allRoutes: Map<string, IRoute> = new Map(),
|
||||
): Array<string | { ip: string; domains: string[] }> {
|
||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
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,
|
||||
routeNameIndex,
|
||||
);
|
||||
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: Map<string, IRoute>,
|
||||
): { domains: string[]; targetIps: string[] } {
|
||||
const domains = new Set<string>();
|
||||
const targetIps = new Set<string>();
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
// 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 all routes
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
if (!route.enabled) continue;
|
||||
if (this.routeMatchesProfile(
|
||||
route.route as IDcRouterRouteConfig,
|
||||
routeId,
|
||||
profile,
|
||||
routeNameIndex,
|
||||
)) {
|
||||
const routeDomains = (route.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,
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): boolean {
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const result = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
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[],
|
||||
routeNameIndex: Map<string, 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 (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
||||
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
||||
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
||||
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 normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||
}
|
||||
|
||||
private normalizeRouteRefsAgainstRoutes(
|
||||
routeRefs: string[] | undefined,
|
||||
allRoutes: Map<string, IRoute>,
|
||||
mode: 'strict' | 'bestEffort',
|
||||
): string[] | undefined {
|
||||
if (!routeRefs?.length) return undefined;
|
||||
if (!allRoutes.size) return [...new Set(routeRefs)];
|
||||
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
const normalizedRefs = new Set<string>();
|
||||
|
||||
for (const routeRef of routeRefs) {
|
||||
if (allRoutes.has(routeRef)) {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
||||
if (matchingRouteIds.length === 1) {
|
||||
normalizedRefs.add(matchingRouteIds[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode === 'bestEffort') {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchingRouteIds.length > 1) {
|
||||
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
||||
}
|
||||
throw new Error(`Route reference '${routeRef}' not found`);
|
||||
}
|
||||
|
||||
return [...normalizedRefs];
|
||||
}
|
||||
|
||||
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
||||
const routeNameIndex = new Map<string, string[]>();
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
const routeName = route.route.name;
|
||||
if (!routeName) continue;
|
||||
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
||||
matchingRouteIds.push(routeId);
|
||||
routeNameIndex.set(routeName, matchingRouteIds);
|
||||
}
|
||||
return routeNameIndex;
|
||||
}
|
||||
|
||||
private sameStringArray(left?: string[], right?: string[]): boolean {
|
||||
if (!left?.length && !right?.length) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadProfiles(): Promise<void> {
|
||||
const docs = await TargetProfileDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.profiles.set(doc.id, {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
domains: doc.domains,
|
||||
targets: doc.targets,
|
||||
routeRefs: doc.routeRefs,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.profiles.size > 0) {
|
||||
logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistProfile(profile: ITargetProfile): Promise<void> {
|
||||
const existingDoc = await TargetProfileDoc.findById(profile.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = profile.name;
|
||||
existingDoc.description = profile.description;
|
||||
existingDoc.domains = profile.domains;
|
||||
existingDoc.targets = profile.targets;
|
||||
existingDoc.routeRefs = profile.routeRefs;
|
||||
existingDoc.updatedAt = profile.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new TargetProfileDoc();
|
||||
doc.id = profile.id;
|
||||
doc.name = profile.name;
|
||||
doc.description = profile.description;
|
||||
doc.domains = profile.domains;
|
||||
doc.targets = profile.targets;
|
||||
doc.routeRefs = profile.routeRefs;
|
||||
doc.createdAt = profile.createdAt;
|
||||
doc.updatedAt = profile.updatedAt;
|
||||
doc.createdBy = profile.createdBy;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export * from './validator.js';
|
||||
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
export { DbSeeder } from './classes.db-seeder.js';
|
||||
export { DbSeeder } from './classes.db-seeder.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* Singleton ACME configuration document. One row per dcrouter instance,
|
||||
* keyed on the fixed `configId = 'acme-config'` following the
|
||||
* `VpnServerKeysDoc` pattern.
|
||||
*
|
||||
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
|
||||
* constructor fields. Managed via the OpsServer UI at
|
||||
* **Domains > Certificates > Settings**.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public configId: string = 'acme-config';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public accountEmail: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useProduction: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public autoRenew: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public renewThresholdDays: number = 30;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedBy: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async load(): Promise<AcmeConfigDoc | null> {
|
||||
return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
|
||||
}
|
||||
}
|
||||
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
TDnsProviderType,
|
||||
TDnsProviderStatus,
|
||||
TDnsProviderCredentials,
|
||||
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc<DnsProviderDoc, DnsProviderDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TDnsProviderType;
|
||||
|
||||
/**
|
||||
* Provider credentials, persisted as an opaque object. Shape varies by `type`.
|
||||
* Never returned to the UI — handlers map to IDnsProviderPublic before sending.
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public credentials!: TDnsProviderCredentials;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TDnsProviderStatus = 'untested';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DnsProviderDoc | null> {
|
||||
return await DnsProviderDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DnsProviderDoc[]> {
|
||||
return await DnsProviderDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByType(type: TDnsProviderType): Promise<DnsProviderDoc[]> {
|
||||
return await DnsProviderDoc.getInstances({ type });
|
||||
}
|
||||
}
|
||||
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc, DnsRecordDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domainId!: string;
|
||||
|
||||
/** FQDN of the record (e.g. 'www.example.com'). */
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TDnsRecordType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ttl: number = 300;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public proxied?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public source!: TDnsRecordSource;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public providerRecordId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DnsRecordDoc | null> {
|
||||
return await DnsRecordDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DnsRecordDoc[]> {
|
||||
return await DnsRecordDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByDomainId(domainId: string): Promise<DnsRecordDoc[]> {
|
||||
return await DnsRecordDoc.getInstances({ domainId });
|
||||
}
|
||||
}
|
||||
66
ts/db/documents/classes.domain.doc.ts
Normal file
66
ts/db/documents/classes.domain.doc.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TDomainSource } from '../../../ts_interfaces/data/domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DomainDoc extends plugins.smartdata.SmartDataDbDoc<DomainDoc, DomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
/** FQDN — kept lowercased on save. */
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public source!: TDomainSource;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public providerId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public authoritative: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public nameservers?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public externalZoneId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastSyncedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DomainDoc | null> {
|
||||
return await DomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<DomainDoc | null> {
|
||||
return await DomainDoc.getInstance({ name: name.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DomainDoc[]> {
|
||||
return await DomainDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByProviderId(providerId: string): Promise<DomainDoc[]> {
|
||||
return await DomainDoc.getInstances({ providerId });
|
||||
}
|
||||
}
|
||||
56
ts/db/documents/classes.email-domain.doc.ts
Normal file
56
ts/db/documents/classes.email-domain.doc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
IEmailDomainDkim,
|
||||
IEmailDomainRateLimits,
|
||||
IEmailDomainDnsStatus,
|
||||
} from '../../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomainDoc, EmailDomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public linkedDomainId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public subdomain?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dkim!: IEmailDomainDkim;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public rateLimits?: IEmailDomainRateLimits;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dnsStatus!: IEmailDomainDnsStatus;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<EmailDomainDoc[]> {
|
||||
return await EmailDomainDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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({});
|
||||
}
|
||||
}
|
||||
54
ts/db/documents/classes.route.doc.ts
Normal file
54
ts/db/documents/classes.route.doc.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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 RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
|
||||
@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 origin!: 'config' | 'email' | 'dns' | 'api';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata?: IRouteMetadata;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<RouteDoc[]> {
|
||||
return await RouteDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ 'route.name': name });
|
||||
}
|
||||
|
||||
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
|
||||
return await RouteDoc.getInstances({ origin });
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { IRouteSecurity } from '../../../ts_interfaces/data/route-managemen
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<SecurityProfileDoc, SecurityProfileDoc> {
|
||||
export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourceProfileDoc, SourceProfileDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
@@ -35,15 +35,11 @@ export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<Securit
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SecurityProfileDoc | null> {
|
||||
return await SecurityProfileDoc.getInstance({ id });
|
||||
public static async findById(id: string): Promise<SourceProfileDoc | null> {
|
||||
return await SourceProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<SecurityProfileDoc | null> {
|
||||
return await SecurityProfileDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SecurityProfileDoc[]> {
|
||||
return await SecurityProfileDoc.getInstances({});
|
||||
public static async findAll(): Promise<SourceProfileDoc[]> {
|
||||
return await SourceProfileDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.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!: plugins.smartproxy.IRouteConfig;
|
||||
|
||||
@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({});
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public serverDefinedClientTags?: string[];
|
||||
public targetProfileIds?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
@@ -39,9 +39,6 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public forceDestinationSmartproxy: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public destinationAllowList?: string[];
|
||||
|
||||
@@ -67,15 +64,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByClientId(clientId: string): Promise<VpnClientDoc | null> {
|
||||
return await VpnClientDoc.getInstance({ clientId });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<VpnClientDoc[]> {
|
||||
return await VpnClientDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<VpnClientDoc[]> {
|
||||
return await VpnClientDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ 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.route.doc.js';
|
||||
export * from './classes.api-token.doc.js';
|
||||
export * from './classes.security-profile.doc.js';
|
||||
export * from './classes.source-profile.doc.js';
|
||||
export * from './classes.target-profile.doc.js';
|
||||
export * from './classes.network-target.doc.js';
|
||||
|
||||
// VPN document classes
|
||||
@@ -24,3 +24,14 @@ 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';
|
||||
|
||||
// Email domain management
|
||||
export * from './classes.email-domain.doc.js';
|
||||
|
||||
2
ts/dns/index.ts
Normal file
2
ts/dns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './manager.dns.js';
|
||||
export * from './providers/index.js';
|
||||
1064
ts/dns/manager.dns.ts
Normal file
1064
ts/dns/manager.dns.ts
Normal file
File diff suppressed because it is too large
Load Diff
131
ts/dns/providers/cloudflare.provider.ts
Normal file
131
ts/dns/providers/cloudflare.provider.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type {
|
||||
IDnsProviderClient,
|
||||
IConnectionTestResult,
|
||||
IProviderRecord,
|
||||
IProviderRecordInput,
|
||||
} from './interfaces.js';
|
||||
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||
|
||||
/**
|
||||
* Cloudflare implementation of IDnsProviderClient.
|
||||
*
|
||||
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
|
||||
* an internal record id, which we surface as `providerRecordId` so the rest
|
||||
* of the system can issue updates and deletes without ambiguity (Cloudflare
|
||||
* can have multiple records of the same name+type).
|
||||
*/
|
||||
export class CloudflareDnsProvider implements IDnsProviderClient {
|
||||
private cfAccount: plugins.cloudflare.CloudflareAccount;
|
||||
|
||||
constructor(apiToken: string) {
|
||||
if (!apiToken) {
|
||||
throw new Error('CloudflareDnsProvider: apiToken is required');
|
||||
}
|
||||
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
|
||||
}
|
||||
|
||||
public async testConnection(): Promise<IConnectionTestResult> {
|
||||
try {
|
||||
// Listing zones is the lightest-weight call that proves the token works.
|
||||
await this.cfAccount.zoneManager.listZones();
|
||||
return { ok: true };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
public async listDomains(): Promise<IProviderDomainListing[]> {
|
||||
const zones = await this.cfAccount.zoneManager.listZones();
|
||||
return zones.map((zone) => ({
|
||||
name: zone.name,
|
||||
externalId: zone.id,
|
||||
nameservers: zone.name_servers ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
public async listRecords(domain: string): Promise<IProviderRecord[]> {
|
||||
const records = await this.cfAccount.recordManager.listRecords(domain);
|
||||
return records
|
||||
.filter((r) => this.isSupportedType(r.type))
|
||||
.map((r) => ({
|
||||
providerRecordId: r.id,
|
||||
name: r.name,
|
||||
type: r.type as TDnsRecordType,
|
||||
value: r.content,
|
||||
ttl: r.ttl,
|
||||
proxied: r.proxied,
|
||||
}));
|
||||
}
|
||||
|
||||
public async createRecord(
|
||||
domain: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
const apiRecord: any = {
|
||||
zone_id: zoneId,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.value,
|
||||
ttl: record.ttl ?? 1, // 1 = automatic
|
||||
};
|
||||
if (record.proxied !== undefined) {
|
||||
apiRecord.proxied = record.proxied;
|
||||
}
|
||||
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
|
||||
return {
|
||||
providerRecordId: created.id,
|
||||
name: created.name,
|
||||
type: created.type as TDnsRecordType,
|
||||
value: created.content,
|
||||
ttl: created.ttl,
|
||||
proxied: created.proxied,
|
||||
};
|
||||
}
|
||||
|
||||
public async updateRecord(
|
||||
domain: string,
|
||||
providerRecordId: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
const apiRecord: any = {
|
||||
zone_id: zoneId,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.value,
|
||||
ttl: record.ttl ?? 1,
|
||||
};
|
||||
if (record.proxied !== undefined) {
|
||||
apiRecord.proxied = record.proxied;
|
||||
}
|
||||
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
|
||||
providerRecordId,
|
||||
apiRecord,
|
||||
);
|
||||
return {
|
||||
providerRecordId: updated.id,
|
||||
name: updated.name,
|
||||
type: updated.type as TDnsRecordType,
|
||||
value: updated.content,
|
||||
ttl: updated.ttl,
|
||||
proxied: updated.proxied,
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
|
||||
zone_id: zoneId,
|
||||
});
|
||||
}
|
||||
|
||||
private isSupportedType(type: string): boolean {
|
||||
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
|
||||
}
|
||||
}
|
||||
59
ts/dns/providers/factory.ts
Normal file
59
ts/dns/providers/factory.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { IDnsProviderClient } from './interfaces.js';
|
||||
import type {
|
||||
TDnsProviderType,
|
||||
TDnsProviderCredentials,
|
||||
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||
import { CloudflareDnsProvider } from './cloudflare.provider.js';
|
||||
|
||||
/**
|
||||
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
|
||||
*
|
||||
* @throws if the provider type is not supported.
|
||||
*
|
||||
* ## Adding a new provider (e.g. Route53)
|
||||
*
|
||||
* 1. **Type union** — extend `TDnsProviderType` in
|
||||
* `ts_interfaces/data/dns-provider.ts` (e.g. `'cloudflare' | 'route53'`).
|
||||
* 2. **Credentials interface** — add `IRoute53Credentials` and append it to
|
||||
* the `TDnsProviderCredentials` discriminated union.
|
||||
* 3. **Descriptor** — append a new entry to `dnsProviderTypeDescriptors` so
|
||||
* the OpsServer UI picks up the new type and renders the right credential
|
||||
* form fields automatically.
|
||||
* 4. **Provider class** — create `ts/dns/providers/route53.provider.ts`
|
||||
* implementing `IDnsProviderClient`.
|
||||
* 5. **Factory case** — add a new `case 'route53':` below. The
|
||||
* `_exhaustive: never` line will fail to compile until you do.
|
||||
* 6. **Index** — re-export the new class from `ts/dns/providers/index.ts`.
|
||||
*/
|
||||
export function createDnsProvider(
|
||||
type: TDnsProviderType,
|
||||
credentials: TDnsProviderCredentials,
|
||||
): IDnsProviderClient {
|
||||
switch (type) {
|
||||
case 'cloudflare': {
|
||||
if (credentials.type !== 'cloudflare') {
|
||||
throw new Error(
|
||||
`createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`,
|
||||
);
|
||||
}
|
||||
return new CloudflareDnsProvider(credentials.apiToken);
|
||||
}
|
||||
case 'dcrouter': {
|
||||
// The built-in DcRouter pseudo-provider has no runtime client — dcrouter
|
||||
// itself serves the records via the embedded smartdns.DnsServer. This
|
||||
// case exists only to satisfy the exhaustive switch; it should never
|
||||
// actually run because the handler layer rejects any CRUD that would
|
||||
// result in a DnsProviderDoc with type: 'dcrouter'.
|
||||
throw new Error(
|
||||
`createDnsProvider: 'dcrouter' is a built-in pseudo-provider — no runtime client exists. ` +
|
||||
`This call indicates a DnsProviderDoc with type: 'dcrouter' was persisted, which should never happen.`,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
// If you see a TypeScript error here after extending TDnsProviderType,
|
||||
// add a `case` for the new type above. The `never` enforces exhaustiveness.
|
||||
const _exhaustive: never = type;
|
||||
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
ts/dns/providers/index.ts
Normal file
3
ts/dns/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './interfaces.js';
|
||||
export * from './cloudflare.provider.js';
|
||||
export * from './factory.js';
|
||||
67
ts/dns/providers/interfaces.ts
Normal file
67
ts/dns/providers/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||
|
||||
/**
|
||||
* A DNS record as seen at a provider's API. The `providerRecordId` field
|
||||
* is the provider's internal identifier, used for subsequent updates and
|
||||
* deletes (since providers can have multiple records of the same name+type).
|
||||
*/
|
||||
export interface IProviderRecord {
|
||||
providerRecordId: string;
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
value: string;
|
||||
ttl: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input shape for creating / updating a DNS record at a provider.
|
||||
*/
|
||||
export interface IProviderRecordInput {
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a connection test against a provider's API.
|
||||
*/
|
||||
export interface IConnectionTestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable DNS provider client interface. One implementation per provider type
|
||||
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
|
||||
* instantiated by `createDnsProvider()` in factory.ts.
|
||||
*
|
||||
* NOT a smartdata interface — this is the *runtime* client. The persisted
|
||||
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
|
||||
*/
|
||||
export interface IDnsProviderClient {
|
||||
/** Lightweight check that credentials are valid and the API is reachable. */
|
||||
testConnection(): Promise<IConnectionTestResult>;
|
||||
|
||||
/** List all DNS zones visible to this provider account. */
|
||||
listDomains(): Promise<IProviderDomainListing[]>;
|
||||
|
||||
/** List all DNS records for a zone (FQDN). */
|
||||
listRecords(domain: string): Promise<IProviderRecord[]>;
|
||||
|
||||
/** Create a new DNS record at the provider; returns the created record (with id). */
|
||||
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
|
||||
|
||||
/** Update an existing record by provider id; returns the updated record. */
|
||||
updateRecord(
|
||||
domain: string,
|
||||
providerRecordId: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord>;
|
||||
|
||||
/** Delete a record by provider id. */
|
||||
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
|
||||
}
|
||||
426
ts/email/classes.email-domain.manager.ts
Normal file
426
ts/email/classes.email-domain.manager.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
import { logger } from '../logger.js';
|
||||
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
||||
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
||||
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
||||
import type { DnsManager } from '../dns/manager.dns.js';
|
||||
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
/**
|
||||
* EmailDomainManager — orchestrates email domain setup.
|
||||
*
|
||||
* Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager
|
||||
* (record creation for dcrouter-hosted and provider-managed zones) to provide
|
||||
* a single entry point for setting up an email domain from A to Z.
|
||||
*/
|
||||
export class EmailDomainManager {
|
||||
private dcRouter: any; // DcRouter — avoids circular import
|
||||
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||
|
||||
constructor(dcRouterRef: any) {
|
||||
this.dcRouter = dcRouterRef;
|
||||
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
private get dnsManager(): DnsManager | undefined {
|
||||
return this.dcRouter.dnsManager;
|
||||
}
|
||||
|
||||
private get dkimCreator(): any | undefined {
|
||||
return this.dcRouter.emailServer?.dkimCreator;
|
||||
}
|
||||
|
||||
private get emailHostname(): string {
|
||||
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public async getAll(): Promise<IEmailDomain[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
return docs.map((d) => this.docToInterface(d));
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<IEmailDomain | null> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
return doc ? this.docToInterface(doc) : null;
|
||||
}
|
||||
|
||||
public async createEmailDomain(opts: {
|
||||
linkedDomainId: string;
|
||||
subdomain?: string;
|
||||
dkimSelector?: string;
|
||||
dkimKeySize?: number;
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
}): Promise<IEmailDomain> {
|
||||
// Resolve the linked DNS domain
|
||||
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
|
||||
if (!domainDoc) {
|
||||
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
|
||||
}
|
||||
const baseDomain = domainDoc.name;
|
||||
const subdomain = opts.subdomain?.trim() || undefined;
|
||||
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||
|
||||
// Check for duplicates
|
||||
if (this.isDomainAlreadyConfigured(domainName)) {
|
||||
throw new Error(`Email domain already configured for ${domainName}`);
|
||||
}
|
||||
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||
if (existing) {
|
||||
throw new Error(`Email domain already exists for ${domainName}`);
|
||||
}
|
||||
|
||||
const selector = opts.dkimSelector || 'default';
|
||||
const keySize = opts.dkimKeySize || 2048;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Generate DKIM keys
|
||||
let publicKey: string | undefined;
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
||||
// Extract public key from the DNS record value
|
||||
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
||||
publicKey = match ? match[1] : undefined;
|
||||
logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the document
|
||||
const doc = new EmailDomainDoc();
|
||||
doc.id = plugins.smartunique.shortId();
|
||||
doc.domain = domainName.toLowerCase();
|
||||
doc.linkedDomainId = opts.linkedDomainId;
|
||||
doc.subdomain = subdomain;
|
||||
doc.dkim = {
|
||||
selector,
|
||||
keySize,
|
||||
publicKey,
|
||||
rotateKeys: opts.rotateKeys ?? false,
|
||||
rotationIntervalDays: opts.rotationIntervalDays ?? 90,
|
||||
};
|
||||
doc.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
await doc.save();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
|
||||
logger.log('info', `Email domain created: ${domainName}`);
|
||||
return this.docToInterface(doc);
|
||||
}
|
||||
|
||||
public async updateEmailDomain(
|
||||
id: string,
|
||||
changes: {
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
rateLimits?: IEmailDomain['rateLimits'];
|
||||
},
|
||||
): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
|
||||
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
|
||||
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
}
|
||||
|
||||
public async deleteEmailDomain(id: string): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
await doc.delete();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS record computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the 4 required DNS records for an email domain.
|
||||
*/
|
||||
public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const hostname = this.emailHostname;
|
||||
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
||||
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
|
||||
dkimValue = dnsRecord.value;
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const records: IEmailDnsRecord[] = [
|
||||
{
|
||||
type: 'MX',
|
||||
name: domain,
|
||||
value: `10 ${hostname}`,
|
||||
status: doc.dnsStatus.mx,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: domain,
|
||||
value: 'v=spf1 a mx ~all',
|
||||
status: doc.dnsStatus.spf,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `${selector}._domainkey.${domain}`,
|
||||
value: dkimValue,
|
||||
status: doc.dnsStatus.dkim,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `_dmarc.${domain}`,
|
||||
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
|
||||
status: doc.dnsStatus.dmarc,
|
||||
},
|
||||
];
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS provisioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Auto-create missing DNS records via the linked domain's DNS path.
|
||||
*/
|
||||
public async provisionDnsRecords(id: string): Promise<number> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
if (!this.dnsManager) throw new Error('DnsManager not available');
|
||||
|
||||
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||
const domainId = doc.linkedDomainId;
|
||||
|
||||
// Get existing DNS records for the linked domain
|
||||
const existingRecords = await DnsRecordDoc.findByDomainId(domainId);
|
||||
let provisioned = 0;
|
||||
|
||||
for (const required of requiredRecords) {
|
||||
// Check if a matching record already exists
|
||||
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
||||
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.dnsManager.createRecord({
|
||||
domainId,
|
||||
name: required.name,
|
||||
type: required.type as any,
|
||||
value: required.value,
|
||||
ttl: 3600,
|
||||
createdBy: 'email-domain-manager',
|
||||
});
|
||||
provisioned++;
|
||||
logger.log('info', `Provisioned ${required.type} record for ${required.name}`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate after provisioning
|
||||
await this.validateDns(id);
|
||||
|
||||
return provisioned;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate DNS records via live lookups.
|
||||
*/
|
||||
public async validateDns(id: string): Promise<IEmailDnsRecord[]> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const resolver = new plugins.dns.promises.Resolver();
|
||||
|
||||
// MX check
|
||||
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||
|
||||
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
|
||||
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
|
||||
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
||||
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
||||
|
||||
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
|
||||
|
||||
// SPF check
|
||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
||||
|
||||
// DKIM check
|
||||
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
||||
|
||||
// DMARC check
|
||||
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
|
||||
|
||||
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
|
||||
return this.getRequiredDnsRecords(id);
|
||||
}
|
||||
|
||||
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
|
||||
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
return record.value.trim() === required.value.trim();
|
||||
}
|
||||
|
||||
private async checkMx(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
domain: string,
|
||||
expectedValue?: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveMx(domain);
|
||||
if (!records || records.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
if (!expectedValue) {
|
||||
return 'valid';
|
||||
}
|
||||
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
|
||||
return found ? 'valid' : 'invalid';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
private async checkTxtRecord(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
name: string,
|
||||
expectedValue?: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveTxt(name);
|
||||
const flat = records.map((r) => r.join(''));
|
||||
if (flat.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
if (!expectedValue) {
|
||||
return 'valid';
|
||||
}
|
||||
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
||||
return found ? 'valid' : 'invalid';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private docToInterface(doc: EmailDomainDoc): IEmailDomain {
|
||||
return {
|
||||
id: doc.id,
|
||||
domain: doc.domain,
|
||||
linkedDomainId: doc.linkedDomainId,
|
||||
subdomain: doc.subdomain,
|
||||
dkim: doc.dkim,
|
||||
rateLimits: doc.rateLimits,
|
||||
dnsStatus: doc.dnsStatus,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private isDomainAlreadyConfigured(domainName: string): boolean {
|
||||
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
.map((domainConfig) => domainConfig.domain.toLowerCase());
|
||||
return configuredDomains.includes(domainName.toLowerCase());
|
||||
}
|
||||
|
||||
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
const managedConfigs: IEmailDomainConfig[] = [];
|
||||
|
||||
for (const doc of docs) {
|
||||
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
|
||||
if (!linkedDomain) {
|
||||
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
managedConfigs.push({
|
||||
domain: doc.domain,
|
||||
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
|
||||
dkim: {
|
||||
selector: doc.dkim.selector,
|
||||
keySize: doc.dkim.keySize,
|
||||
rotateKeys: doc.dkim.rotateKeys,
|
||||
rotationInterval: doc.dkim.rotationIntervalDays,
|
||||
},
|
||||
rateLimits: doc.rateLimits,
|
||||
});
|
||||
}
|
||||
|
||||
return managedConfigs;
|
||||
}
|
||||
|
||||
private async syncManagedDomainsToRuntime(): Promise<void> {
|
||||
if (!this.dcRouter.options?.emailConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
||||
for (const domainConfig of this.baseEmailDomains) {
|
||||
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
for (const managedConfig of await this.buildManagedDomainConfigs()) {
|
||||
const key = managedConfig.domain.toLowerCase();
|
||||
if (mergedDomains.has(key)) {
|
||||
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
|
||||
continue;
|
||||
}
|
||||
mergedDomains.set(key, managedConfig);
|
||||
}
|
||||
|
||||
const domains = Array.from(mergedDomains.values());
|
||||
this.dcRouter.options.emailConfig.domains = domains;
|
||||
if (this.dcRouter.emailServer) {
|
||||
this.dcRouter.emailServer.updateOptions({ domains });
|
||||
}
|
||||
}
|
||||
}
|
||||
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IStorageManagerLike } from '@push.rocks/smartmta';
|
||||
|
||||
export class SmartMtaStorageManager implements IStorageManagerLike {
|
||||
private readonly resolvedRootDir: string;
|
||||
|
||||
constructor(private rootDir: string) {
|
||||
this.resolvedRootDir = plugins.path.resolve(rootDir);
|
||||
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
|
||||
}
|
||||
|
||||
private normalizeKey(key: string): string {
|
||||
return key.replace(/^\/+/, '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
private resolvePathForKey(key: string): string {
|
||||
const normalizedKey = this.normalizeKey(key);
|
||||
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
|
||||
if (
|
||||
resolvedPath !== this.resolvedRootDir
|
||||
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
|
||||
) {
|
||||
throw new Error(`Storage key escapes root directory: ${key}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
private toStorageKey(filePath: string): string {
|
||||
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
|
||||
return `/${relativePath}`;
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
const filePath = this.resolvePathForKey(key);
|
||||
try {
|
||||
return await plugins.fs.promises.readFile(filePath, 'utf8');
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async set(key: string, value: string): Promise<void> {
|
||||
const filePath = this.resolvePathForKey(key);
|
||||
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
|
||||
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
|
||||
}
|
||||
|
||||
public async list(prefix: string): Promise<string[]> {
|
||||
const prefixPath = this.resolvePathForKey(prefix);
|
||||
try {
|
||||
const stat = await plugins.fs.promises.stat(prefixPath);
|
||||
if (stat.isFile()) {
|
||||
return [this.toStorageKey(prefixPath)];
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
const walk = async (currentPath: string): Promise<void> => {
|
||||
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = plugins.path.join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(this.toStorageKey(entryPath));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(prefixPath);
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
public async delete(key: string): Promise<void> {
|
||||
const targetPath = this.resolvePathForKey(key);
|
||||
try {
|
||||
const stat = await plugins.fs.promises.stat(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
|
||||
} else {
|
||||
await plugins.fs.promises.unlink(targetPath);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let currentDir = plugins.path.dirname(targetPath);
|
||||
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
|
||||
const entries = await plugins.fs.promises.readdir(currentDir);
|
||||
if (entries.length > 0) {
|
||||
break;
|
||||
}
|
||||
await plugins.fs.promises.rmdir(currentDir);
|
||||
currentDir = plugins.path.dirname(currentDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
ts/email/index.ts
Normal file
2
ts/email/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
export * from './classes.smartmta-storage-manager.js';
|
||||
@@ -553,12 +553,14 @@ export class MetricsManager {
|
||||
connectionsByIP: new Map<string, number>(),
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [] as Array<{ ip: string; count: number }>,
|
||||
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [] as Array<any>,
|
||||
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -572,7 +574,7 @@ export class MetricsManager {
|
||||
bytesOutPerSecond: instantThroughput.out
|
||||
};
|
||||
|
||||
// Get top IPs
|
||||
// Get top IPs by connection count
|
||||
const topIPs = proxyMetrics.connections.topIPs(10);
|
||||
|
||||
// Get total data transferred
|
||||
@@ -591,6 +593,10 @@ export class MetricsManager {
|
||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||
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();
|
||||
@@ -695,16 +701,150 @@ export class MetricsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Build top 10 IPs by bandwidth (sorted by total throughput desc)
|
||||
const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
|
||||
for (const [ip, count] of connectionsByIP) {
|
||||
allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
|
||||
}
|
||||
for (const [ip, tp] of throughputByIP) {
|
||||
const existing = allIPData.get(ip);
|
||||
if (existing) {
|
||||
existing.bwIn = tp.in;
|
||||
existing.bwOut = tp.out;
|
||||
} else {
|
||||
allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
|
||||
}
|
||||
}
|
||||
const topIPsByBandwidth = Array.from(allIPData.entries())
|
||||
.sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
|
||||
.slice(0, 10)
|
||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||
|
||||
// Build domain activity using per-IP domain request counts from Rust engine
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const throughputByRoute = proxyMetrics.throughput.byRoute();
|
||||
|
||||
// Aggregate per-IP domain request counts into per-domain totals
|
||||
const domainRequestTotals = new Map<string, number>();
|
||||
const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP();
|
||||
for (const [, domainMap] of domainRequestsByIP) {
|
||||
for (const [domain, count] of domainMap) {
|
||||
domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count);
|
||||
}
|
||||
}
|
||||
|
||||
// Map canonical route key → domains from route config
|
||||
const routeDomains = new Map<string, string[]>();
|
||||
if (this.dcRouter.smartProxy) {
|
||||
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
||||
const routeKey = route.name || route.id;
|
||||
if (!routeKey || !route.match.domains) continue;
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
if (domains.length > 0) {
|
||||
routeDomains.set(routeKey, domains);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve wildcards using domains seen in request metrics
|
||||
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
|
||||
for (const entry of protocolCache) {
|
||||
if (entry.domain) allKnownDomains.add(entry.domain);
|
||||
}
|
||||
|
||||
// Build reverse map: concrete domain → canonical route key(s)
|
||||
const domainToRoutes = new Map<string, string[]>();
|
||||
for (const [routeKey, domains] of routeDomains) {
|
||||
for (const pattern of domains) {
|
||||
if (pattern.includes('*')) {
|
||||
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
||||
for (const knownDomain of allKnownDomains) {
|
||||
if (regex.test(knownDomain)) {
|
||||
const existing = domainToRoutes.get(knownDomain);
|
||||
if (existing) { existing.push(routeKey); }
|
||||
else { domainToRoutes.set(knownDomain, [routeKey]); }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const existing = domainToRoutes.get(pattern);
|
||||
if (existing) { existing.push(routeKey); }
|
||||
else { domainToRoutes.set(pattern, [routeKey]); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each route, compute the total request count across all its resolved domains
|
||||
// so we can distribute throughput/connections proportionally
|
||||
const routeTotalRequests = new Map<string, number>();
|
||||
for (const [domain, routeKeys] of domainToRoutes) {
|
||||
const reqs = domainRequestTotals.get(domain) || 0;
|
||||
for (const routeKey of routeKeys) {
|
||||
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate metrics per domain using request-count-proportional splitting
|
||||
const domainAgg = new Map<string, {
|
||||
activeConnections: number;
|
||||
bytesInPerSec: number;
|
||||
bytesOutPerSec: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
}>();
|
||||
|
||||
for (const [domain, routeKeys] of domainToRoutes) {
|
||||
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||
let totalConns = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
|
||||
for (const routeKey of routeKeys) {
|
||||
const conns = connectionsByRoute.get(routeKey) || 0;
|
||||
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
|
||||
const routeTotal = routeTotalRequests.get(routeKey) || 0;
|
||||
|
||||
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
||||
totalConns += conns * share;
|
||||
totalIn += tp.in * share;
|
||||
totalOut += tp.out * share;
|
||||
}
|
||||
|
||||
domainAgg.set(domain, {
|
||||
activeConnections: Math.round(totalConns),
|
||||
bytesInPerSec: totalIn,
|
||||
bytesOutPerSec: totalOut,
|
||||
routeCount: routeKeys.length,
|
||||
requestCount: domainReqs,
|
||||
});
|
||||
}
|
||||
|
||||
const domainActivity = Array.from(domainAgg.entries())
|
||||
.map(([domain, data]) => ({
|
||||
domain,
|
||||
bytesInPerSecond: data.bytesInPerSec,
|
||||
bytesOutPerSecond: data.bytesOutPerSec,
|
||||
activeConnections: data.activeConnections,
|
||||
routeCount: data.routeCount,
|
||||
requestCount: data.requestCount,
|
||||
}))
|
||||
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
||||
|
||||
return {
|
||||
connectionsByIP,
|
||||
throughputRate,
|
||||
topIPs,
|
||||
topIPsByBandwidth,
|
||||
totalDataTransferred,
|
||||
throughputHistory,
|
||||
throughputByIP,
|
||||
requestsPerSecond,
|
||||
requestsTotal,
|
||||
backends,
|
||||
frontendProtocols,
|
||||
backendProtocols,
|
||||
domainActivity,
|
||||
};
|
||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||
}
|
||||
@@ -851,4 +991,4 @@ export class MetricsManager {
|
||||
|
||||
return { queries };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,15 @@ export class OpsServer {
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||
private vpnHandler!: handlers.VpnHandler;
|
||||
private securityProfileHandler!: handlers.SecurityProfileHandler;
|
||||
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;
|
||||
private emailDomainHandler!: handlers.EmailDomainHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -90,8 +97,15 @@ export class OpsServer {
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||
this.vpnHandler = new handlers.VpnHandler(this);
|
||||
this.securityProfileHandler = new handlers.SecurityProfileHandler(this);
|
||||
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
||||
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
||||
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||
this.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);
|
||||
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD handler for the singleton `AcmeConfigDoc`.
|
||||
*
|
||||
* Auth: same dual-mode pattern as other handlers — admin JWT or API token
|
||||
* with `acme-config:read` / `acme-config:write` scope.
|
||||
*/
|
||||
export class AcmeConfigHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get current ACME config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAcmeConfig>(
|
||||
'getAcmeConfig',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'acme-config:read');
|
||||
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
||||
if (!mgr) return { config: null };
|
||||
return { config: mgr.getConfig() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update (upsert) the ACME config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAcmeConfig>(
|
||||
'updateAcmeConfig',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'acme-config:write');
|
||||
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
||||
if (!mgr) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'AcmeConfigManager not initialized (DB disabled?)',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const updated = await mgr.updateConfig(
|
||||
{
|
||||
accountEmail: dataArg.accountEmail,
|
||||
enabled: dataArg.enabled,
|
||||
useProduction: dataArg.useProduction,
|
||||
autoRenew: dataArg.autoRenew,
|
||||
renewThresholdDays: dataArg.renewThresholdDays,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
return { success: true, config: updated };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,18 @@ export class AdminHandler {
|
||||
role: 'admin',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Admin Login Handler
|
||||
|
||||
@@ -2,6 +2,28 @@ import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.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 {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -43,7 +65,7 @@ export class CertificateHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
'reprovisionCertificateDomain',
|
||||
async (dataArg) => {
|
||||
return this.reprovisionCertificateDomain(dataArg.domain);
|
||||
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -176,12 +198,11 @@ export class CertificateHandler {
|
||||
try {
|
||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||
if (rustStatus) {
|
||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
||||
status = rustStatus.status;
|
||||
if (rustStatus.expiresAt > 0) {
|
||||
expiryDate = new Date(rustStatus.expiresAt).toISOString();
|
||||
}
|
||||
if (rustStatus.source) issuer = rustStatus.source;
|
||||
status = rustStatus.isValid ? 'valid' : 'expired';
|
||||
}
|
||||
} catch {
|
||||
// Rust bridge may not support this command yet — ignore
|
||||
@@ -191,7 +212,11 @@ export class CertificateHandler {
|
||||
// Check persisted cert data from smartdata document classes
|
||||
if (status === 'unknown') {
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
// SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
|
||||
const parts = cleanDomain.split('.');
|
||||
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
|
||||
|
||||
if (acmeDoc?.validUntil) {
|
||||
@@ -291,7 +316,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 }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
@@ -301,13 +331,19 @@ export class CertificateHandler {
|
||||
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 {
|
||||
await smartProxy.provisionCertificate(routeName);
|
||||
// Clear event-based status for domains in this route
|
||||
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
||||
if (entry.routeNames.includes(routeName)) {
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
}
|
||||
if (dcRouter.routeConfigManager) {
|
||||
await dcRouter.routeConfigManager.applyRoutes();
|
||||
} else {
|
||||
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
|
||||
}
|
||||
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||
} catch (err: unknown) {
|
||||
@@ -316,9 +352,18 @@ export class CertificateHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
||||
* Domain-based reprovisioning — clears backoff first, refreshes the smartacme
|
||||
* cert (when forceRenew is set), then re-applies routes so the running Rust
|
||||
* proxy actually picks up the new cert.
|
||||
*
|
||||
* Why applyRoutes (not smartProxy.provisionCertificate)?
|
||||
* smartProxy.provisionCertificate(routeName) routes through the Rust ACME
|
||||
* path, which is forcibly disabled whenever certProvisionFunction is set
|
||||
* (smart-proxy.ts:168-171). The only path that re-invokes
|
||||
* certProvisionFunction → bridge.loadCertificate is updateRoutes(), which
|
||||
* we trigger via routeConfigManager.applyRoutes().
|
||||
*/
|
||||
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||
private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const smartProxy = dcRouter.smartProxy;
|
||||
|
||||
@@ -331,31 +376,143 @@ export class CertificateHandler {
|
||||
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);
|
||||
|
||||
// Try to provision via SmartAcme directly
|
||||
if (dcRouter.smartAcme) {
|
||||
try {
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
// Trigger the full route apply pipeline:
|
||||
// applyRoutes → updateRoutes → provisionCertificatesViaCallback →
|
||||
// certProvisionFunction(domain) → smartAcme.getCertificateForDomain →
|
||||
// bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
|
||||
// certificate-issued event → certificateStatusMap updated
|
||||
try {
|
||||
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
|
||||
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
||||
if (routeNames.length > 0) {
|
||||
if (affected.size === 0) return;
|
||||
|
||||
// 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 {
|
||||
await smartProxy.provisionCertificate(routeNames[0]);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
|
||||
validUntil = new Date(x509.validTo).getTime();
|
||||
validFrom = new Date(x509.validFrom).getTime();
|
||||
} catch { /* fall back to smartacme's value */ }
|
||||
}
|
||||
|
||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||
// Persist new cert PEM under each affected route domain
|
||||
for (const routeDomain of affected) {
|
||||
let doc = await ProxyCertDoc.findByDomain(routeDomain);
|
||||
if (!doc) {
|
||||
doc = new ProxyCertDoc();
|
||||
doc.domain = routeDomain;
|
||||
}
|
||||
doc.publicKey = newCert.publicKey;
|
||||
doc.privateKey = newCert.privateKey;
|
||||
doc.ca = '';
|
||||
doc.validUntil = validUntil || 0;
|
||||
doc.validFrom = validFrom || 0;
|
||||
await doc.save();
|
||||
|
||||
// Clear status so the next event refresh shows the new cert
|
||||
dcRouter.certificateStatusMap.delete(routeDomain);
|
||||
}
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`Propagated force-renewed cert for ${forcedDomain} (cert identity '${certIdentity}') to ${affected.size} sibling route domain(s): ${[...affected].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,9 +521,12 @@ export class CertificateHandler {
|
||||
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
const parts = cleanDomain.split('.');
|
||||
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
|
||||
|
||||
// Delete from smartdata document classes
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
// Delete from smartdata document classes (try base domain first, then exact)
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||
if (acmeDoc) {
|
||||
await acmeDoc.delete();
|
||||
}
|
||||
|
||||
@@ -123,6 +123,15 @@ export class ConfigHandler {
|
||||
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?.hasAnyManagedDomain()) ?? false;
|
||||
} catch {
|
||||
dnsChallengeEnabled = false;
|
||||
}
|
||||
|
||||
const dns: interfaces.requests.IConfigData['dns'] = {
|
||||
enabled: !!dcRouter.dnsServer,
|
||||
port: 53,
|
||||
@@ -130,7 +139,7 @@ export class ConfigHandler {
|
||||
scopes: opts.dnsScopes || [],
|
||||
recordCount: dnsRecords.length,
|
||||
records: dnsRecords,
|
||||
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
|
||||
dnsChallenge: dnsChallengeEnabled,
|
||||
};
|
||||
|
||||
// --- TLS ---
|
||||
|
||||
197
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
197
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD + connection-test handlers for DnsProviderDoc.
|
||||
*
|
||||
* Auth: same dual-mode pattern as TargetProfileHandler — admin JWT or
|
||||
* API token with the appropriate `dns-providers:read|write` scope.
|
||||
*/
|
||||
export class DnsProviderHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all providers — prepends the built-in DcRouter pseudo-provider
|
||||
// so operators see a uniform "who serves this?" list that includes the
|
||||
// authoritative dcrouter alongside external accounts.
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProviders>(
|
||||
'getDnsProviders',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
const synthetic: interfaces.data.IDnsProviderPublic = {
|
||||
id: interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID,
|
||||
name: 'DcRouter',
|
||||
type: 'dcrouter',
|
||||
status: 'ok',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
createdBy: 'system',
|
||||
hasCredentials: false,
|
||||
builtIn: true,
|
||||
};
|
||||
const real = dnsManager ? await dnsManager.listProviders() : [];
|
||||
return { providers: [synthetic, ...real] };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProvider>(
|
||||
'getDnsProvider',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { provider: null };
|
||||
return { provider: await dnsManager.getProvider(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsProvider>(
|
||||
'createDnsProvider',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'dns-providers:write');
|
||||
if (dataArg.type === 'dcrouter') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'cannot create built-in provider',
|
||||
};
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) {
|
||||
return { success: false, message: 'DnsManager not initialized (DB disabled?)' };
|
||||
}
|
||||
const id = await dnsManager.createProvider({
|
||||
name: dataArg.name,
|
||||
type: dataArg.type,
|
||||
credentials: dataArg.credentials,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, id };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsProvider>(
|
||||
'updateDnsProvider',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:write');
|
||||
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||
return { success: false, message: 'cannot edit built-in provider' };
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
const ok = await dnsManager.updateProvider(dataArg.id, {
|
||||
name: dataArg.name,
|
||||
credentials: dataArg.credentials,
|
||||
});
|
||||
return ok ? { success: true } : { success: false, message: 'Provider not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsProvider>(
|
||||
'deleteDnsProvider',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:write');
|
||||
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||
return { success: false, message: 'cannot delete built-in provider' };
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Test provider connection
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestDnsProvider>(
|
||||
'testDnsProvider',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'built-in provider has no external connection to test',
|
||||
testedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) {
|
||||
return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() };
|
||||
}
|
||||
return await dnsManager.testProvider(dataArg.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// List domains visible to a provider's account (without importing them)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListProviderDomains>(
|
||||
'listProviderDomains',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||
if (dataArg.providerId === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'built-in provider has no external domain listing — use "Add DcRouter Domain" instead',
|
||||
};
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
try {
|
||||
const domains = await dnsManager.listProviderDomains(dataArg.providerId);
|
||||
return { success: true, domains };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD handlers for DnsRecordDoc.
|
||||
*/
|
||||
export class DnsRecordHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get records by domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
|
||||
'getDnsRecords',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-records:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { records: [] };
|
||||
const docs = await dnsManager.listRecordsForDomain(dataArg.domainId);
|
||||
return { records: docs.map((d) => dnsManager.toPublicRecord(d)) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single record
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecord>(
|
||||
'getDnsRecord',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-records:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { record: null };
|
||||
const doc = await dnsManager.getRecord(dataArg.id);
|
||||
return { record: doc ? dnsManager.toPublicRecord(doc) : null };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create record
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
|
||||
'createDnsRecord',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'dns-records:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.createRecord({
|
||||
domainId: dataArg.domainId,
|
||||
name: dataArg.name,
|
||||
type: dataArg.type,
|
||||
value: dataArg.value,
|
||||
ttl: dataArg.ttl,
|
||||
proxied: dataArg.proxied,
|
||||
createdBy: userId,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update record
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
|
||||
'updateDnsRecord',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-records:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.updateRecord({
|
||||
id: dataArg.id,
|
||||
name: dataArg.name,
|
||||
value: dataArg.value,
|
||||
ttl: dataArg.ttl,
|
||||
proxied: dataArg.proxied,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete record
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
|
||||
'deleteDnsRecord',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-records:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.deleteRecord(dataArg.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
ts/opsserver/handlers/domain.handler.ts
Normal file
179
ts/opsserver/handlers/domain.handler.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD handlers for DomainDoc.
|
||||
*/
|
||||
export class DomainHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all domains
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
|
||||
'getDomains',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { domains: [] };
|
||||
const docs = await dnsManager.listDomains();
|
||||
return { domains: docs.map((d) => dnsManager.toPublicDomain(d)) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
|
||||
'getDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { domain: null };
|
||||
const doc = await dnsManager.getDomain(dataArg.id);
|
||||
return { domain: doc ? dnsManager.toPublicDomain(doc) : null };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create dcrouter-hosted domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDomain>(
|
||||
'createDomain',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
try {
|
||||
const id = await dnsManager.createDcrouterDomain({
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, id };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Import domains from a provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportDomain>(
|
||||
'importDomain',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
try {
|
||||
const importedIds = await dnsManager.importDomainsFromProvider({
|
||||
providerId: dataArg.providerId,
|
||||
domainNames: dataArg.domainNames,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, importedIds };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update domain metadata
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDomain>(
|
||||
'updateDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
const ok = await dnsManager.updateDomain(dataArg.id, {
|
||||
description: dataArg.description,
|
||||
});
|
||||
return ok ? { success: true } : { success: false, message: 'Domain not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDomain>(
|
||||
'deleteDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
const ok = await dnsManager.deleteDomain(dataArg.id);
|
||||
return ok ? { success: true } : { success: false, message: 'Domain not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Force-resync provider domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomain>(
|
||||
'syncDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.syncDomain(dataArg.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Migrate domain between dcrouter-hosted and provider-managed
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MigrateDomain>(
|
||||
'migrateDomain',
|
||||
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.migrateDomain({
|
||||
id: dataArg.id,
|
||||
targetSource: dataArg.targetSource,
|
||||
targetProviderId: dataArg.targetProviderId,
|
||||
deleteExistingProviderRecords: dataArg.deleteExistingProviderRecords,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
195
ts/opsserver/handlers/email-domain.handler.ts
Normal file
195
ts/opsserver/handlers/email-domain.handler.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD + DNS provisioning handler for email domains.
|
||||
*
|
||||
* Auth: admin JWT or API token with `email-domains:read` / `email-domains:write` scope.
|
||||
*/
|
||||
export class EmailDomainHandler {
|
||||
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 get manager() {
|
||||
return this.opsServerRef.dcRouterRef.emailDomainManager;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// List all email domains
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomains>(
|
||||
'getEmailDomains',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { domains: [] };
|
||||
return { domains: await this.manager.getAll() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomain>(
|
||||
'getEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { domain: null };
|
||||
return { domain: await this.manager.getById(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateEmailDomain>(
|
||||
'createEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const domain = await this.manager.createEmailDomain({
|
||||
linkedDomainId: dataArg.linkedDomainId,
|
||||
subdomain: dataArg.subdomain,
|
||||
dkimSelector: dataArg.dkimSelector,
|
||||
dkimKeySize: dataArg.dkimKeySize,
|
||||
rotateKeys: dataArg.rotateKeys,
|
||||
rotationIntervalDays: dataArg.rotationIntervalDays,
|
||||
});
|
||||
return { success: true, domain };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailDomain>(
|
||||
'updateEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
await this.manager.updateEmailDomain(dataArg.id, {
|
||||
rotateKeys: dataArg.rotateKeys,
|
||||
rotationIntervalDays: dataArg.rotationIntervalDays,
|
||||
rateLimits: dataArg.rateLimits,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete email domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteEmailDomain>(
|
||||
'deleteEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
await this.manager.deleteEmailDomain(dataArg.id);
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Validate DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ValidateEmailDomain>(
|
||||
'validateEmailDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const records = await this.manager.validateDns(dataArg.id);
|
||||
const domain = await this.manager.getById(dataArg.id);
|
||||
return { success: true, domain: domain ?? undefined, records };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get required DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomainDnsRecords>(
|
||||
'getEmailDomainDnsRecords',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||
if (!this.manager) return { records: [] };
|
||||
return { records: await this.manager.getRequiredDnsRecords(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Auto-provision DNS records
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ProvisionEmailDomainDns>(
|
||||
'provisionEmailDomainDns',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||
if (!this.manager) {
|
||||
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const provisioned = await this.manager.provisionDnsRecords(dataArg.id);
|
||||
return { success: true, provisioned };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(dataArg.emailId);
|
||||
const item = emailServer.getQueueItem(dataArg.emailId);
|
||||
|
||||
if (!item) {
|
||||
return { success: false, error: 'Email not found in queue' };
|
||||
@@ -82,22 +82,10 @@ export class EmailOpsHandler {
|
||||
*/
|
||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
if (!emailServer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const queueMap = (queue as any).queue as Map<string, any>;
|
||||
|
||||
if (!queueMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
for (const [id, item] of queueMap.entries()) {
|
||||
emails.push(this.mapQueueItemToEmail(item));
|
||||
}
|
||||
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
@@ -110,12 +98,10 @@ export class EmailOpsHandler {
|
||||
*/
|
||||
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
if (!emailServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(emailId);
|
||||
const item = emailServer.getQueueItem(emailId);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
|
||||
@@ -10,5 +10,12 @@ export * from './remoteingress.handler.js';
|
||||
export * from './route-management.handler.js';
|
||||
export * from './api-token.handler.js';
|
||||
export * from './vpn.handler.js';
|
||||
export * from './security-profile.handler.js';
|
||||
export * from './network-target.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';
|
||||
export * from './email-domain.handler.js';
|
||||
@@ -255,7 +255,7 @@ export class LogsHandler {
|
||||
} {
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
let stopped = false;
|
||||
let logIndex = 0;
|
||||
let lastTimestamp = Date.now();
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
@@ -284,53 +284,65 @@ export class LogsHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// For follow mode, simulate real-time log streaming
|
||||
// For follow mode, tail real log entries from the in-memory buffer
|
||||
intervalId = setInterval(async () => {
|
||||
if (stopped) {
|
||||
// Guard: clear interval if stop() was called between ticks
|
||||
clearInterval(intervalId!);
|
||||
intervalId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
// Fetch new entries since last poll
|
||||
const rawEntries = logBuffer.getEntries({
|
||||
since: lastTimestamp,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||
if (rawEntries.length === 0) return;
|
||||
|
||||
// Filter by requested criteria
|
||||
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
||||
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
||||
for (const raw of rawEntries) {
|
||||
const mappedLevel = LogsHandler.mapLogLevel(raw.level);
|
||||
const mappedCategory = LogsHandler.deriveCategory(
|
||||
(raw as any).context?.zone,
|
||||
raw.message,
|
||||
);
|
||||
|
||||
const logEntry = {
|
||||
timestamp: Date.now(),
|
||||
level: mockLevel,
|
||||
category: mockCategory,
|
||||
message: `Real-time log ${logIndex++} from ${mockCategory}`,
|
||||
metadata: {
|
||||
requestId: plugins.uuid.v4(),
|
||||
},
|
||||
};
|
||||
// Apply filters
|
||||
if (levelFilter && !levelFilter.includes(mappedLevel)) continue;
|
||||
if (categoryFilter && !categoryFilter.includes(mappedCategory)) continue;
|
||||
|
||||
const logData = JSON.stringify(logEntry);
|
||||
const encoder = new TextEncoder();
|
||||
try {
|
||||
// Use a timeout to detect hung streams (sendData can hang if the
|
||||
// VirtualStream's keepAlive loop has ended)
|
||||
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
await Promise.race([
|
||||
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
return result;
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// Stream closed, errored, or timed out — clean up
|
||||
stop();
|
||||
const logEntry = {
|
||||
timestamp: raw.timestamp || Date.now(),
|
||||
level: mappedLevel,
|
||||
category: mappedCategory,
|
||||
message: raw.message,
|
||||
metadata: (raw as any).data,
|
||||
};
|
||||
|
||||
const logData = JSON.stringify(logEntry);
|
||||
const encoder = new TextEncoder();
|
||||
try {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
await Promise.race([
|
||||
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
return result;
|
||||
}),
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -135,7 +135,7 @@ export class NetworkTargetHandler {
|
||||
const result = await resolver.deleteTarget(
|
||||
dataArg.id,
|
||||
dataArg.force ?? false,
|
||||
manager.getStoredRoutes(),
|
||||
manager.getRoutes(),
|
||||
);
|
||||
|
||||
if (result.success && dataArg.force) {
|
||||
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
|
||||
if (!resolver || !manager) {
|
||||
return { routes: [] };
|
||||
}
|
||||
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getRoutes());
|
||||
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -72,7 +72,7 @@ export class RouteManagementHandler {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
|
||||
return { success: true, storedRouteId: id };
|
||||
return { success: true, routeId: id };
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -113,39 +113,7 @@ export class RouteManagementHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Set override on a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Remove override from a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.removeOverride(dataArg.routeName);
|
||||
return { success: ok, message: ok ? undefined : 'Override not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Toggle programmatic route
|
||||
// Toggle route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
|
||||
@@ -51,8 +51,8 @@ export class SecurityHandler {
|
||||
startTime: conn.startTime,
|
||||
protocol: conn.type === 'http' ? 'https' : conn.type as any,
|
||||
state: conn.status as any,
|
||||
bytesReceived: Math.floor(conn.bytesTransferred / 2),
|
||||
bytesSent: Math.floor(conn.bytesTransferred / 2),
|
||||
bytesReceived: (conn as any)._throughputIn || 0,
|
||||
bytesSent: (conn as any)._throughputOut || 0,
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
@@ -96,9 +96,11 @@ export class SecurityHandler {
|
||||
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
||||
throughputRate: networkStats.throughputRate,
|
||||
topIPs: networkStats.topIPs,
|
||||
topIPsByBandwidth: networkStats.topIPsByBandwidth,
|
||||
totalDataTransferred: networkStats.totalDataTransferred,
|
||||
throughputHistory: networkStats.throughputHistory || [],
|
||||
throughputByIP,
|
||||
domainActivity: networkStats.domainActivity || [],
|
||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||
requestsTotal: networkStats.requestsTotal || 0,
|
||||
backends: networkStats.backends || [],
|
||||
@@ -110,9 +112,11 @@ export class SecurityHandler {
|
||||
connectionsByIP: [],
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
topIPsByBandwidth: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [],
|
||||
throughputByIP: [],
|
||||
domainActivity: [],
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [],
|
||||
@@ -251,31 +255,31 @@ export class SecurityHandler {
|
||||
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
|
||||
// Use IP-based connection data from the new metrics API
|
||||
// One aggregate row per IP with real throughput data
|
||||
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
|
||||
let connIndex = 0;
|
||||
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
|
||||
|
||||
|
||||
for (const [ip, count] of networkStats.connectionsByIP) {
|
||||
// Create a connection entry for each active IP connection
|
||||
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
|
||||
connections.push({
|
||||
id: `conn-${connIndex++}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: ip,
|
||||
port: Math.floor(Math.random() * 50000) + 10000, // High port range
|
||||
},
|
||||
destination: {
|
||||
ip: publicIp,
|
||||
port: 443,
|
||||
service: 'proxy',
|
||||
},
|
||||
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
|
||||
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
const tp = networkStats.throughputByIP?.get(ip);
|
||||
connections.push({
|
||||
id: `ip-${connIndex++}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: ip,
|
||||
port: 0,
|
||||
},
|
||||
destination: {
|
||||
ip: publicIp,
|
||||
port: 443,
|
||||
service: 'proxy',
|
||||
},
|
||||
startTime: 0,
|
||||
bytesTransferred: count, // Store connection count here
|
||||
status: 'active',
|
||||
// Attach real throughput for the handler mapping
|
||||
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
|
||||
} as any);
|
||||
}
|
||||
} else if (connectionInfo.length > 0) {
|
||||
// Fallback to route-based connection info if no IP data available
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class SecurityProfileHandler {
|
||||
export class SourceProfileHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -40,12 +40,12 @@ export class SecurityProfileHandler {
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all security profiles
|
||||
// Get all source profiles
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||
'getSecurityProfiles',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfiles>(
|
||||
'getSourceProfiles',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:read');
|
||||
await this.requireAuth(dataArg, 'source-profiles:read');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
if (!resolver) {
|
||||
return { profiles: [] };
|
||||
@@ -55,12 +55,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Get a single security profile
|
||||
// Get a single source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfile>(
|
||||
'getSecurityProfile',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfile>(
|
||||
'getSourceProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:read');
|
||||
await this.requireAuth(dataArg, 'source-profiles:read');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
if (!resolver) {
|
||||
return { profile: null };
|
||||
@@ -70,12 +70,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Create a security profile
|
||||
// Create a source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||
'createSecurityProfile',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSourceProfile>(
|
||||
'createSourceProfile',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'profiles:write');
|
||||
const userId = await this.requireAuth(dataArg, 'source-profiles:write');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
if (!resolver) {
|
||||
return { success: false, message: 'Reference resolver not initialized' };
|
||||
@@ -92,12 +92,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Update a security profile
|
||||
// Update a source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityProfile>(
|
||||
'updateSecurityProfile',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSourceProfile>(
|
||||
'updateSourceProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:write');
|
||||
await this.requireAuth(dataArg, 'source-profiles:write');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!resolver || !manager) {
|
||||
@@ -121,12 +121,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Delete a security profile
|
||||
// Delete a source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityProfile>(
|
||||
'deleteSecurityProfile',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSourceProfile>(
|
||||
'deleteSourceProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:write');
|
||||
await this.requireAuth(dataArg, 'source-profiles:write');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!resolver || !manager) {
|
||||
@@ -136,7 +136,7 @@ export class SecurityProfileHandler {
|
||||
const result = await resolver.deleteProfile(
|
||||
dataArg.id,
|
||||
dataArg.force ?? false,
|
||||
manager.getStoredRoutes(),
|
||||
manager.getRoutes(),
|
||||
);
|
||||
|
||||
// If force-deleted with affected routes, re-apply
|
||||
@@ -149,18 +149,18 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Get routes using a security profile
|
||||
// Get routes using a source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||
'getSecurityProfileUsage',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfileUsage>(
|
||||
'getSourceProfileUsage',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:read');
|
||||
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());
|
||||
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getRoutes());
|
||||
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 { MetricsManager } from '../../monitoring/index.js';
|
||||
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||
import { commitinfo } from '../../00_commitinfo_data.js';
|
||||
|
||||
export class StatsHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -158,7 +159,7 @@ export class StatsHandler {
|
||||
};
|
||||
return acc;
|
||||
}, {} as any),
|
||||
version: '2.12.0', // TODO: Get from package.json
|
||||
version: commitinfo.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -290,6 +291,20 @@ export class StatsHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Build connectionDetails from real per-IP data
|
||||
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
|
||||
for (const [ip, count] of stats.connectionsByIP) {
|
||||
const tp = stats.throughputByIP?.get(ip);
|
||||
connectionDetails.push({
|
||||
remoteAddress: ip,
|
||||
protocol: 'https',
|
||||
state: 'connected',
|
||||
startTime: 0,
|
||||
bytesIn: tp?.in || 0,
|
||||
bytesOut: tp?.out || 0,
|
||||
});
|
||||
}
|
||||
|
||||
metrics.network = {
|
||||
totalBandwidth: {
|
||||
in: stats.throughputRate.bytesInPerSecond,
|
||||
@@ -300,21 +315,69 @@ export class StatsHandler {
|
||||
out: stats.totalDataTransferred.bytesOut,
|
||||
},
|
||||
activeConnections: serverStats.activeConnections,
|
||||
connectionDetails: [],
|
||||
connectionDetails,
|
||||
topEndpoints: stats.topIPs.map(ip => ({
|
||||
endpoint: ip.ip,
|
||||
requests: ip.count,
|
||||
connections: ip.count,
|
||||
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
|
||||
})),
|
||||
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
|
||||
endpoint: ip.ip,
|
||||
connections: ip.count,
|
||||
bandwidth: { in: ip.bwIn, out: ip.bwOut },
|
||||
})),
|
||||
domainActivity: stats.domainActivity || [],
|
||||
throughputHistory: stats.throughputHistory || [],
|
||||
requestsPerSecond: stats.requestsPerSecond || 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);
|
||||
|
||||
return {
|
||||
@@ -467,13 +530,49 @@ export class StatsHandler {
|
||||
nextRetry?: number;
|
||||
}>;
|
||||
}> {
|
||||
// TODO: Implement actual queue status collection
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer) {
|
||||
return {
|
||||
pending: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const queueStats = emailServer.getQueueStats();
|
||||
const items = emailServer.getQueueItems()
|
||||
.sort((a, b) => {
|
||||
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
|
||||
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
|
||||
return right - left;
|
||||
})
|
||||
.slice(0, 50)
|
||||
.map((item) => {
|
||||
const emailLike = item.processingResult;
|
||||
const recipients = Array.isArray(emailLike?.to)
|
||||
? emailLike.to
|
||||
: Array.isArray(emailLike?.email?.to)
|
||||
? emailLike.email.to
|
||||
: [];
|
||||
const subject = emailLike?.subject || emailLike?.email?.subject || '';
|
||||
return {
|
||||
id: item.id,
|
||||
recipient: recipients[0] || '',
|
||||
subject,
|
||||
status: item.status,
|
||||
attempts: item.attempts,
|
||||
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
pending: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
items: [],
|
||||
pending: queueStats.status.pending,
|
||||
active: queueStats.status.processing,
|
||||
failed: queueStats.status.failed,
|
||||
retrying: queueStats.status.deferred,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -537,4 +636,4 @@ export class StatsHandler {
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
157
ts/opsserver/handlers/target-profile.handler.ts
Normal file
157
ts/opsserver/handlers/target-profile.handler.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class TargetProfileHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all target profiles
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfiles>(
|
||||
'getTargetProfiles',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { profiles: [] };
|
||||
}
|
||||
return { profiles: manager.listProfiles() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get a single target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfile>(
|
||||
'getTargetProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { profile: null };
|
||||
}
|
||||
return { profile: manager.getProfile(dataArg.id) || null };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create a target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateTargetProfile>(
|
||||
'createTargetProfile',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'target-profiles:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Target profile manager not initialized' };
|
||||
}
|
||||
const id = await manager.createProfile({
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, id };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update a target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateTargetProfile>(
|
||||
'updateTargetProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Not initialized' };
|
||||
}
|
||||
await manager.updateProfile(dataArg.id, {
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
});
|
||||
// Re-apply routes and refresh VPN client security to update access
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete a target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteTargetProfile>(
|
||||
'deleteTargetProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Not initialized' };
|
||||
}
|
||||
const result = await manager.deleteProfile(dataArg.id, dataArg.force);
|
||||
if (result.success) {
|
||||
// Re-apply routes and refresh VPN client security to update access
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get VPN clients using a target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfileUsage>(
|
||||
'getTargetProfileUsage',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { clients: [] };
|
||||
}
|
||||
return { clients: await manager.getProfileUsage(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
ts/opsserver/handlers/users.handler.ts
Normal file
30
ts/opsserver/handlers/users.handler.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
|
||||
* so admin middleware enforces auth + role check before the handler runs.
|
||||
* User data is owned by AdminHandler; this handler just exposes a safe
|
||||
* projection of it via TypedRequest.
|
||||
*/
|
||||
export class UsersHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
const router = this.opsServerRef.adminRouter;
|
||||
|
||||
// List users (admin-only, read-only)
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||
'listUsers',
|
||||
async (_dataArg) => {
|
||||
const users = this.opsServerRef.adminHandler.listUsers();
|
||||
return { users };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,12 @@ export class VpnHandler {
|
||||
const clients = manager.listClients().map((c) => ({
|
||||
clientId: c.clientId,
|
||||
enabled: c.enabled,
|
||||
serverDefinedClientTags: c.serverDefinedClientTags,
|
||||
targetProfileIds: c.targetProfileIds,
|
||||
description: c.description,
|
||||
assignedIp: c.assignedIp,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
expiresAt: c.expiresAt,
|
||||
forceDestinationSmartproxy: c.forceDestinationSmartproxy ?? true,
|
||||
destinationAllowList: c.destinationAllowList,
|
||||
destinationBlockList: c.destinationBlockList,
|
||||
useHostIp: c.useHostIp,
|
||||
@@ -120,9 +119,8 @@ export class VpnHandler {
|
||||
try {
|
||||
const bundle = await manager.createClient({
|
||||
clientId: dataArg.clientId,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
description: dataArg.description,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
useHostIp: dataArg.useHostIp,
|
||||
@@ -142,13 +140,12 @@ export class VpnHandler {
|
||||
client: {
|
||||
clientId: bundle.entry.clientId,
|
||||
enabled: bundle.entry.enabled ?? true,
|
||||
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||
targetProfileIds: persistedClient?.targetProfileIds,
|
||||
description: bundle.entry.description,
|
||||
assignedIp: bundle.entry.assignedIp,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: bundle.entry.expiresAt,
|
||||
forceDestinationSmartproxy: persistedClient?.forceDestinationSmartproxy ?? true,
|
||||
destinationAllowList: persistedClient?.destinationAllowList,
|
||||
destinationBlockList: persistedClient?.destinationBlockList,
|
||||
useHostIp: persistedClient?.useHostIp,
|
||||
@@ -179,8 +176,7 @@ export class VpnHandler {
|
||||
try {
|
||||
await manager.updateClient(dataArg.clientId, {
|
||||
description: dataArg.description,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
useHostIp: dataArg.useHostIp,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"order": 2
|
||||
"order": 3
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface IVpnManagerConfig {
|
||||
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
||||
initialClients?: Array<{
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
}>;
|
||||
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
||||
@@ -26,10 +26,13 @@ export interface IVpnManagerConfig {
|
||||
allowList?: string[];
|
||||
blockList?: string[];
|
||||
};
|
||||
/** Compute per-client AllowedIPs based on the client's server-defined tags.
|
||||
/** Compute per-client AllowedIPs based on the client's target profile IDs.
|
||||
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||
* When not set, defaults to [subnet]. */
|
||||
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
|
||||
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
|
||||
/** Resolve per-client destination allow-list IPs from target profile IDs.
|
||||
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
|
||||
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
|
||||
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
|
||||
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
|
||||
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
@@ -52,6 +55,8 @@ export class VpnManager {
|
||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||
private clients: Map<string, VpnClientDoc> = new Map();
|
||||
private serverKeys?: VpnServerKeysDoc;
|
||||
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||
|
||||
constructor(config: IVpnManagerConfig) {
|
||||
this.config = config;
|
||||
@@ -85,23 +90,22 @@ export class VpnManager {
|
||||
if (client.useHostIp) {
|
||||
anyClientUsesHostIp = true;
|
||||
}
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
const entry: plugins.smartvpn.IClientEntry = {
|
||||
clientId: client.clientId,
|
||||
publicKey: client.noisePublicKey,
|
||||
wgPublicKey: client.wgPublicKey,
|
||||
enabled: client.enabled,
|
||||
serverDefinedClientTags: client.serverDefinedClientTags,
|
||||
description: client.description,
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
security: this.buildClientSecurity(client),
|
||||
useHostIp: client.useHostIp,
|
||||
useDhcp: client.useDhcp,
|
||||
staticIp: client.staticIp,
|
||||
forceVlan: client.forceVlan,
|
||||
vlanId: client.vlanId,
|
||||
};
|
||||
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
||||
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
||||
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
||||
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
||||
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
||||
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
||||
clientEntries.push(entry);
|
||||
}
|
||||
|
||||
@@ -110,13 +114,15 @@ export class VpnManager {
|
||||
|
||||
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||
let configuredMode = this.config.forwardingMode ?? 'socket';
|
||||
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||
configuredMode = 'hybrid';
|
||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||
}
|
||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||
const isBridge = forwardingMode === 'bridge';
|
||||
this.resolvedForwardingMode = forwardingMode;
|
||||
this.forwardingModeOverride = undefined;
|
||||
|
||||
// Create and start VpnServer
|
||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||
@@ -141,7 +147,7 @@ export class VpnManager {
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
@@ -163,7 +169,7 @@ export class VpnManager {
|
||||
if (!this.clients.has(initial.clientId)) {
|
||||
const bundle = await this.createClient({
|
||||
clientId: initial.clientId,
|
||||
serverDefinedClientTags: initial.serverDefinedClientTags,
|
||||
targetProfileIds: initial.targetProfileIds,
|
||||
description: initial.description,
|
||||
});
|
||||
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
|
||||
@@ -187,6 +193,7 @@ export class VpnManager {
|
||||
this.vpnServer.stop();
|
||||
this.vpnServer = undefined;
|
||||
}
|
||||
this.resolvedForwardingMode = undefined;
|
||||
logger.log('info', 'VPN server stopped');
|
||||
}
|
||||
|
||||
@@ -197,9 +204,8 @@ export class VpnManager {
|
||||
*/
|
||||
public async createClient(opts: {
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
@@ -212,15 +218,38 @@ export class VpnManager {
|
||||
throw new Error('VPN server not running');
|
||||
}
|
||||
|
||||
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
|
||||
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = opts.clientId;
|
||||
doc.enabled = true;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = opts.description;
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
doc.destinationBlockList = opts.destinationBlockList;
|
||||
doc.useHostIp = opts.useHostIp;
|
||||
doc.useDhcp = opts.useDhcp;
|
||||
doc.staticIp = opts.staticIp;
|
||||
doc.forceVlan = opts.forceVlan;
|
||||
doc.vlanId = opts.vlanId;
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
|
||||
const bundle = await this.vpnServer.createClient({
|
||||
clientId: opts.clientId,
|
||||
serverDefinedClientTags: opts.serverDefinedClientTags,
|
||||
description: opts.description,
|
||||
clientId: doc.clientId,
|
||||
description: doc.description,
|
||||
security: this.buildClientSecurity(doc),
|
||||
useHostIp: doc.useHostIp,
|
||||
useDhcp: doc.useDhcp,
|
||||
staticIp: doc.staticIp,
|
||||
forceVlan: doc.forceVlan,
|
||||
vlanId: doc.vlanId,
|
||||
});
|
||||
|
||||
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
@@ -228,50 +257,28 @@ export class VpnManager {
|
||||
}
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = bundle.entry.clientId;
|
||||
doc.enabled = bundle.entry.enabled ?? true;
|
||||
doc.serverDefinedClientTags = bundle.entry.serverDefinedClientTags;
|
||||
doc.description = bundle.entry.description;
|
||||
doc.assignedIp = bundle.entry.assignedIp;
|
||||
doc.noisePublicKey = bundle.entry.publicKey;
|
||||
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
doc.expiresAt = bundle.entry.expiresAt;
|
||||
if (opts.forceDestinationSmartproxy !== undefined) {
|
||||
doc.forceDestinationSmartproxy = opts.forceDestinationSmartproxy;
|
||||
}
|
||||
if (opts.destinationAllowList !== undefined) {
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
}
|
||||
if (opts.destinationBlockList !== undefined) {
|
||||
doc.destinationBlockList = opts.destinationBlockList;
|
||||
}
|
||||
if (opts.useHostIp !== undefined) {
|
||||
doc.useHostIp = opts.useHostIp;
|
||||
}
|
||||
if (opts.useDhcp !== undefined) {
|
||||
doc.useDhcp = opts.useDhcp;
|
||||
}
|
||||
if (opts.staticIp !== undefined) {
|
||||
doc.staticIp = opts.staticIp;
|
||||
}
|
||||
if (opts.forceVlan !== undefined) {
|
||||
doc.forceVlan = opts.forceVlan;
|
||||
}
|
||||
if (opts.vlanId !== undefined) {
|
||||
doc.vlanId = opts.vlanId;
|
||||
}
|
||||
this.clients.set(doc.clientId, doc);
|
||||
await this.persistClient(doc);
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
const security = this.buildClientSecurity(doc);
|
||||
if (security.destinationPolicy) {
|
||||
await this.vpnServer!.updateClient(doc.clientId, { security });
|
||||
try {
|
||||
await this.persistClient(doc);
|
||||
} catch (err) {
|
||||
// Rollback: remove from in-memory map and daemon to stay consistent with DB
|
||||
this.clients.delete(doc.clientId);
|
||||
try {
|
||||
await this.vpnServer!.removeClient(doc.clientId);
|
||||
} catch {
|
||||
// best-effort daemon cleanup
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
@@ -332,12 +339,11 @@ export class VpnManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a client's metadata (description, tags) without rotating keys.
|
||||
* Update a client's metadata (description, target profiles) without rotating keys.
|
||||
*/
|
||||
public async updateClient(clientId: string, update: {
|
||||
description?: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
targetProfileIds?: string[];
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
@@ -349,8 +355,7 @@ export class VpnManager {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) throw new Error(`Client not found: ${clientId}`);
|
||||
if (update.description !== undefined) client.description = update.description;
|
||||
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
|
||||
if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
|
||||
if (update.targetProfileIds !== undefined) client.targetProfileIds = update.targetProfileIds;
|
||||
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
|
||||
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
|
||||
if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
|
||||
@@ -358,13 +363,13 @@ export class VpnManager {
|
||||
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
if (this.vpnServer) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
await this.vpnServer.updateClient(clientId, { security });
|
||||
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
@@ -409,10 +414,10 @@ export class VpnManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs) {
|
||||
const clientTags = persisted?.serverDefinedClientTags || [];
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(clientTags);
|
||||
const profileIds = persisted?.targetProfileIds || [];
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
|
||||
config = config.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
@@ -423,22 +428,6 @@ export class VpnManager {
|
||||
return config;
|
||||
}
|
||||
|
||||
// ── Tag-based access control ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
|
||||
*/
|
||||
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
|
||||
const ips: string[] = [];
|
||||
for (const client of this.clients.values()) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
|
||||
ips.push(client.assignedIp);
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
// ── Status and telemetry ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -488,33 +477,44 @@ export class VpnManager {
|
||||
|
||||
/**
|
||||
* Build per-client security settings for the smartvpn daemon.
|
||||
* Maps dcrouter-level fields (forceDestinationSmartproxy, allow/block lists)
|
||||
* to smartvpn's IClientSecurity with a destinationPolicy.
|
||||
* TargetProfile direct IP:port targets extend the effective allow-list.
|
||||
*/
|
||||
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||
const security: plugins.smartvpn.IClientSecurity = {};
|
||||
const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
|
||||
const basePolicy = this.getBaseDestinationPolicy(client);
|
||||
|
||||
if (!forceSmartproxy) {
|
||||
// Client traffic goes directly — not forced to SmartProxy
|
||||
security.destinationPolicy = {
|
||||
default: 'allow' as const,
|
||||
blockList: client.destinationBlockList,
|
||||
};
|
||||
} else if (client.destinationAllowList?.length || client.destinationBlockList?.length) {
|
||||
// Client is forced to SmartProxy, but with per-client allow/block overrides
|
||||
security.destinationPolicy = {
|
||||
default: 'forceTarget' as const,
|
||||
target: '127.0.0.1',
|
||||
allowList: client.destinationAllowList,
|
||||
blockList: client.destinationBlockList,
|
||||
};
|
||||
}
|
||||
// else: no per-client policy, server-wide applies
|
||||
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||
const mergedAllowList = this.mergeDestinationLists(
|
||||
basePolicy.allowList,
|
||||
client.destinationAllowList,
|
||||
profileDirectTargets,
|
||||
);
|
||||
const mergedBlockList = this.mergeDestinationLists(
|
||||
basePolicy.blockList,
|
||||
client.destinationBlockList,
|
||||
);
|
||||
|
||||
security.destinationPolicy = {
|
||||
default: basePolicy.default,
|
||||
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
||||
};
|
||||
|
||||
return security;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all client security policies against the running daemon.
|
||||
* Call this when TargetProfiles change so destination allow-lists stay in sync.
|
||||
*/
|
||||
public async refreshAllClientSecurity(): Promise<void> {
|
||||
if (!this.vpnServer) return;
|
||||
for (const client of this.clients.values()) {
|
||||
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
||||
@@ -548,12 +548,7 @@ export class VpnManager {
|
||||
private async loadPersistedClients(): Promise<void> {
|
||||
const docs = await VpnClientDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
// Migrate legacy `tags` → `serverDefinedClientTags`
|
||||
if (!doc.serverDefinedClientTags && (doc as any).tags) {
|
||||
doc.serverDefinedClientTags = (doc as any).tags;
|
||||
(doc as any).tags = undefined;
|
||||
await doc.save();
|
||||
}
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
this.clients.set(doc.clientId, doc);
|
||||
}
|
||||
if (this.clients.size > 0) {
|
||||
@@ -561,6 +556,93 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
||||
return this.resolvedForwardingMode
|
||||
?? this.forwardingModeOverride
|
||||
?? this.config.forwardingMode
|
||||
?? 'socket';
|
||||
}
|
||||
|
||||
private getDefaultDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
useHostIp = false,
|
||||
): plugins.smartvpn.IDestinationPolicy {
|
||||
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
|
||||
return { default: 'allow' };
|
||||
}
|
||||
return { default: 'forceTarget', target: '127.0.0.1' };
|
||||
}
|
||||
|
||||
private getServerDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
|
||||
): plugins.smartvpn.IDestinationPolicy {
|
||||
return this.config.destinationPolicy ?? fallbackPolicy;
|
||||
}
|
||||
|
||||
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
|
||||
if (this.config.destinationPolicy) {
|
||||
return { ...this.config.destinationPolicy };
|
||||
}
|
||||
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
|
||||
}
|
||||
|
||||
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
|
||||
const merged = new Set<string>();
|
||||
for (const list of lists) {
|
||||
for (const entry of list || []) {
|
||||
merged.add(entry);
|
||||
}
|
||||
}
|
||||
return [...merged];
|
||||
}
|
||||
|
||||
private normalizeClientRoutingSettings(
|
||||
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
|
||||
): void {
|
||||
client.useHostIp = client.useHostIp === true;
|
||||
|
||||
if (!client.useHostIp) {
|
||||
client.useDhcp = false;
|
||||
client.staticIp = undefined;
|
||||
client.forceVlan = false;
|
||||
client.vlanId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
client.useDhcp = client.useDhcp === true;
|
||||
if (client.useDhcp) {
|
||||
client.staticIp = undefined;
|
||||
}
|
||||
|
||||
client.forceVlan = client.forceVlan === true;
|
||||
if (!client.forceVlan) {
|
||||
client.vlanId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
|
||||
return {
|
||||
description: client.description,
|
||||
security: this.buildClientSecurity(client),
|
||||
useHostIp: client.useHostIp,
|
||||
useDhcp: client.useDhcp,
|
||||
staticIp: client.staticIp,
|
||||
forceVlan: client.forceVlan,
|
||||
vlanId: client.vlanId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
|
||||
if (!useHostIp || !this.vpnServer) return;
|
||||
if (this.getResolvedForwardingMode() !== 'socket') return;
|
||||
|
||||
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
|
||||
this.forwardingModeOverride = 'hybrid';
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||
await client.save();
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ export class Route {
|
||||
|
||||
// Data from IMergedRoute
|
||||
public routeConfig: IRouteConfig;
|
||||
public source: 'hardcoded' | 'programmatic';
|
||||
public id: string;
|
||||
public enabled: boolean;
|
||||
public overridden: boolean;
|
||||
public storedRouteId?: string;
|
||||
public origin: 'config' | 'email' | 'dns' | 'api';
|
||||
public createdAt?: number;
|
||||
public updatedAt?: number;
|
||||
|
||||
@@ -22,21 +21,17 @@ export class Route {
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
|
||||
this.clientRef = clientRef;
|
||||
this.routeConfig = data.route;
|
||||
this.source = data.source;
|
||||
this.id = data.id;
|
||||
this.enabled = data.enabled;
|
||||
this.overridden = data.overridden;
|
||||
this.storedRouteId = data.storedRouteId;
|
||||
this.origin = data.origin;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
public async update(changes: Partial<IRouteConfig>): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
|
||||
'updateRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to update route');
|
||||
@@ -44,12 +39,9 @@ export class Route {
|
||||
}
|
||||
|
||||
public async delete(): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
|
||||
'deleteRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete route');
|
||||
@@ -57,41 +49,15 @@ export class Route {
|
||||
}
|
||||
|
||||
public async toggle(enabled: boolean): Promise<void> {
|
||||
if (!this.storedRouteId) {
|
||||
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
|
||||
}
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to toggle route');
|
||||
}
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public async setOverride(enabled: boolean): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to set route override');
|
||||
}
|
||||
this.overridden = true;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public async removeOverride(): Promise<void> {
|
||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to remove route override');
|
||||
}
|
||||
this.overridden = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteBuilder {
|
||||
@@ -144,9 +110,8 @@ export class RouteBuilder {
|
||||
}
|
||||
|
||||
// Return a Route instance by re-fetching the list
|
||||
// The created route is programmatic, so we find it by storedRouteId
|
||||
const { routes } = await new RouteManager(this.clientRef).list();
|
||||
const created = routes.find((r) => r.storedRouteId === response.storedRouteId);
|
||||
const created = routes.find((r) => r.id === response.routeId);
|
||||
if (created) {
|
||||
return created;
|
||||
}
|
||||
@@ -154,10 +119,9 @@ export class RouteBuilder {
|
||||
// Fallback: construct from known data
|
||||
return new Route(this.clientRef, {
|
||||
route: this.routeConfig as IRouteConfig,
|
||||
source: 'programmatic',
|
||||
id: response.routeId || '',
|
||||
enabled: this.isEnabled,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
origin: 'api',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -190,10 +154,9 @@ export class RouteManager {
|
||||
}
|
||||
return new Route(this.clientRef, {
|
||||
route: routeConfig,
|
||||
source: 'programmatic',
|
||||
id: response.routeId || '',
|
||||
enabled: enabled ?? true,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
origin: 'api',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"order": 4
|
||||
"order": 5
|
||||
}
|
||||
|
||||
25
ts_interfaces/data/acme-config.ts
Normal file
25
ts_interfaces/data/acme-config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* ACME configuration for automated TLS certificate issuance via Let's Encrypt.
|
||||
*
|
||||
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb. Replaces the
|
||||
* legacy constructor fields `tls.contactEmail` / `smartProxyConfig.acme.*`
|
||||
* which are now seed-only (used once on first boot if the DB is empty).
|
||||
*
|
||||
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
|
||||
*/
|
||||
export interface IAcmeConfig {
|
||||
/** Contact email used for Let's Encrypt account registration. */
|
||||
accountEmail: string;
|
||||
/** Whether ACME is enabled. If false, no certs are issued via ACME. */
|
||||
enabled: boolean;
|
||||
/** True = Let's Encrypt production, false = staging. */
|
||||
useProduction: boolean;
|
||||
/** Whether to automatically renew certs before expiry. */
|
||||
autoRenew: boolean;
|
||||
/** Renew when a cert has fewer than this many days of validity left. */
|
||||
renewThresholdDays: number;
|
||||
/** Unix ms timestamp of last config change. */
|
||||
updatedAt: number;
|
||||
/** Who last updated the config (userId or 'seed' / 'system'). */
|
||||
updatedBy: string;
|
||||
}
|
||||
174
ts_interfaces/data/dns-provider.ts
Normal file
174
ts_interfaces/data/dns-provider.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Stable ID for the built-in DcRouter pseudo-provider. The Providers list
|
||||
* surfaces this as the first, non-deletable row so operators see a uniform
|
||||
* "who serves this?" answer for every domain. The ID is magic — it never
|
||||
* exists in the DnsProviderDoc collection; handlers inject it at read time
|
||||
* and reject any mutation that targets it.
|
||||
*/
|
||||
export const DCROUTER_BUILTIN_PROVIDER_ID = '__dcrouter__';
|
||||
|
||||
/**
|
||||
* Supported DNS provider types.
|
||||
*
|
||||
* - 'cloudflare' → Cloudflare account (API token-based). Provider stays
|
||||
* authoritative; dcrouter pushes record changes via API.
|
||||
* - 'dcrouter' → Built-in pseudo-provider for dcrouter-hosted zones.
|
||||
* dcrouter itself is the authoritative DNS server. No
|
||||
* credentials, cannot be created/edited/deleted through
|
||||
* the provider CRUD — the Providers view renders it from
|
||||
* a handler-level synthetic row.
|
||||
*
|
||||
* The abstraction is designed so additional providers (Route53, Gandi,
|
||||
* DigitalOcean, foreign dcrouters…) can be added by implementing the
|
||||
* IDnsProvider class interface in ts/dns/providers/.
|
||||
*/
|
||||
export type TDnsProviderType = 'cloudflare' | 'dcrouter';
|
||||
|
||||
/**
|
||||
* Status of the last connection test against a provider.
|
||||
*/
|
||||
export type TDnsProviderStatus = 'untested' | 'ok' | 'error';
|
||||
|
||||
/**
|
||||
* Cloudflare-specific credential shape.
|
||||
*/
|
||||
export interface ICloudflareCredentials {
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all supported provider credential shapes.
|
||||
* Persisted opaquely on `IDnsProvider.credentials`.
|
||||
*/
|
||||
export type TDnsProviderCredentials =
|
||||
| ({ type: 'cloudflare' } & ICloudflareCredentials);
|
||||
|
||||
/**
|
||||
* A registered DNS provider account. Holds the credentials needed to
|
||||
* call the provider's API and a snapshot of its last health check.
|
||||
*/
|
||||
export interface IDnsProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TDnsProviderType;
|
||||
/** Opaque credentials object — shape depends on `type`. */
|
||||
credentials: TDnsProviderCredentials;
|
||||
status: TDnsProviderStatus;
|
||||
lastTestedAt?: number;
|
||||
lastError?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A redacted view of IDnsProvider safe to send to the UI / over the wire.
|
||||
* Strips secret fields from `credentials` while preserving the rest.
|
||||
*/
|
||||
export interface IDnsProviderPublic {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TDnsProviderType;
|
||||
status: TDnsProviderStatus;
|
||||
lastTestedAt?: number;
|
||||
lastError?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
/** Whether credentials are configured (true after creation). Never the secret itself. */
|
||||
hasCredentials: boolean;
|
||||
/**
|
||||
* True for the built-in DcRouter pseudo-provider — read-only, cannot be
|
||||
* created / edited / deleted. Injected by the handler layer, never
|
||||
* persisted in the DnsProviderDoc collection.
|
||||
*/
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A domain reported by a provider's API (not yet imported into dcrouter).
|
||||
*/
|
||||
export interface IProviderDomainListing {
|
||||
/** FQDN of the zone (e.g. 'example.com'). */
|
||||
name: string;
|
||||
/** Provider's internal zone identifier (zone_id for Cloudflare). */
|
||||
externalId: string;
|
||||
/** Authoritative nameservers reported by the provider. */
|
||||
nameservers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema entry for a single credential field, used by the OpsServer UI to
|
||||
* render a provider's credential form dynamically.
|
||||
*/
|
||||
export interface IDnsProviderCredentialField {
|
||||
/** Key under which the value is stored in the credentials object. */
|
||||
key: string;
|
||||
/** Label shown to the user. */
|
||||
label: string;
|
||||
/** Optional inline help text. */
|
||||
helpText?: string;
|
||||
/** Whether the field must be filled. */
|
||||
required: boolean;
|
||||
/** True for secret fields (rendered as password input, never echoed back). */
|
||||
secret: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata describing a DNS provider type. Drives:
|
||||
* - the OpsServer UI's provider type picker + credential form,
|
||||
* - documentation of which credentials each provider needs,
|
||||
* - end-to-end consistency between the type union, the discriminated
|
||||
* credentials union, the runtime factory, and the form rendering.
|
||||
*
|
||||
* To add a new provider, append a new entry to `dnsProviderTypeDescriptors`
|
||||
* below — and follow the checklist in `ts/dns/providers/factory.ts`.
|
||||
*/
|
||||
export interface IDnsProviderTypeDescriptor {
|
||||
type: TDnsProviderType;
|
||||
/** Human-readable name for the UI. */
|
||||
displayName: string;
|
||||
/** One-line description shown next to the type picker. */
|
||||
description: string;
|
||||
/** Schema for the credentials form. */
|
||||
credentialFields: IDnsProviderCredentialField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for which DNS provider types exist and what
|
||||
* credentials each one needs. Used by both backend and frontend.
|
||||
*/
|
||||
export const dnsProviderTypeDescriptors: ReadonlyArray<IDnsProviderTypeDescriptor> = [
|
||||
{
|
||||
type: 'dcrouter',
|
||||
displayName: 'DcRouter (built-in)',
|
||||
description:
|
||||
'Built-in authoritative DNS. Records are served directly by dcrouter — delegate the domain\'s NS records to make this effective.',
|
||||
credentialFields: [],
|
||||
},
|
||||
{
|
||||
type: 'cloudflare',
|
||||
displayName: 'Cloudflare',
|
||||
description:
|
||||
'External DNS provider. The provider stays authoritative; dcrouter pushes record changes via its API.',
|
||||
credentialFields: [
|
||||
{
|
||||
key: 'apiToken',
|
||||
label: 'API Token',
|
||||
helpText:
|
||||
'A Cloudflare API token with Zone:Read and DNS:Edit permissions for the target zones.',
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Look up the descriptor for a given provider type.
|
||||
*/
|
||||
export function getDnsProviderTypeDescriptor(
|
||||
type: TDnsProviderType,
|
||||
): IDnsProviderTypeDescriptor | undefined {
|
||||
return dnsProviderTypeDescriptors.find((d) => d.type === type);
|
||||
}
|
||||
44
ts_interfaces/data/dns-record.ts
Normal file
44
ts_interfaces/data/dns-record.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Supported DNS record types.
|
||||
*/
|
||||
export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA' | 'CAA';
|
||||
|
||||
/**
|
||||
* Where a DNS record came from.
|
||||
*
|
||||
* - 'local' → originated in this dcrouter (created via UI / API)
|
||||
* - 'synced' → pulled from an upstream provider (Cloudflare, foreign
|
||||
* dcrouter, …) during a sync operation
|
||||
*/
|
||||
export type TDnsRecordSource = 'local' | 'synced';
|
||||
|
||||
/**
|
||||
* A DNS record. For dcrouter-hosted (authoritative) domains, the record is
|
||||
* registered with the embedded smartdns.DnsServer. For provider-managed
|
||||
* domains, the record is mirrored from / pushed to the provider API and
|
||||
* `providerRecordId` holds the provider's internal record id (for updates
|
||||
* and deletes).
|
||||
*/
|
||||
export interface IDnsRecord {
|
||||
id: string;
|
||||
/** ID of the parent IDomain. */
|
||||
domainId: string;
|
||||
/** Fully qualified record name (e.g. 'www.example.com'). */
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
/**
|
||||
* Record value as a string. For MX records, formatted as
|
||||
* `<priority> <exchange>` (e.g. `10 mail.example.com`).
|
||||
*/
|
||||
value: string;
|
||||
/** TTL in seconds. */
|
||||
ttl: number;
|
||||
/** Cloudflare-specific: whether the record is proxied through Cloudflare. */
|
||||
proxied?: boolean;
|
||||
source: TDnsRecordSource;
|
||||
/** Provider's internal record id (for updates/deletes). Only set for provider records. */
|
||||
providerRecordId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
36
ts_interfaces/data/domain.ts
Normal file
36
ts_interfaces/data/domain.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Where a domain came from / how it is managed.
|
||||
*
|
||||
* - 'dcrouter' → dcrouter is the authoritative DNS server for this domain;
|
||||
* records are served by the embedded smartdns.DnsServer.
|
||||
* Operators delegate the domain's NS records to make this
|
||||
* effective.
|
||||
* - 'provider' → domain was imported from an external DNS provider
|
||||
* (e.g. Cloudflare). The provider stays authoritative;
|
||||
* dcrouter only reads/writes records via the provider API.
|
||||
*/
|
||||
export type TDomainSource = 'dcrouter' | 'provider';
|
||||
|
||||
/**
|
||||
* A domain under management by dcrouter.
|
||||
*/
|
||||
export interface IDomain {
|
||||
id: string;
|
||||
/** Fully qualified domain name (e.g. 'example.com'). */
|
||||
name: string;
|
||||
source: TDomainSource;
|
||||
/** ID of the DnsProvider that owns this domain — only set when source === 'provider'. */
|
||||
providerId?: string;
|
||||
/** True when dcrouter is the authoritative DNS server for this domain (source === 'dcrouter'). */
|
||||
authoritative: boolean;
|
||||
/** Authoritative nameservers (display only — populated from provider for imported domains). */
|
||||
nameservers?: string[];
|
||||
/** Provider's internal zone identifier — only set when source === 'provider'. */
|
||||
externalZoneId?: string;
|
||||
/** Last time records were synced from the provider — only set when source === 'provider'. */
|
||||
lastSyncedAt?: number;
|
||||
description?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
75
ts_interfaces/data/email-domain.ts
Normal file
75
ts_interfaces/data/email-domain.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* DNS record validation status for a single email-related record (MX, SPF, DKIM, DMARC).
|
||||
*/
|
||||
export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked';
|
||||
|
||||
/**
|
||||
* An email domain managed by dcrouter.
|
||||
*
|
||||
* Each email domain is linked to an existing dcrouter DNS domain (dcrouter-hosted
|
||||
* or provider-managed). The DNS management path is inherited from the linked domain
|
||||
* — no separate DNS mode is needed.
|
||||
*/
|
||||
export interface IEmailDomain {
|
||||
id: string;
|
||||
/** Fully qualified email domain name (e.g. 'example.com' or 'mail.example.com'). */
|
||||
domain: string;
|
||||
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
|
||||
linkedDomainId: string;
|
||||
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Empty/undefined = bare domain. */
|
||||
subdomain?: string;
|
||||
/** DKIM configuration and key state. */
|
||||
dkim: IEmailDomainDkim;
|
||||
/** Optional per-domain rate limits. */
|
||||
rateLimits?: IEmailDomainRateLimits;
|
||||
/** DNS record validation status — populated by validateDns(). */
|
||||
dnsStatus: IEmailDomainDnsStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IEmailDomainDkim {
|
||||
/** DKIM selector (default: 'default'). */
|
||||
selector: string;
|
||||
/** RSA key size in bits (default: 2048). */
|
||||
keySize: number;
|
||||
/** Base64-encoded public key — populated after key generation. */
|
||||
publicKey?: string;
|
||||
/** Whether automatic key rotation is enabled. */
|
||||
rotateKeys: boolean;
|
||||
/** Days between key rotations (default: 90). */
|
||||
rotationIntervalDays: number;
|
||||
/** ISO date of last key rotation. */
|
||||
lastRotatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IEmailDomainRateLimits {
|
||||
outbound?: {
|
||||
messagesPerMinute?: number;
|
||||
messagesPerHour?: number;
|
||||
messagesPerDay?: number;
|
||||
};
|
||||
inbound?: {
|
||||
messagesPerMinute?: number;
|
||||
connectionsPerIp?: number;
|
||||
recipientsPerMessage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IEmailDomainDnsStatus {
|
||||
mx: TDnsRecordStatus;
|
||||
spf: TDnsRecordStatus;
|
||||
dkim: TDnsRecordStatus;
|
||||
dmarc: TDnsRecordStatus;
|
||||
lastCheckedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single required DNS record for an email domain — used for display / copy-paste.
|
||||
*/
|
||||
export interface IEmailDnsRecord {
|
||||
type: 'MX' | 'TXT';
|
||||
name: string;
|
||||
value: string;
|
||||
status: TDnsRecordStatus;
|
||||
}
|
||||
@@ -2,4 +2,10 @@ export * from './auth.js';
|
||||
export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './vpn.js';
|
||||
export * from './target-profile.js';
|
||||
export * from './vpn.js';
|
||||
export * from './dns-provider.js';
|
||||
export * from './domain.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
@@ -51,26 +51,14 @@ export interface IRouteRemoteIngress {
|
||||
edgeFilter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level VPN access configuration.
|
||||
* When attached to a route, controls VPN client access.
|
||||
*/
|
||||
export interface IRouteVpn {
|
||||
/** Enable VPN client access for this route */
|
||||
enabled: boolean;
|
||||
/** When true (default), ONLY VPN clients can access this route (replaces ipAllowList).
|
||||
* When false, VPN client IPs are added alongside the existing allowlist. */
|
||||
mandatory?: boolean;
|
||||
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
|
||||
allowedServerDefinedClientTags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended route config used within dcrouter.
|
||||
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
|
||||
* Adds optional `remoteIngress` and `vpnOnly` properties to SmartProxy's IRouteConfig.
|
||||
* SmartProxy ignores unknown properties at runtime.
|
||||
*/
|
||||
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||
remoteIngress?: IRouteRemoteIngress;
|
||||
vpn?: IRouteVpn;
|
||||
/** When true, only VPN clients whose TargetProfile matches this route get access.
|
||||
* Matching is determined by domain overlap, target overlap, or direct routeRef. */
|
||||
vpnOnly?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
import type { IDcRouterRouteConfig } from './remoteingress.js';
|
||||
|
||||
// Derive IRouteSecurity from IRouteConfig since it's not directly exported
|
||||
export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||
@@ -11,18 +12,26 @@ export type TApiTokenScope =
|
||||
| 'routes:read' | 'routes:write'
|
||||
| 'config:read'
|
||||
| 'tokens:read' | 'tokens:manage'
|
||||
| 'profiles:read' | 'profiles:write'
|
||||
| 'targets:read' | 'targets:write';
|
||||
| 'source-profiles:read' | 'source-profiles:write'
|
||||
| 'target-profiles:read' | 'target-profiles:write'
|
||||
| 'targets:read' | 'targets:write'
|
||||
| 'dns-providers:read' | 'dns-providers:write'
|
||||
| 'domains:read' | 'domains:write'
|
||||
| 'dns-records:read' | 'dns-records:write'
|
||||
| 'acme-config:read' | 'acme-config:write';
|
||||
|
||||
// ============================================================================
|
||||
// Security Profile Types
|
||||
// Source Profile Types (source-side: who can access)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A reusable, named security profile that can be referenced by routes.
|
||||
* A reusable, named source profile that can be referenced by routes.
|
||||
* Stores the full IRouteSecurity shape from SmartProxy.
|
||||
*
|
||||
* SourceProfile = source-side (who can access: ipAllowList, rateLimit, auth)
|
||||
* TargetProfile = target-side (what can be accessed: domains, IP:port targets, route refs)
|
||||
*/
|
||||
export interface ISecurityProfile {
|
||||
export interface ISourceProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -61,12 +70,12 @@ export interface INetworkTarget {
|
||||
* Metadata on a stored route tracking where its resolved values came from.
|
||||
*/
|
||||
export interface IRouteMetadata {
|
||||
/** ID of the SecurityProfileDoc used to resolve this route's security. */
|
||||
securityProfileRef?: string;
|
||||
/** ID of the SourceProfileDoc used to resolve this route's security. */
|
||||
sourceProfileRef?: string;
|
||||
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
|
||||
networkTargetRef?: string;
|
||||
/** Snapshot of the profile name at resolution time, for display. */
|
||||
securityProfileName?: string;
|
||||
sourceProfileName?: string;
|
||||
/** Snapshot of the target name at resolution time, for display. */
|
||||
networkTargetName?: string;
|
||||
/** Timestamp of last reference resolution. */
|
||||
@@ -74,24 +83,23 @@ export interface IRouteMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* A merged route combining hardcoded and programmatic sources.
|
||||
* A route entry returned by the route management API.
|
||||
*/
|
||||
export interface IMergedRoute {
|
||||
route: IRouteConfig;
|
||||
source: 'hardcoded' | 'programmatic';
|
||||
route: IDcRouterRouteConfig;
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
overridden: boolean;
|
||||
storedRouteId?: string;
|
||||
origin: 'config' | 'email' | 'dns' | 'api';
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
metadata?: IRouteMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning generated during route merge/startup.
|
||||
* A warning generated during route startup/apply.
|
||||
*/
|
||||
export interface IRouteWarning {
|
||||
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
|
||||
type: 'disabled-route';
|
||||
routeName: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -114,28 +122,19 @@ export interface IApiTokenInfo {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A programmatic route stored in /config-api/routes/{id}.json
|
||||
* A route persisted in the database.
|
||||
*/
|
||||
export interface IStoredRoute {
|
||||
export interface IRoute {
|
||||
id: string;
|
||||
route: IRouteConfig;
|
||||
route: IDcRouterRouteConfig;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
origin: 'config' | 'email' | 'dns' | 'api';
|
||||
metadata?: IRouteMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
|
||||
*/
|
||||
export interface IRouteOverride {
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored API token, stored in /config-api/tokens/{id}.json
|
||||
*/
|
||||
|
||||
@@ -143,6 +143,15 @@ export interface IHealthStatus {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface IDomainActivity {
|
||||
domain: string;
|
||||
bytesInPerSecond: number;
|
||||
bytesOutPerSecond: number;
|
||||
activeConnections: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface INetworkMetrics {
|
||||
totalBandwidth: {
|
||||
in: number;
|
||||
@@ -156,16 +165,40 @@ export interface INetworkMetrics {
|
||||
connectionDetails: IConnectionDetails[];
|
||||
topEndpoints: Array<{
|
||||
endpoint: string;
|
||||
requests: number;
|
||||
connections: number;
|
||||
bandwidth: {
|
||||
in: number;
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
topEndpointsByBandwidth: Array<{
|
||||
endpoint: string;
|
||||
connections: number;
|
||||
bandwidth: {
|
||||
in: number;
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
domainActivity: IDomainActivity[];
|
||||
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
|
||||
requestsPerSecond?: number;
|
||||
requestsTotal?: number;
|
||||
backends?: IBackendInfo[];
|
||||
frontendProtocols?: IProtocolDistribution | null;
|
||||
backendProtocols?: IProtocolDistribution | null;
|
||||
}
|
||||
|
||||
export interface IProtocolDistribution {
|
||||
h1Active: number;
|
||||
h1Total: number;
|
||||
h2Active: number;
|
||||
h2Total: number;
|
||||
h3Active: number;
|
||||
h3Total: number;
|
||||
wsActive: number;
|
||||
wsTotal: number;
|
||||
otherActive: number;
|
||||
otherTotal: number;
|
||||
}
|
||||
|
||||
export interface IConnectionDetails {
|
||||
@@ -197,4 +230,24 @@ export interface IBackendInfo {
|
||||
h3ConsecutiveFailures: number | null;
|
||||
h3Port: number | null;
|
||||
cacheAgeSecs: number | null;
|
||||
}
|
||||
|
||||
export interface IRadiusStats {
|
||||
running: boolean;
|
||||
uptime: number;
|
||||
authRequests: number;
|
||||
authAccepts: number;
|
||||
authRejects: number;
|
||||
accountingRequests: number;
|
||||
activeSessions: number;
|
||||
totalInputBytes: number;
|
||||
totalOutputBytes: number;
|
||||
}
|
||||
|
||||
export interface IVpnStats {
|
||||
running: boolean;
|
||||
subnet: string;
|
||||
registeredClients: number;
|
||||
connectedClients: number;
|
||||
wgListenPort: number;
|
||||
}
|
||||
29
ts_interfaces/data/target-profile.ts
Normal file
29
ts_interfaces/data/target-profile.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* A specific IP:port target within a TargetProfile.
|
||||
*/
|
||||
export interface ITargetProfileTarget {
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable, named target profile that defines what resources a VPN client can reach.
|
||||
* Assigned to VPN clients via targetProfileIds.
|
||||
*
|
||||
* SourceProfile = source-side (who can access: ipAllowList, rateLimit, auth)
|
||||
* TargetProfile = target-side (what can be accessed: domains, IP:port targets, route refs)
|
||||
*/
|
||||
export interface ITargetProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Domain patterns this profile grants access to (supports wildcards: '*.example.com') */
|
||||
domains?: string[];
|
||||
/** Specific IP:port targets this profile grants access to */
|
||||
targets?: ITargetProfileTarget[];
|
||||
/** Route references by stored route ID. Legacy route names are normalized when unique. */
|
||||
routeRefs?: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
export interface IVpnClient {
|
||||
clientId: string;
|
||||
enabled: boolean;
|
||||
serverDefinedClientTags?: string[];
|
||||
/** IDs of TargetProfiles assigned to this client */
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
assignedIp?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt?: string;
|
||||
forceDestinationSmartproxy: boolean;
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
|
||||
@@ -80,6 +80,8 @@ interface IIdentity {
|
||||
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
||||
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||
| `IRadiusStats` | Running, uptime, auth requests/accepts/rejects, sessions, data transfer |
|
||||
| `IVpnStats` | Running, subnet, registered/connected clients, WireGuard port |
|
||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||
|
||||
#### Route Management Interfaces
|
||||
@@ -90,6 +92,13 @@ interface IIdentity {
|
||||
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
||||
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
||||
|
||||
#### Security & Reference Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `ISecurityProfile` | Reusable security config: id, name, description, security (ipAllowList, ipBlockList, maxConnections, rateLimit, etc.), extendsProfiles |
|
||||
| `INetworkTarget` | Reusable host:port destination: id, name, description, host (string or string[]), port |
|
||||
| `IRouteMetadata` | Route-to-reference links: securityProfileRef, networkTargetRef, snapshot names, lastResolvedAt |
|
||||
|
||||
#### Remote Ingress Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
@@ -128,7 +137,8 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
||||
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
||||
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
||||
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request |
|
||||
| `IReq_GetNetworkStats` | `getNetworkStats` | Network throughput and connection analytics |
|
||||
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request (server, email, DNS, security, network, RADIUS, VPN) |
|
||||
|
||||
#### ⚙️ Configuration
|
||||
| Interface | Method | Description |
|
||||
@@ -241,6 +251,26 @@ interface ICertificateInfo {
|
||||
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
|
||||
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
|
||||
|
||||
#### 🛡️ Security Profiles
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetSecurityProfiles` | `getSecurityProfiles` | List all security profiles |
|
||||
| `IReq_GetSecurityProfile` | `getSecurityProfile` | Get a single profile by ID |
|
||||
| `IReq_CreateSecurityProfile` | `createSecurityProfile` | Create a reusable security profile |
|
||||
| `IReq_UpdateSecurityProfile` | `updateSecurityProfile` | Update a profile (propagates to routes) |
|
||||
| `IReq_DeleteSecurityProfile` | `deleteSecurityProfile` | Delete a profile (with optional force) |
|
||||
| `IReq_GetSecurityProfileUsage` | `getSecurityProfileUsage` | Get routes referencing a profile |
|
||||
|
||||
#### 🎯 Network Targets
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetNetworkTargets` | `getNetworkTargets` | List all network targets |
|
||||
| `IReq_GetNetworkTarget` | `getNetworkTarget` | Get a single target by ID |
|
||||
| `IReq_CreateNetworkTarget` | `createNetworkTarget` | Create a reusable host:port target |
|
||||
| `IReq_UpdateNetworkTarget` | `updateNetworkTarget` | Update a target (propagates to routes) |
|
||||
| `IReq_DeleteNetworkTarget` | `deleteNetworkTarget` | Delete a target (with optional force) |
|
||||
| `IReq_GetNetworkTargetUsage` | `getNetworkTargetUsage` | Get routes referencing a target |
|
||||
|
||||
## Example: Full API Integration
|
||||
|
||||
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
||||
|
||||
54
ts_interfaces/requests/acme-config.ts
Normal file
54
ts_interfaces/requests/acme-config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IAcmeConfig } from '../data/acme-config.js';
|
||||
|
||||
// ============================================================================
|
||||
// ACME Config Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the current ACME configuration. Returns null if no config has been
|
||||
* set yet (neither from DB nor seeded from the constructor).
|
||||
*/
|
||||
export interface IReq_GetAcmeConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetAcmeConfig
|
||||
> {
|
||||
method: 'getAcmeConfig';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
config: IAcmeConfig | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the ACME configuration (upsert). All fields are required on first
|
||||
* create, optional on subsequent updates (partial update).
|
||||
*
|
||||
* NOTE: Most fields take effect on the next dcrouter restart — SmartAcme is
|
||||
* instantiated once at startup. `renewThresholdDays` applies immediately to
|
||||
* the next renewal check.
|
||||
*/
|
||||
export interface IReq_UpdateAcmeConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateAcmeConfig
|
||||
> {
|
||||
method: 'updateAcmeConfig';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
accountEmail?: string;
|
||||
enabled?: boolean;
|
||||
useProduction?: boolean;
|
||||
autoRenew?: boolean;
|
||||
renewThresholdDays?: number;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
config?: IAcmeConfig;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
domain: string;
|
||||
forceRenew?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface IReq_GetCombinedMetrics {
|
||||
dns?: boolean;
|
||||
security?: boolean;
|
||||
network?: boolean;
|
||||
radius?: boolean;
|
||||
vpn?: boolean;
|
||||
};
|
||||
};
|
||||
response: {
|
||||
@@ -19,6 +21,8 @@ export interface IReq_GetCombinedMetrics {
|
||||
dns?: data.IDnsStats;
|
||||
security?: data.ISecurityMetrics;
|
||||
network?: data.INetworkMetrics;
|
||||
radius?: data.IRadiusStats;
|
||||
vpn?: data.IVpnStats;
|
||||
};
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
154
ts_interfaces/requests/dns-providers.ts
Normal file
154
ts_interfaces/requests/dns-providers.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type {
|
||||
IDnsProviderPublic,
|
||||
IProviderDomainListing,
|
||||
TDnsProviderType,
|
||||
TDnsProviderCredentials,
|
||||
} from '../data/dns-provider.js';
|
||||
|
||||
// ============================================================================
|
||||
// DNS Provider Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all DNS providers (public view, no secrets).
|
||||
*/
|
||||
export interface IReq_GetDnsProviders extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsProviders
|
||||
> {
|
||||
method: 'getDnsProviders';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
providers: IDnsProviderPublic[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single DNS provider by id.
|
||||
*/
|
||||
export interface IReq_GetDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsProvider
|
||||
> {
|
||||
method: 'getDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
provider: IDnsProviderPublic | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new DNS provider.
|
||||
*/
|
||||
export interface IReq_CreateDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateDnsProvider
|
||||
> {
|
||||
method: 'createDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
type: TDnsProviderType;
|
||||
credentials: TDnsProviderCredentials;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a DNS provider. Only supplied fields are updated.
|
||||
* Pass `credentials` to rotate the secret.
|
||||
*/
|
||||
export interface IReq_UpdateDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateDnsProvider
|
||||
> {
|
||||
method: 'updateDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
credentials?: TDnsProviderCredentials;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a DNS provider. Fails if any IDomain still references it
|
||||
* unless `force: true` is set.
|
||||
*/
|
||||
export interface IReq_DeleteDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteDnsProvider
|
||||
> {
|
||||
method: 'deleteDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
force?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to a DNS provider. Used both for newly-saved
|
||||
* providers and on demand from the UI.
|
||||
*/
|
||||
export interface IReq_TestDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_TestDnsProvider
|
||||
> {
|
||||
method: 'testDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
testedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List the domains visible to a DNS provider's API account.
|
||||
* Used when importing — does NOT persist anything.
|
||||
*/
|
||||
export interface IReq_ListProviderDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListProviderDomains
|
||||
> {
|
||||
method: 'listProviderDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
providerId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
domains?: IProviderDomainListing[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
113
ts_interfaces/requests/dns-records.ts
Normal file
113
ts_interfaces/requests/dns-records.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IDnsRecord, TDnsRecordType } from '../data/dns-record.js';
|
||||
|
||||
// ============================================================================
|
||||
// DNS Record Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all DNS records for a domain.
|
||||
*/
|
||||
export interface IReq_GetDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsRecords
|
||||
> {
|
||||
method: 'getDnsRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
domainId: string;
|
||||
};
|
||||
response: {
|
||||
records: IDnsRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single DNS record by id.
|
||||
*/
|
||||
export interface IReq_GetDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsRecord
|
||||
> {
|
||||
method: 'getDnsRecord';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
record: IDnsRecord | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new DNS record.
|
||||
*
|
||||
* For dcrouter-hosted domains: registers the record with the embedded DnsServer.
|
||||
* For provider domains: pushes the record to the provider API.
|
||||
*/
|
||||
export interface IReq_CreateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateDnsRecord
|
||||
> {
|
||||
method: 'createDnsRecord';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
domainId: string;
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a DNS record.
|
||||
*/
|
||||
export interface IReq_UpdateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateDnsRecord
|
||||
> {
|
||||
method: 'updateDnsRecord';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a DNS record.
|
||||
*/
|
||||
export interface IReq_DeleteDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteDnsRecord
|
||||
> {
|
||||
method: 'deleteDnsRecord';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
178
ts_interfaces/requests/domains.ts
Normal file
178
ts_interfaces/requests/domains.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IDomain } from '../data/domain.js';
|
||||
|
||||
// ============================================================================
|
||||
// Domain Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all domains under management.
|
||||
*/
|
||||
export interface IReq_GetDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDomains
|
||||
> {
|
||||
method: 'getDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
domains: IDomain[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single domain by id.
|
||||
*/
|
||||
export interface IReq_GetDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDomain
|
||||
> {
|
||||
method: 'getDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
domain: IDomain | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
|
||||
* DNS records for this domain via the embedded smartdns.DnsServer.
|
||||
*/
|
||||
export interface IReq_CreateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateDomain
|
||||
> {
|
||||
method: 'createDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import one or more domains from a DNS provider. For each imported
|
||||
* domain, records are pulled from the provider into DnsRecordDoc.
|
||||
*/
|
||||
export interface IReq_ImportDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ImportDomain
|
||||
> {
|
||||
method: 'importDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
providerId: string;
|
||||
/** FQDN(s) of the zone(s) to import — must be visible to the provider account. */
|
||||
domainNames: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
importedIds?: string[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a domain's metadata. Cannot change source / providerId once set.
|
||||
*/
|
||||
export interface IReq_UpdateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateDomain
|
||||
> {
|
||||
method: 'updateDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
description?: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a domain and all of its DNS records.
|
||||
* For provider-managed domains, this only removes dcrouter's local record —
|
||||
* it does NOT delete the zone at the provider.
|
||||
*/
|
||||
export interface IReq_DeleteDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteDomain
|
||||
> {
|
||||
method: 'deleteDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-resync a provider-managed domain: re-pulls all records from the
|
||||
* provider API, replacing the cached DnsRecordDocs.
|
||||
* No-op for dcrouter-hosted domains.
|
||||
*/
|
||||
export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SyncDomain
|
||||
> {
|
||||
method: 'syncDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
recordCount?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a domain between dcrouter-hosted and provider-managed (or between providers).
|
||||
* Records are transferred to the target and the domain source/providerId are updated.
|
||||
*/
|
||||
export interface IReq_MigrateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_MigrateDomain
|
||||
> {
|
||||
method: 'migrateDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
/** Target source type. */
|
||||
targetSource: import('../data/domain.js').TDomainSource;
|
||||
/** Required when targetSource is 'provider'. */
|
||||
targetProviderId?: string;
|
||||
/** When migrating to a provider: delete all existing records at the provider first. */
|
||||
deleteExistingProviderRecords?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
/** Number of records migrated. */
|
||||
recordsMigrated?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
178
ts_interfaces/requests/email-domains.ts
Normal file
178
ts_interfaces/requests/email-domains.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IEmailDomain, IEmailDnsRecord } from '../data/email-domain.js';
|
||||
|
||||
// ============================================================================
|
||||
// Email Domain Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* List all email domains.
|
||||
*/
|
||||
export interface IReq_GetEmailDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomains
|
||||
> {
|
||||
method: 'getEmailDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
domains: IEmailDomain[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single email domain by id.
|
||||
*/
|
||||
export interface IReq_GetEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomain
|
||||
> {
|
||||
method: 'getEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
domain: IEmailDomain | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an email domain. Links to an existing dcrouter DNS domain.
|
||||
* Generates DKIM keys and computes the required DNS records.
|
||||
*/
|
||||
export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateEmailDomain
|
||||
> {
|
||||
method: 'createEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
/** ID of the existing dcrouter DNS domain to link to. */
|
||||
linkedDomainId: string;
|
||||
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Leave empty for bare domain. */
|
||||
subdomain?: string;
|
||||
/** DKIM selector (default: 'default'). */
|
||||
dkimSelector?: string;
|
||||
/** RSA key size (default: 2048). */
|
||||
dkimKeySize?: number;
|
||||
/** Enable automatic key rotation (default: false). */
|
||||
rotateKeys?: boolean;
|
||||
/** Days between rotations (default: 90). */
|
||||
rotationIntervalDays?: number;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
domain?: IEmailDomain;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an email domain's configuration.
|
||||
*/
|
||||
export interface IReq_UpdateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateEmailDomain
|
||||
> {
|
||||
method: 'updateEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
rateLimits?: IEmailDomain['rateLimits'];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an email domain.
|
||||
*/
|
||||
export interface IReq_DeleteEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteEmailDomain
|
||||
> {
|
||||
method: 'deleteEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger DNS validation for an email domain.
|
||||
* Performs live lookups for MX, SPF, DKIM, and DMARC records.
|
||||
*/
|
||||
export interface IReq_ValidateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ValidateEmailDomain
|
||||
> {
|
||||
method: 'validateEmailDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
domain?: IEmailDomain;
|
||||
records?: IEmailDnsRecord[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required DNS records for an email domain (for display / copy-paste).
|
||||
*/
|
||||
export interface IReq_GetEmailDomainDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailDomainDnsRecords
|
||||
> {
|
||||
method: 'getEmailDomainDnsRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
records: IEmailDnsRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-provision DNS records for an email domain.
|
||||
* Creates any missing MX, SPF, DKIM, and DMARC records via the linked
|
||||
* domain's DNS path (dcrouter zone or provider API).
|
||||
*/
|
||||
export interface IReq_ProvisionEmailDomainDns extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ProvisionEmailDomainDns
|
||||
> {
|
||||
method: 'provisionEmailDomainDns';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
/** Number of records created. */
|
||||
provisioned?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -10,5 +10,12 @@ export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './api-tokens.js';
|
||||
export * from './vpn.js';
|
||||
export * from './security-profiles.js';
|
||||
export * from './network-targets.js';
|
||||
export * from './source-profiles.js';
|
||||
export * from './target-profiles.js';
|
||||
export * from './network-targets.js';
|
||||
export * from './users.js';
|
||||
export * from './dns-providers.js';
|
||||
export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domains.js';
|
||||
@@ -2,13 +2,14 @@ import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all merged routes (hardcoded + programmatic) with warnings.
|
||||
* Get all routes with warnings.
|
||||
*/
|
||||
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -26,7 +27,7 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new programmatic route.
|
||||
* Create a new route.
|
||||
*/
|
||||
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -36,19 +37,19 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
route: IRouteConfig;
|
||||
route: IDcRouterRouteConfig;
|
||||
enabled?: boolean;
|
||||
metadata?: IRouteMetadata;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
storedRouteId?: string;
|
||||
routeId?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a programmatic route.
|
||||
* Update a route.
|
||||
*/
|
||||
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -59,7 +60,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
route?: Partial<IRouteConfig>;
|
||||
route?: Partial<IDcRouterRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
};
|
||||
@@ -70,7 +71,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a programmatic route.
|
||||
* Delete a route.
|
||||
*/
|
||||
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -89,46 +90,7 @@ export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.impleme
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an override on a hardcoded route (disable/enable by name).
|
||||
*/
|
||||
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SetRouteOverride
|
||||
> {
|
||||
method: 'setRouteOverride';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an override from a hardcoded route (restore default behavior).
|
||||
*/
|
||||
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveRouteOverride
|
||||
> {
|
||||
method: 'removeRouteOverride';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a programmatic route on/off by id.
|
||||
* Toggle a route on/off by id.
|
||||
*/
|
||||
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { ISecurityProfile, IRouteSecurity } from '../data/route-management.js';
|
||||
import type { ISourceProfile, IRouteSecurity } from '../data/route-management.js';
|
||||
|
||||
// ============================================================================
|
||||
// Security Profile Endpoints
|
||||
// Source Profile Endpoints (source-side: who can access)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all security profiles.
|
||||
* Get all source profiles.
|
||||
*/
|
||||
export interface IReq_GetSecurityProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetSourceProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityProfiles
|
||||
IReq_GetSourceProfiles
|
||||
> {
|
||||
method: 'getSecurityProfiles';
|
||||
method: 'getSourceProfiles';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
profiles: ISecurityProfile[];
|
||||
profiles: ISourceProfile[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single security profile by ID.
|
||||
* Get a single source profile by ID.
|
||||
*/
|
||||
export interface IReq_GetSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityProfile
|
||||
IReq_GetSourceProfile
|
||||
> {
|
||||
method: 'getSecurityProfile';
|
||||
method: 'getSourceProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
profile: ISecurityProfile | null;
|
||||
profile: ISourceProfile | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new security profile.
|
||||
* Create a new source profile.
|
||||
*/
|
||||
export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_CreateSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateSecurityProfile
|
||||
IReq_CreateSourceProfile
|
||||
> {
|
||||
method: 'createSecurityProfile';
|
||||
method: 'createSourceProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
@@ -65,13 +65,13 @@ export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfac
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a security profile.
|
||||
* Update a source profile.
|
||||
*/
|
||||
export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_UpdateSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateSecurityProfile
|
||||
IReq_UpdateSourceProfile
|
||||
> {
|
||||
method: 'updateSecurityProfile';
|
||||
method: 'updateSourceProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
@@ -89,13 +89,13 @@ export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfac
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a security profile.
|
||||
* Delete a source profile.
|
||||
*/
|
||||
export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_DeleteSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteSecurityProfile
|
||||
IReq_DeleteSourceProfile
|
||||
> {
|
||||
method: 'deleteSecurityProfile';
|
||||
method: 'deleteSourceProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
@@ -109,13 +109,13 @@ export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfac
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which routes reference a security profile.
|
||||
* Get which routes reference a source profile.
|
||||
*/
|
||||
export interface IReq_GetSecurityProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetSourceProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityProfileUsage
|
||||
IReq_GetSourceProfileUsage
|
||||
> {
|
||||
method: 'getSecurityProfileUsage';
|
||||
method: 'getSourceProfileUsage';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
@@ -180,5 +180,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
|
||||
requestsPerSecond: number;
|
||||
requestsTotal: number;
|
||||
backends?: statsInterfaces.IBackendInfo[];
|
||||
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||
domainActivity: statsInterfaces.IDomainActivity[];
|
||||
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
};
|
||||
}
|
||||
128
ts_interfaces/requests/target-profiles.ts
Normal file
128
ts_interfaces/requests/target-profiles.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { ITargetProfile, ITargetProfileTarget } from '../data/target-profile.js';
|
||||
|
||||
// ============================================================================
|
||||
// Target Profile Endpoints (target-side: what can be accessed)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all target profiles.
|
||||
*/
|
||||
export interface IReq_GetTargetProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetTargetProfiles
|
||||
> {
|
||||
method: 'getTargetProfiles';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
profiles: ITargetProfile[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single target profile by ID.
|
||||
*/
|
||||
export interface IReq_GetTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetTargetProfile
|
||||
> {
|
||||
method: 'getTargetProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
profile: ITargetProfile | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new target profile.
|
||||
*/
|
||||
export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateTargetProfile
|
||||
> {
|
||||
method: 'createTargetProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: ITargetProfileTarget[];
|
||||
routeRefs?: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a target profile.
|
||||
*/
|
||||
export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateTargetProfile
|
||||
> {
|
||||
method: 'updateTargetProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: ITargetProfileTarget[];
|
||||
routeRefs?: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a target profile.
|
||||
*/
|
||||
export interface IReq_DeleteTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteTargetProfile
|
||||
> {
|
||||
method: 'deleteTargetProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
force?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which VPN clients reference a target profile.
|
||||
*/
|
||||
export interface IReq_GetTargetProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetTargetProfileUsage
|
||||
> {
|
||||
method: 'getTargetProfileUsage';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
clients: Array<{ clientId: string; description?: string }>;
|
||||
};
|
||||
}
|
||||
23
ts_interfaces/requests/users.ts
Normal file
23
ts_interfaces/requests/users.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
/**
|
||||
* List all OpsServer users (admin-only, read-only).
|
||||
* Deliberately omits password/secret fields from the response.
|
||||
*/
|
||||
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListUsers
|
||||
> {
|
||||
method: 'listUsers';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
users: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -49,9 +49,9 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
@@ -81,8 +81,8 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
description?: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
targetProfileIds?: string[];
|
||||
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
|
||||
140
ts_migrations/index.ts
Normal file
140
ts_migrations/index.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
/**
|
||||
* dcrouter migration runner.
|
||||
*
|
||||
* Uses @push.rocks/smartmigration via dynamic import so smartmigration's type
|
||||
* chain (which pulls in mongodb 7.x and related types) doesn't leak into
|
||||
* compile-time type checking for this folder.
|
||||
*/
|
||||
|
||||
/** Matches the subset of IMigrationRunResult we actually log. */
|
||||
export interface IMigrationRunResult {
|
||||
stepsApplied: Array<unknown>;
|
||||
wasFreshInstall: boolean;
|
||||
currentVersionBefore: string | null;
|
||||
currentVersionAfter: string;
|
||||
totalDurationMs: number;
|
||||
}
|
||||
|
||||
export interface IMigrationRunner {
|
||||
run(): Promise<IMigrationRunResult>;
|
||||
}
|
||||
|
||||
async function migrateTargetProfileTargetHosts(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('TargetProfileDoc');
|
||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||
let migrated = 0;
|
||||
|
||||
for await (const doc of cursor) {
|
||||
const targets = ((doc as any).targets || []).map((target: any) => {
|
||||
if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) {
|
||||
const { host, ...rest } = target;
|
||||
return { ...rest, ip: host };
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
* Call `.run()` on the returned instance at startup (after DcRouterDb is ready,
|
||||
* before any service that reads migrated collections).
|
||||
*
|
||||
* @param db - The initialized SmartdataDb instance from DcRouterDb.getDb()
|
||||
* @param targetVersion - The current app version (from commitinfo.version)
|
||||
*/
|
||||
export async function createMigrationRunner(
|
||||
db: unknown,
|
||||
targetVersion: string,
|
||||
): Promise<IMigrationRunner> {
|
||||
const sm = await import('@push.rocks/smartmigration');
|
||||
const migration = new sm.SmartMigration({
|
||||
targetVersion,
|
||||
db: db as any,
|
||||
// Brand-new installs skip all migrations and stamp directly to the current version.
|
||||
freshInstallVersion: targetVersion,
|
||||
});
|
||||
|
||||
// Register steps in execution order. Each step's .from() must match the
|
||||
// previous step's .to() to form a contiguous chain.
|
||||
migration
|
||||
.step('rename-target-profile-host-to-ip')
|
||||
.from('13.0.11').to('13.1.0')
|
||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
|
||||
.step('rename-domain-source-manual-to-dcrouter')
|
||||
.from('13.1.0').to('13.8.1')
|
||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('domaindoc');
|
||||
const result = await collection.updateMany(
|
||||
{ source: 'manual' },
|
||||
{ $set: { source: 'dcrouter' } },
|
||||
);
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`rename-domain-source-manual-to-dcrouter: migrated ${result.modifiedCount} domain(s)`,
|
||||
);
|
||||
})
|
||||
.step('rename-record-source-manual-to-local')
|
||||
.from('13.8.1').to('13.8.2')
|
||||
.description('Rename DnsRecordDoc.source value from "manual" to "local"')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('dnsrecorddoc');
|
||||
const result = await collection.updateMany(
|
||||
{ source: 'manual' },
|
||||
{ $set: { source: 'local' } },
|
||||
);
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`,
|
||||
);
|
||||
})
|
||||
.step('unify-routes-rename-collection')
|
||||
.from('13.8.2').to('13.16.0')
|
||||
.description('Rename StoredRouteDoc → RouteDoc, add origin field, drop RouteOverrideDoc')
|
||||
.up(async (ctx) => {
|
||||
const db = ctx.mongo!;
|
||||
|
||||
// 1. Rename StoredRouteDoc → RouteDoc (smartdata uses exact class names)
|
||||
const collections = await db.listCollections({ name: 'StoredRouteDoc' }).toArray();
|
||||
if (collections.length > 0) {
|
||||
await db.renameCollection('StoredRouteDoc', 'RouteDoc');
|
||||
ctx.log.log('info', 'Renamed StoredRouteDoc → RouteDoc');
|
||||
}
|
||||
|
||||
// 2. Set origin='api' on all migrated docs (they were API-created)
|
||||
const routeCol = db.collection('RouteDoc');
|
||||
const result = await routeCol.updateMany(
|
||||
{ origin: { $exists: false } },
|
||||
{ $set: { origin: 'api' } },
|
||||
);
|
||||
ctx.log.log('info', `Set origin='api' on ${result.modifiedCount} migrated route(s)`);
|
||||
|
||||
// 3. Drop RouteOverrideDoc collection
|
||||
const overrideCollections = await db.listCollections({ name: 'RouteOverrideDoc' }).toArray();
|
||||
if (overrideCollections.length > 0) {
|
||||
await db.collection('RouteOverrideDoc').drop();
|
||||
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
|
||||
}
|
||||
})
|
||||
.step('repair-target-profile-ip-migration')
|
||||
.from('13.16.0').to('13.17.4')
|
||||
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
|
||||
.up(async (ctx) => {
|
||||
await migrateTargetProfileTargetHosts(ctx);
|
||||
});
|
||||
|
||||
return migration;
|
||||
}
|
||||
3
ts_migrations/tspublish.json
Normal file
3
ts_migrations/tspublish.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 2
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '12.2.4',
|
||||
version: '13.18.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
1163
ts_web/appstate.ts
1163
ts_web/appstate.ts
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user