Compare commits

..

78 Commits

Author SHA1 Message Date
8bfc0c2fa2 v12.2.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-02 15:44:36 +00:00
55699f6618 feat(config): add reusable security profiles and network targets with route reference resolution 2026-04-02 15:44:36 +00:00
6344c2deae v12.1.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-01 05:13:02 +00:00
c1452131fa feat(vpn): add per-client routing controls and bridge forwarding support for VPN clients 2026-04-01 05:13:01 +00:00
81f8e543e1 v12.0.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 15:31:16 +00:00
bb6c26484d BREAKING CHANGE(db): replace StorageManager and CacheDb with a unified smartdata-backed database layer 2026-03-31 15:31:16 +00:00
193a4bb180 v11.23.5
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:51:46 +00:00
0d9e6a4925 fix(config): correct VPN mandatory flag default handling in route config manager 2026-03-31 11:51:45 +00:00
ece9e46be9 v11.23.4
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:41:44 +00:00
918390a6a4 fix(deps): bump @push.rocks/smartvpn to 1.17.1 2026-03-31 11:41:44 +00:00
4ec0b67a71 v11.23.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:33:45 +00:00
356d6eca77 fix(ts_web): update appstate to import interfaces from source TypeScript module path 2026-03-31 11:33:45 +00:00
39c77accf8 v11.23.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:30:39 +00:00
b8fba52cb3 fix(repo): no changes to commit 2026-03-31 11:30:39 +00:00
f247c77807 v11.23.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:28:26 +00:00
e88938cf95 fix(repo): no changes to commit 2026-03-31 11:28:26 +00:00
4f705a591e v11.23.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 11:19:29 +00:00
29687670e8 feat(vpn): support optional non-mandatory VPN route access and align route config with enabled semantics 2026-03-31 11:19:29 +00:00
95daee1d8f v11.22.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 09:53:37 +00:00
11ca64a1cd feat(vpn): add VPN client editing and connected client visibility in ops server 2026-03-31 09:53:37 +00:00
cfb727b86d v11.21.5
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 04:15:51 +00:00
1e4b9997f4 fix(routing): apply VPN route allowlists dynamically after VPN clients load 2026-03-31 04:15:51 +00:00
bb32f23d77 v11.21.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 03:36:36 +00:00
1aa6451dba fix(deps): bump @push.rocks/smartvpn to 1.16.4 2026-03-31 03:36:36 +00:00
eb0408c036 v11.21.3
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 03:21:41 +00:00
098a2567fa fix(deps): bump @push.rocks/smartvpn to 1.16.3 2026-03-31 03:21:41 +00:00
c6534df362 v11.21.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 02:12:18 +00:00
2e4b375ad5 fix(deps): bump @push.rocks/smartvpn to 1.16.2 2026-03-31 02:12:18 +00:00
802bcf1c3d v11.21.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 01:10:19 +00:00
bad0bd9053 fix(vpn): resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups 2026-03-31 01:10:19 +00:00
ca990781b0 v11.21.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 00:45:46 +00:00
6807aefce8 feat(vpn): add tag-aware WireGuard AllowedIPs for VPN-gated routes 2026-03-31 00:45:46 +00:00
450ec4816e v11.20.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 00:08:54 +00:00
ab4310b775 fix(vpn-manager): persist WireGuard private keys for valid client exports and QR codes 2026-03-31 00:08:54 +00:00
6efd986406 v11.20.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 23:50:51 +00:00
7370d7f0e7 feat(vpn-ui): add QR code export for WireGuard client configurations 2026-03-30 23:50:51 +00:00
e733067c25 v11.19.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 18:14:51 +00:00
bc2ed808f9 fix(vpn): configure SmartVPN client exports with explicit server endpoint and split-tunnel allowed IPs 2026-03-30 18:14:51 +00:00
61d856f371 v11.19.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 17:24:18 +00:00
a8d52a4709 feat(vpn): document tag-based VPN access control, declarative clients, and destination policy options 2026-03-30 17:24:17 +00:00
f685ce9928 v11.18.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 17:08:57 +00:00
699aa8a8e1 feat(vpn-ui): add format selection for VPN client config exports 2026-03-30 17:08:57 +00:00
6fa7206f86 v11.17.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 16:49:58 +00:00
11cce23e21 feat(vpn): expand VPN operations view with client management and config export actions 2026-03-30 16:49:58 +00:00
d109554134 v11.16.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 13:06:14 +00:00
cc3a7cb5b6 feat(vpn): add destination-based VPN routing policy and standardize socket proxy forwarding 2026-03-30 13:06:14 +00:00
d53cff6a94 v11.15.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 12:07:58 +00:00
eb211348d2 feat(vpn): add tag-based VPN route access control and support configured initial VPN clients 2026-03-30 12:07:58 +00:00
43618abeba v11.14.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:59:38 +00:00
dd9769b814 feat(docs): document VPN access control and add OpsServer VPN navigation 2026-03-30 08:59:38 +00:00
99b40fea3f v11.13.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:15:09 +00:00
6f72e4fdbc feat(vpn): add VPN server management and route-based VPN access control 2026-03-30 08:15:09 +00:00
fbe845cd8e v11.12.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 22:38:29 +00:00
31413d28be fix(acme): use X509 certificate expiry when reporting ACME certificate validity 2026-03-27 22:38:29 +00:00
cd286cede6 v11.12.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:49:39 +00:00
36a3060cce fix(dcrouter): re-trigger auto certificate provisioning after SmartAcme becomes ready 2026-03-27 19:49:38 +00:00
d2b108317e v11.12.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:28:55 +00:00
dcd75f5e47 fix(dcrouter): guard auto certificate reprovisioning against unnamed routes 2026-03-27 19:28:55 +00:00
3d443fa147 v11.12.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 19:26:40 +00:00
2efdd2f16b fix(dcrouter): retry auto certificate provisioning after SmartAcme becomes ready 2026-03-27 19:26:39 +00:00
ec0348a83c v11.12.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-27 18:46:11 +00:00
6c4adf70c7 feat(web-ui): pause dashboard polling, sockets, and chart updates when the tab is hidden 2026-03-27 18:46:11 +00:00
29d6076355 v11.11.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 16:21:45 +00:00
fa96a41e68 feat(docker,cache,proxy): improve container runtime defaults and add configurable connection limits 2026-03-26 16:21:45 +00:00
1ea38ed5d2 v11.10.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 08:43:36 +00:00
7209903d02 fix(sms): update sms service to use async ProjectInfo initialization 2026-03-26 08:43:36 +00:00
20eda1ab3e v11.10.6
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 07:40:56 +00:00
44f2a7f0a9 fix(typescript): tighten TypeScript null safety and error handling across backend and ops UI 2026-03-26 07:40:56 +00:00
0195a21f30 v11.10.5
Some checks failed
Docker (tags) / security (push) Failing after 4s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-26 07:10:59 +00:00
4dca747386 fix(build): rename smart tooling config to .smartconfig.json and update package references 2026-03-26 07:10:59 +00:00
7663f502fa v11.10.4
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-24 13:40:28 +00:00
104cd417d8 fix(monitoring): handle multiple protocol cache entries per backend in metrics output 2026-03-24 13:40:28 +00:00
93254d5d3d v11.10.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 21:18:20 +00:00
9a3f121a9c fix(deps): bump tstest, smartmetrics, and taskbuffer to latest patch releases 2026-03-23 21:18:20 +00:00
bef74eb1aa v11.10.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 14:22:24 +00:00
308d8e4851 fix(deps): bump @api.global/typedserver to ^8.4.6 and @push.rocks/smartproxy to ^26.2.1 2026-03-23 14:22:24 +00:00
dc010dc3ae v11.10.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-23 10:29:16 +00:00
61d5d3b1ad fix(deps): bump @push.rocks/smartproxy to ^26.2.0 2026-03-23 10:29:16 +00:00
108 changed files with 8919 additions and 2999 deletions

View File

@@ -1 +1,7 @@
node_modules/ node_modules/
.nogit/
.git/
.playwright-mcp/
.vscode/
test/
test_watch/

View File

@@ -1,7 +1,7 @@
{ {
"json.schemas": [ "json.schemas": [
{ {
"fileMatch": ["/npmextra.json"], "fileMatch": ["/.smartconfig.json"],
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -18,9 +18,17 @@ WORKDIR /app
COPY --from=build /app /app COPY --from=build /app /app
ENV DCROUTER_MODE=OCI_CONTAINER ENV DCROUTER_MODE=OCI_CONTAINER
ENV DCROUTER_HEAP_SIZE=512
ENV UV_THREADPOOL_SIZE=16
RUN pnpm install -g @servezone/healthy RUN pnpm install -g @servezone/healthy
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ] HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
EXPOSE 80 LABEL org.opencontainers.image.title="dcrouter" \
CMD ["npm", "start"] org.opencontainers.image.description="Multi-service datacenter gateway" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]

View File

@@ -1,5 +1,270 @@
# Changelog # Changelog
## 2026-04-02 - 12.2.0 - feat(config)
add reusable security profiles and network targets with route reference resolution
- introduces persisted security profile and network target models plus typed OpsServer CRUD and usage endpoints
- adds route metadata support so routes can reference profiles and targets and be re-resolved after updates
- supports optional seeding of default profiles and targets when the database is empty
- adds dashboard views and state management for managing security profiles and network targets
- includes tests for reference resolver behavior and API fallback/auth handling
## 2026-04-01 - 12.1.0 - feat(vpn)
add per-client routing controls and bridge forwarding support for VPN clients
- adds persisted per-client VPN settings for SmartProxy enforcement, destination allow/block lists, host IP assignment, DHCP/static IP selection, and VLAN options
- passes new VPN routing and bridge configuration through request handlers, app state, and the ops UI for creating, editing, and viewing clients
- supports bridge and hybrid forwarding modes in the VPN manager, including auto-upgrading to hybrid when clients request host IP access
- updates smartvpn and dees-catalog dependencies to support the new VPN forwarding capabilities
## 2026-03-31 - 12.0.0 - BREAKING CHANGE(db)
replace StorageManager and CacheDb with a unified smartdata-backed database layer
- introduces DcRouterDb with embedded LocalSmartDb or external MongoDB support via dbConfig
- migrates persisted routes, API tokens, VPN data, certificates, remote ingress, VLAN mappings, RADIUS accounting, and cache records to smartdata document classes
- removes StorageManager and CacheDb modules and renames configuration from cacheConfig to dbConfig
- updates certificate, security, remote ingress, VPN, and RADIUS components to read and write through document models
## 2026-03-31 - 11.23.5 - fix(config)
correct VPN mandatory flag default handling in route config manager
- Changes the VPN mandatory check so it only applies when explicitly set to true, matching the updated default behavior of false.
- Prevents routes from being treated as VPN-mandatory when the setting is omitted.
## 2026-03-31 - 11.23.4 - fix(deps)
bump @push.rocks/smartvpn to 1.17.1
- Updates the @push.rocks/smartvpn dependency from 1.16.5 to 1.17.1.
## 2026-03-31 - 11.23.3 - fix(ts_web)
update appstate to import interfaces from source TypeScript module path
- Replaces the appstate interfaces import from ../dist_ts_interfaces/index.js with ../ts_interfaces/index.js.
- Aligns the web app state module with the source interface location instead of the built distribution path.
## 2026-03-31 - 11.23.2 - fix(repo)
no changes to commit
## 2026-03-31 - 11.23.1 - fix(repo)
no changes to commit
## 2026-03-31 - 11.23.0 - feat(vpn)
support optional non-mandatory VPN route access and align route config with enabled semantics
- rename route VPN configuration from `required` to `enabled` across code, docs, and examples
- add `vpn.mandatory` to control whether VPN allowlists replace or extend existing `security.ipAllowList` rules
- improve VPN client status matching in the ops view by falling back to assigned IP when client IDs differ
## 2026-03-31 - 11.22.0 - feat(vpn)
add VPN client editing and connected client visibility in ops server
- Adds API support to list currently connected VPN clients and update client metadata without rotating keys
- Updates the web VPN view to show live connection status, client detail telemetry, and separate enable/disable actions
- Refreshes documentation for smart split tunnel behavior, QR code setup/export, and storage architecture
- Bumps @push.rocks/smartvpn from 1.16.4 to 1.16.5
## 2026-03-31 - 11.21.5 - fix(routing)
apply VPN route allowlists dynamically after VPN clients load
- Moves VPN security injection for hardcoded and programmatic routes into RouteConfigManager.applyRoutes() so allowlists are generated from current VPN client state.
- Re-applies routes after starting the VPN manager to ensure tag-based ipAllowLists are available once VPN clients are loaded.
- Avoids caching constructor routes with stale VPN security baked in while preserving HTTP/3 route augmentation.
## 2026-03-31 - 11.21.4 - fix(deps)
bump @push.rocks/smartvpn to 1.16.4
- Updates the @push.rocks/smartvpn dependency from 1.16.3 to 1.16.4 in package.json.
## 2026-03-31 - 11.21.3 - fix(deps)
bump @push.rocks/smartvpn to 1.16.3
- Updates the @push.rocks/smartvpn dependency from 1.16.2 to 1.16.3.
## 2026-03-31 - 11.21.2 - fix(deps)
bump @push.rocks/smartvpn to 1.16.2
- Updates the @push.rocks/smartvpn dependency from 1.16.1 to 1.16.2 in package.json.
## 2026-03-31 - 11.21.1 - fix(vpn)
resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups
- Derive WireGuard AllowedIPs from DNS A records of matched vpn.required route domains instead of only configured public proxy IPs.
- Cache resolved domain IPs for 5 minutes and fall back to stale results on DNS lookup failures.
- Make per-client AllowedIPs generation asynchronous throughout VPN config export and regeneration flows.
## 2026-03-31 - 11.21.0 - feat(vpn)
add tag-aware WireGuard AllowedIPs for VPN-gated routes
- compute per-client WireGuard AllowedIPs from server-defined client tags and VPN-required proxy routes
- include the server public IP in AllowedIPs when a client can access VPN-gated domains so routed traffic reaches the proxy
- preserve and inject WireGuard private keys in generated and exported client configs for valid exports
## 2026-03-31 - 11.20.1 - fix(vpn-manager)
persist WireGuard private keys for valid client exports and QR codes
- Store each client's WireGuard private key when creating and rotating keys.
- Inject the stored private key into exported WireGuard configs so generated configs are complete and scannable.
## 2026-03-30 - 11.20.0 - feat(vpn-ui)
add QR code export for WireGuard client configurations
- adds a QR code action for newly created WireGuard configs in the VPN operations view
- adds a QR code export option for existing VPN clients alongside file downloads
- introduces qrcode and @types/qrcode dependencies and exposes the plugin for web UI use
## 2026-03-30 - 11.19.1 - fix(vpn)
configure SmartVPN client exports with explicit server endpoint and split-tunnel allowed IPs
- Pass the configured WireGuard server endpoint directly to SmartVPN instead of rewriting generated client configs in dcrouter.
- Set client allowed IPs to the VPN subnet so generated WireGuard configs default to split-tunnel routing.
- Update documentation to reflect SmartVPN startup, dashboard/API coverage, and the new split-tunnel behavior.
- Bump @push.rocks/smartvpn from 1.14.0 to 1.16.1 to support the updated VPN configuration flow.
## 2026-03-30 - 11.19.0 - feat(vpn)
document tag-based VPN access control, declarative clients, and destination policy options
- Adds documentation for restricting VPN-protected routes with allowedServerDefinedClientTags.
- Documents pre-defined VPN clients in configuration via vpnConfig.clients.
- Describes destinationPolicy behavior for forceTarget, allow, and block traffic handling.
- Updates interface docs to reflect serverDefinedClientTags and revised VPN server status fields.
## 2026-03-30 - 11.18.0 - feat(vpn-ui)
add format selection for VPN client config exports
- Show an export modal that lets operators choose between WireGuard (.conf) and SmartVPN (.json) client configs.
- Update VPN client row actions to read the selected item from actionData for toggle, export, rotate keys, and delete handlers.
## 2026-03-30 - 11.17.0 - feat(vpn)
expand VPN operations view with client management and config export actions
- adds predefined VPN clients to the dev server configuration for local testing
- adds table actions to create clients, export WireGuard configs, rotate client keys, toggle access, and delete clients
- updates the VPN view layout and stats grid binding to match the current component API
## 2026-03-30 - 11.16.0 - feat(vpn)
add destination-based VPN routing policy and standardize socket proxy forwarding
- replace configurable VPN forwarding mode with socket-based forwarding and always enable proxy protocol support to SmartProxy from localhost
- add destinationPolicy configuration for controlling default VPN traffic handling, including forceTarget, allow, and block rules
- remove forwarding mode reporting from VPN status APIs, logs, and ops UI to reflect the simplified VPN runtime model
- update @push.rocks/smartvpn to 1.14.0 to support the new VPN routing behavior
## 2026-03-30 - 11.15.0 - feat(vpn)
add tag-based VPN route access control and support configured initial VPN clients
- allow VPN-protected routes to restrict access to clients with matching server-defined tags instead of always permitting the full VPN subnet
- create configured VPN clients automatically on startup and re-apply routes when VPN clients change
- rename VPN client tag fields to serverDefinedClientTags across APIs, interfaces, handlers, and UI with legacy tag migration on load
- upgrade @push.rocks/smartvpn from 1.12.0 to 1.13.0
## 2026-03-30 - 11.14.0 - feat(docs)
document VPN access control and add OpsServer VPN navigation
- Adds comprehensive README documentation for VPN access control, configuration, operating modes, and client management
- Updates TypeScript interface documentation with VPN-related route, client, status, telemetry, and API request types
- Extends web dashboard documentation and router view list to include VPN management
## 2026-03-30 - 11.13.0 - feat(vpn)
add VPN server management and route-based VPN access control
- introduces a VPN manager backed by @push.rocks/smartvpn with persisted server keys and client registrations
- adds ops API handlers and typed request interfaces for VPN client lifecycle, status, config export, and telemetry
- adds ops dashboard VPN view and application state for managing VPN clients from the web UI
- supports vpn.required on routes by injecting VPN subnet allowlists into static and programmatic SmartProxy routes
- configures SmartProxy to accept proxy protocol in VPN socket forwarding mode to preserve client tunnel IPs
## 2026-03-27 - 11.12.4 - fix(acme)
use X509 certificate expiry when reporting ACME certificate validity
- Parse the actual X509 validTo value from the PEM public certificate and fall back to SmartAcme's stored expiry if parsing fails
- Update reported certificate expiry data and event communication timestamps to use the verified validity date
- Bump @push.rocks/smartacme to ^9.3.1 and @push.rocks/smartproxy to ^27.1.0
## 2026-03-27 - 11.12.3 - fix(dcrouter)
re-trigger auto certificate provisioning after SmartAcme becomes ready
- clear certificate provisioning scheduler state before retrying startup-affected routes
- use route updates to re-run certificate provisioning for all current auto-cert routes
- remove the unused single-route domain lookup helper
## 2026-03-27 - 11.12.2 - fix(dcrouter)
guard auto certificate reprovisioning against unnamed routes
- Only re-triggers certificate provisioning for auto-cert routes when a route name is present.
- Prevents reprovision attempts from running with an undefined route name and reduces warning noise.
## 2026-03-27 - 11.12.1 - fix(dcrouter)
retry auto certificate provisioning after SmartAcme becomes ready
- detects certificates that failed during startup before the DNS-01 provider was available
- clears provisioning backoff and failed status for affected domains before retrying
- re-triggers auto certificate provisioning for SmartProxy routes once SmartAcme is ready
## 2026-03-27 - 11.12.0 - feat(web-ui)
pause dashboard polling, sockets, and chart updates when the tab is hidden
- replace interval-based auto-refresh with scheduled actions using visibility-aware auto-pause
- disconnect and reconnect the TypedSocket on tab visibility changes to avoid background log buildup
- batch pushed log entries per animation frame and add an in-flight refresh guard to reduce unnecessary re-renders and overlapping requests
- update state subscriptions to use select() and document the new tab visibility optimization behavior
- bump smartdb, smartproxy, smartstate, remoteingress, dees-element, and tstest dependencies
## 2026-03-26 - 11.11.0 - feat(docker,cache,proxy)
improve container runtime defaults and add configurable connection limits
- replace the embedded cache backend integration from smartmongo LocalTsmDb to smartdb LocalSmartDb
- add OCI container settings for heap size, threadpool size, expanded exposed ports, image metadata, and a direct node startup command
- introduce startup checks for file descriptor limits and warn when container nofile limits are too low for production
- set gateway-oriented SmartProxy default limits and allow max connections, per-IP connections, and rate limits to be configured through OCI environment variables
## 2026-03-26 - 11.10.7 - fix(sms)
update sms service to use async ProjectInfo initialization
- Replace direct ProjectInfo construction with the async create() factory in the SMS service startup flow
- Bump related dependencies including @push.rocks/projectinfo, @push.rocks/smartdata, @push.rocks/smartmongo, @serve.zone/remoteingress, and @git.zone/tstest
## 2026-03-26 - 11.10.6 - fix(typescript)
tighten TypeScript null safety and error handling across backend and ops UI
- add explicit unknown error typing and safe message access in logging and handler code
- mark deferred-initialized class properties with definite assignment assertions to satisfy stricter TypeScript checks
- harden ops web state access and action return types with non-null assertions and explicit Promise state typing
- update storage reads to allow missing values and align license file references with the lowercase license filename
## 2026-03-26 - 11.10.5 - fix(build)
rename smart tooling config to .smartconfig.json and update package references
- Moves the shared tool configuration from npmextra.json to .smartconfig.json.
- Updates package.json published files and documentation to reference the new config file.
- Refreshes several development and runtime dependency versions alongside the config migration.
## 2026-03-24 - 11.10.4 - fix(monitoring)
handle multiple protocol cache entries per backend in metrics output
- Group detected protocol cache entries by backend host and port so multiple domain-specific records are preserved.
- Emit one backend metrics row per cached domain and avoid dropping unmatched protocol cache entries by tracking seen entries with a composite host:port:domain key.
- Use cached protocol values when available while keeping backend-only rows for metrics without protocol cache data.
## 2026-03-23 - 11.10.3 - fix(deps)
bump tstest, smartmetrics, and taskbuffer to latest patch releases
- update @git.zone/tstest from ^3.5.0 to ^3.5.1
- update @push.rocks/smartmetrics from ^3.0.2 to ^3.0.3
- update @push.rocks/taskbuffer from ^8.0.0 to ^8.0.2
## 2026-03-23 - 11.10.2 - fix(deps)
bump @api.global/typedserver to ^8.4.6 and @push.rocks/smartproxy to ^26.2.1
- Updates @api.global/typedserver from ^8.4.2 to ^8.4.6
- Updates @push.rocks/smartproxy from ^26.2.0 to ^26.2.1
## 2026-03-23 - 11.10.1 - fix(deps)
bump @push.rocks/smartproxy to ^26.2.0
- Updates the @push.rocks/smartproxy dependency from ^26.1.0 to ^26.2.0 in package.json.
## 2026-03-23 - 11.10.0 - feat(monitoring) ## 2026-03-23 - 11.10.0 - feat(monitoring)
add backend protocol metrics to network stats and ops dashboard add backend protocol metrics to network stats and ops dashboard

21
license Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "11.10.0", "version": "12.2.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -13,7 +13,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "(tstest test/ --logfile --timeout 60)", "test": "(tstest test/ --logfile --timeout 60)",
"start": "(node --max_old_space_size=250 ./cli.js)", "start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)", "startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)", "build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"build:docker": "tsdocker build --verbose", "build:docker": "tsdocker build --verbose",
@@ -22,49 +22,52 @@
"watch": "tswatch" "watch": "tswatch"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.3.0", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.9.1", "@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.5.0", "@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.0", "@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.0" "@types/node": "^25.5.0"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.3.0", "@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.2", "@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.48.5", "@design.estate/dees-catalog": "^3.49.1",
"@design.estate/dees-element": "^2.2.3", "@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0", "@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.3.0", "@push.rocks/smartacme": "^9.3.1",
"@push.rocks/smartdata": "^7.1.0", "@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdb": "^2.0.0",
"@push.rocks/smartdns": "^7.9.0", "@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1", "@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^3.0.2", "@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmta": "^5.3.1", "@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^26.1.0", "@push.rocks/smartproxy": "^27.1.0",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.2.0", "@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^8.0.0", "@push.rocks/smartvpn": "1.19.1",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0", "@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.14.1", "@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.4.0", "@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.2.7", "lru-cache": "^11.2.7",
"qrcode": "^1.5.4",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"keywords": [ "keywords": [
@@ -112,7 +115,7 @@
"dist_ts_apiclient/**/*", "dist_ts_apiclient/**/*",
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", ".smartconfig.json",
"readme.md" "readme.md"
] ]
} }

1838
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -133,7 +133,7 @@ The project now uses tswatch for development:
```bash ```bash
pnpm run watch pnpm run watch
``` ```
Configuration in `npmextra.json`: Configuration in `.smartconfig.json`:
```json ```json
{ {
"@git.zone/tswatch": { "@git.zone/tswatch": {

259
readme.md
View File

@@ -4,7 +4,7 @@
**dcrouter: The all-in-one gateway for your datacenter.** 🚀 **dcrouter: The all-in-one gateway for your datacenter.** 🚀
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure. A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -23,6 +23,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- [DNS Server](#dns-server) - [DNS Server](#dns-server)
- [RADIUS Server](#radius-server) - [RADIUS Server](#radius-server)
- [Remote Ingress](#remote-ingress) - [Remote Ingress](#remote-ingress)
- [VPN Access Control](#vpn-access-control)
- [Certificate Management](#certificate-management) - [Certificate Management](#certificate-management)
- [Storage & Caching](#storage--caching) - [Storage & Caching](#storage--caching)
- [Security Features](#security-features) - [Security Features](#security-features)
@@ -73,6 +74,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking - **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions - **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
- **Route-level VPN gating** — mark any route with `vpn: { enabled: true }` to restrict access to VPN clients only, or `vpn: { enabled: true, mandatory: false }` to add VPN clients alongside existing access rules
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
- **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup
- **Rootless operation** — uses userspace NAT (smoltcp) with no root required
- **Destination policy** — configurable `forceTarget`, `block`, or `allow` with allowList/blockList for granular traffic control
- **Client management** — create, enable, disable, rotate keys, export WireGuard/SmartVPN configs via OpsServer API and dashboard
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
- **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
### ⚡ High Performance ### ⚡ High Performance
- **Rust-powered proxy engine** via SmartProxy for maximum throughput - **Rust-powered proxy engine** via SmartProxy for maximum throughput
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery - **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
@@ -83,16 +95,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 💾 Persistent Storage & Caching ### 💾 Persistent Storage & Caching
- **Multiple storage backends**: filesystem, custom functions, or in-memory - **Multiple storage backends**: filesystem, custom functions, or in-memory
- **Embedded cache database** via smartdata + LocalTsmDb (MongoDB-compatible) - **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
- **Automatic TTL-based cleanup** for cached emails and IP reputation data - **Automatic TTL-based cleanup** for cached emails and IP reputation data
### 🖥️ OpsServer Dashboard ### 🖥️ OpsServer Dashboard
- **Web-based management interface** with real-time monitoring - **Web-based management interface** with real-time monitoring
- **JWT authentication** with session persistence - **JWT authentication** with session persistence
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events - **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning - **Domain-centric certificate overview** with backoff status and one-click reprovisioning
- **Remote ingress management** with connection token generation and one-click copy - **Remote ingress management** with connection token generation and one-click copy
- **Read-only configuration display** — DcRouter is configured through code - **Read-only configuration display** — DcRouter is configured through code
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
### 🔧 Programmatic API Client ### 🔧 Programmatic API Client
- **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods - **Object-oriented API** — resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) with instance methods
@@ -247,6 +260,15 @@ const router = new DcRouter({
hubDomain: 'hub.example.com', hubDomain: 'hub.example.com',
}, },
// VPN — restrict sensitive routes to VPN clients
vpnConfig: {
enabled: true,
serverEndpoint: 'vpn.example.com',
clients: [
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
],
},
// Persistent storage // Persistent storage
storage: { fsPath: '/var/lib/dcrouter/data' }, storage: { fsPath: '/var/lib/dcrouter/data' },
@@ -275,6 +297,7 @@ graph TB
DNS[DNS Queries] DNS[DNS Queries]
RAD[RADIUS Clients] RAD[RADIUS Clients]
EDGE[Edge Nodes] EDGE[Edge Nodes]
VPN[VPN Clients]
end end
subgraph "DcRouter Core" subgraph "DcRouter Core"
@@ -284,6 +307,7 @@ graph TB
DS[SmartDNS Server<br/><i>Rust-powered</i>] DS[SmartDNS Server<br/><i>Rust-powered</i>]
RS[SmartRadius Server] RS[SmartRadius Server]
RI[RemoteIngress Hub<br/><i>Rust data plane</i>] RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
VS[SmartVPN Server<br/><i>Rust data plane</i>]
CM[Certificate Manager<br/><i>smartacme v9</i>] CM[Certificate Manager<br/><i>smartacme v9</i>]
OS[OpsServer Dashboard] OS[OpsServer Dashboard]
MM[Metrics Manager] MM[Metrics Manager]
@@ -304,12 +328,14 @@ graph TB
DNS --> DS DNS --> DS
RAD --> RS RAD --> RS
EDGE --> RI EDGE --> RI
VPN --> VS
DC --> SP DC --> SP
DC --> ES DC --> ES
DC --> DS DC --> DS
DC --> RS DC --> RS
DC --> RI DC --> RI
DC --> VS
DC --> CM DC --> CM
DC --> OS DC --> OS
DC --> MM DC --> MM
@@ -340,14 +366,14 @@ graph TB
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management | | **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) | | **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) | | **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
| **CacheDb** | `@push.rocks/smartdata` | Embedded MongoDB-compatible database (LocalTsmDb) for persistent caching | | **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
### How It Works ### How It Works
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol: DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided. 1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and SmartVPN based on which configs are provided. Services start in dependency order via `ServiceManager`.
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting. 2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartVPN runs a Rust data plane for WireGuard and custom transports. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients. 3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
### Rust-Powered Architecture ### Rust-Powered Architecture
@@ -360,6 +386,7 @@ DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-c
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation | | **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution | | **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management | | **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
| **SmartVPN** | `smartvpn_daemon` | WireGuard (boringtun), Noise IK handshake, QUIC/WS transports, userspace NAT (smoltcp) |
| **SmartRadius** | — | Pure TypeScript (no Rust component) | | **SmartRadius** | — | Pure TypeScript (no Rust component) |
## Configuration Reference ## Configuration Reference
@@ -427,6 +454,27 @@ interface IDcRouterOptions {
}; };
}; };
// ── VPN ───────────────────────────────────────────────────────
/** VPN server for route-level access control */
vpnConfig?: {
enabled?: boolean; // default: false
subnet?: string; // default: '10.8.0.0/24'
wgListenPort?: number; // default: 51820
dns?: string[]; // DNS servers pushed to VPN clients
serverEndpoint?: string; // Hostname in generated client configs
clients?: Array<{ // Pre-defined VPN clients
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
destinationPolicy?: { // Traffic routing policy
default: 'forceTarget' | 'block' | 'allow';
target?: string; // IP for forceTarget (default: '127.0.0.1')
allowList?: string[]; // Pass through directly
blockList?: string[]; // Always block (overrides allowList)
};
};
// ── HTTP/3 (QUIC) ──────────────────────────────────────────── // ── HTTP/3 (QUIC) ────────────────────────────────────────────
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */ /** HTTP/3 config — enabled by default on qualifying HTTPS routes */
http3?: { http3?: {
@@ -974,6 +1022,129 @@ The OpsServer Remote Ingress view provides:
| **Copy Token** | Generate and copy a base64url connection token to clipboard | | **Copy Token** | Generate and copy a base64url connection token to clipboard |
| **Delete** | Remove the edge registration | | **Delete** | Remove the edge registration |
## VPN Access Control
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
### How It Works
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
3. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.enabled` are resolved at config generation time, so clients route only the necessary traffic through the tunnel
4. Routes with `vpn: { enabled: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change). With `mandatory: true` (default), the allowlist is replaced; with `mandatory: false`, VPN IPs are appended to existing rules
5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
### Destination Policy
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
```typescript
// Default: all traffic → SmartProxy
destinationPolicy: { default: 'forceTarget', target: '127.0.0.1' }
// Allow direct access to a backend subnet
destinationPolicy: {
default: 'forceTarget',
target: '127.0.0.1',
allowList: ['192.168.190.*'], // direct access to this subnet
blockList: ['192.168.190.1'], // except the gateway
}
// Block everything except specific IPs
destinationPolicy: {
default: 'block',
allowList: ['10.0.0.*', '192.168.1.*'],
}
```
### Configuration
```typescript
const router = new DcRouter({
vpnConfig: {
enabled: true,
subnet: '10.8.0.0/24', // VPN client IP pool (default)
wgListenPort: 51820, // WireGuard UDP port (default)
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
// Pre-define VPN clients with server-defined tags
clients: [
{ clientId: 'alice-laptop', serverDefinedClientTags: ['engineering'], description: 'Dev laptop' },
{ clientId: 'bob-phone', serverDefinedClientTags: ['engineering', 'mobile'] },
{ clientId: 'carol-desktop', serverDefinedClientTags: ['finance'] },
],
// Optional: customize destination policy (default: forceTarget → localhost)
// destinationPolicy: { default: 'forceTarget', target: '127.0.0.1', allowList: ['192.168.1.*'] },
},
smartProxyConfig: {
routes: [
// 🔐 VPN-only: any VPN client can access
{
name: 'internal-app',
match: { domains: ['internal.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.50', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
vpn: { enabled: true },
},
// 🔐 VPN + tag-restricted: only 'engineering' tagged clients
{
name: 'eng-dashboard',
match: { domains: ['eng.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.51', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
// → alice + bob can access, carol cannot
},
// 🌐 Public: no VPN
{
name: 'public-site',
match: { domains: ['example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 80 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
],
},
});
```
### Client Tags
SmartVPN distinguishes between two types of client tags:
| Tag Type | Set By | Purpose |
|----------|--------|---------|
| `serverDefinedClientTags` | Admin (via config or API) | **Trusted** — used for route access control |
| `clientDefinedClientTags` | Connecting client | **Informational** — displayed in dashboard, never used for security |
Routes with `allowedServerDefinedClientTags` only permit VPN clients whose admin-assigned tags match. Clients cannot influence their own server-defined tags.
### Client Management via OpsServer
The OpsServer dashboard and API provide full VPN client lifecycle management:
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
- **QR code** — scan with the WireGuard mobile app (iOS/Android) for instant setup
- **Enable / Disable** — toggle client access without deleting
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
- **Export config** — download in WireGuard (`.conf`), SmartVPN (`.json`), or scan as QR code
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
- **Delete** — remove a client and revoke access
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or by scanning the QR code — no custom VPN software needed.
## Certificate Management ## Certificate Management
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions: DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
@@ -1066,7 +1237,7 @@ Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, I
### Cache Database ### Cache Database
An embedded MongoDB-compatible database (via smartdata + LocalTsmDb) for persistent caching with automatic TTL cleanup: An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
```typescript ```typescript
cacheConfig: { cacheConfig: {
@@ -1148,8 +1319,12 @@ The OpsServer provides a web-based management interface served on port 3000 by d
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput | | 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics | | 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents | | 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
| 🛣️ **Routes** | Merged route list (hardcoded + programmatic), create/edit/toggle/override routes |
| 🔑 **API Tokens** | Token management with scopes, create/revoke/roll/toggle |
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export | | 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable | | 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
| 🔐 **VPN** | VPN client management, server status, create/toggle/export/rotate/delete clients |
| 📡 **RADIUS** | NAS client management, VLAN mappings, session monitoring, accounting |
| 📜 **Logs** | Real-time log viewer with level filtering and search | | 📜 **Logs** | Real-time log viewer with level filtering and search |
| ⚙️ **Configuration** | Read-only view of current system configuration | | ⚙️ **Configuration** | Read-only view of current system configuration |
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections | | 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
@@ -1214,6 +1389,17 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
'getRecentLogs' // Retrieve system logs with filtering 'getRecentLogs' // Retrieve system logs with filtering
'getLogStream' // Stream live logs 'getLogStream' // Stream live logs
// VPN
'getVpnClients' // List all registered VPN clients
'getVpnStatus' // VPN server status (running, subnet, port, keys)
'createVpnClient' // Create client → returns WireGuard config (shown once)
'deleteVpnClient' // Remove a VPN client
'enableVpnClient' // Enable a disabled client
'disableVpnClient' // Disable a client
'rotateVpnClientKey' // Generate new keys (invalidates old ones)
'exportVpnClientConfig' // Export WireGuard (.conf) or SmartVPN (.json) config
'getVpnClientTelemetry' // Per-client bytes sent/received, keepalives
// RADIUS // RADIUS
'getRadiusSessions' // Active RADIUS sessions 'getRadiusSessions' // Active RADIUS sessions
'getRadiusClients' // List NAS clients 'getRadiusClients' // List NAS clients
@@ -1331,6 +1517,7 @@ const router = new DcRouter(options: IDcRouterOptions);
| `radiusServer` | `RadiusServer` | RADIUS server instance | | `radiusServer` | `RadiusServer` | RADIUS server instance |
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager | | `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager | | `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
| `storageManager` | `StorageManager` | Storage backend | | `storageManager` | `StorageManager` | Storage backend |
| `opsServer` | `OpsServer` | OpsServer/dashboard instance | | `opsServer` | `OpsServer` | OpsServer/dashboard instance |
| `metricsManager` | `MetricsManager` | Metrics collector | | `metricsManager` | `MetricsManager` | Metrics collector |
@@ -1406,37 +1593,59 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
## Docker / OCI Container Deployment ## Docker / OCI Container Deployment
DcRouter ships with a `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file). DcRouter ships with a production-ready `Dockerfile` and supports environment-variable-driven configuration for OCI container deployments. The container image includes tini as PID 1 (via the base image), proper health checks, and configurable resource limits. When `DCROUTER_MODE=OCI_CONTAINER` is set, DcRouter automatically reads configuration from environment variables (and optionally from a JSON config file).
### Running with Docker ### Running with Docker
```bash ```bash
docker run -d \ docker run -d \
-e DCROUTER_MODE=OCI_CONTAINER \ --ulimit nofile=65536:65536 \
-e DCROUTER_TLS_EMAIL=admin@example.com \ -e DCROUTER_TLS_EMAIL=admin@example.com \
-e DCROUTER_PUBLIC_IP=203.0.113.1 \ -e DCROUTER_PUBLIC_IP=203.0.113.1 \
-e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \ -e DCROUTER_DNS_NS_DOMAINS=ns1.example.com,ns2.example.com \
-e DCROUTER_DNS_SCOPES=example.com \ -e DCROUTER_DNS_SCOPES=example.com \
-p 80:80 -p 443:443 -p 25:25 -p 53:53/udp -p 3000:3000 \ -p 80:80 -p 443:443 -p 25:25 -p 587:587 -p 465:465 \
-p 53:53/udp -p 3000:3000 -p 8443:8443 \
code.foss.global/serve.zone/dcrouter:latest code.foss.global/serve.zone/dcrouter:latest
``` ```
> ⚡ **Production tip:** Always set `--ulimit nofile=65536:65536` for production deployments. DcRouter will log a warning at startup if the file descriptor limit is below 65536.
### Environment Variables ### Environment Variables
| Variable | Description | Example | | Variable | Description | Default | Example |
|----------|-------------|---------| |----------|-------------|---------|---------|
| `DCROUTER_MODE` | Set to `OCI_CONTAINER` to enable container mode | `OCI_CONTAINER` | | `DCROUTER_MODE` | Container mode (set automatically in image) | `OCI_CONTAINER` | — |
| `DCROUTER_CONFIG_PATH` | Path to a JSON config file (loaded as base, env vars override) | `/config/dcrouter.json` | | `DCROUTER_CONFIG_PATH` | Path to JSON config file (env vars override) | — | `/config/dcrouter.json` |
| `DCROUTER_BASE_DIR` | Override base data directory | `/data/dcrouter` | | `DCROUTER_BASE_DIR` | Base data directory | `~/.serve.zone/dcrouter` | `/data/dcrouter` |
| `DCROUTER_TLS_EMAIL` | ACME contact email | `admin@example.com` | | `DCROUTER_TLS_EMAIL` | ACME contact email | — | `admin@example.com` |
| `DCROUTER_TLS_DOMAIN` | Primary TLS domain | `example.com` | | `DCROUTER_TLS_DOMAIN` | Primary TLS domain | — | `example.com` |
| `DCROUTER_PUBLIC_IP` | Public IP for DNS records | `203.0.113.1` | | `DCROUTER_PUBLIC_IP` | Public IP for DNS records | — | `203.0.113.1` |
| `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | `198.51.100.1,198.51.100.2` | | `DCROUTER_PROXY_IPS` | Comma-separated ingress proxy IPs | — | `198.51.100.1,198.51.100.2` |
| `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | `ns1.example.com,ns2.example.com` | | `DCROUTER_DNS_NS_DOMAINS` | Comma-separated nameserver domains | — | `ns1.example.com,ns2.example.com` |
| `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | `example.com,other.com` | | `DCROUTER_DNS_SCOPES` | Comma-separated authoritative domains | — | `example.com,other.com` |
| `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | `mail.example.com` | | `DCROUTER_EMAIL_HOSTNAME` | SMTP server hostname | — | `mail.example.com` |
| `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | `25,587,465` | | `DCROUTER_EMAIL_PORTS` | Comma-separated email ports | — | `25,587,465` |
| `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | | `DCROUTER_CACHE_ENABLED` | Enable/disable cache database | `true` | `false` |
| `DCROUTER_HEAP_SIZE` | Node.js V8 heap size in MB | `512` | `1024` |
| `DCROUTER_MAX_CONNECTIONS` | Global max concurrent connections | `50000` | `100000` |
| `DCROUTER_MAX_CONNECTIONS_PER_IP` | Max connections per source IP | `100` | `200` |
| `DCROUTER_CONNECTION_RATE_LIMIT` | Max new connections/min per IP | `600` | `1200` |
### Exposed Ports
The container exposes all service ports:
| Port(s) | Protocol | Service |
|---------|----------|---------|
| 80, 443 | TCP | HTTP/HTTPS (SmartProxy) |
| 25, 587, 465 | TCP | SMTP, Submission, SMTPS |
| 53 | TCP/UDP | DNS |
| 1812, 1813 | UDP | RADIUS auth/acct |
| 3000 | TCP | OpsServer dashboard |
| 8443 | TCP | Remote ingress tunnels |
| 51820 | UDP | WireGuard VPN |
| 2900030000 | TCP | Dynamic port range |
### Building the Image ### Building the Image
@@ -1449,7 +1658,7 @@ The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsd
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

84
readme.storage.md Normal file
View File

@@ -0,0 +1,84 @@
# DCRouter Storage Overview
DCRouter uses a **unified database layer** backed by `@push.rocks/smartdata` for all persistent data. All data is stored as typed document classes in a single database.
## Database Modes
### Embedded Mode (default)
When no external MongoDB URL is provided, DCRouter starts an embedded `LocalSmartDb` (Rust-based MongoDB-compatible engine) via `@push.rocks/smartdb`.
```
~/.serve.zone/dcrouter/tsmdb/
```
### External Mode
Connect to any MongoDB-compatible database by providing a connection URL.
```typescript
dbConfig: {
mongoDbUrl: 'mongodb://host:27017',
dbName: 'dcrouter',
}
```
## Configuration
```typescript
dbConfig: {
enabled: true, // default: true
mongoDbUrl: undefined, // default: embedded LocalSmartDb
storagePath: '~/.serve.zone/dcrouter/tsmdb', // default (embedded mode only)
dbName: 'dcrouter', // default
cleanupIntervalHours: 1, // TTL cleanup interval
}
```
## Document Classes
All data is stored as smartdata document classes in `ts/db/documents/`.
| Document Class | Collection | Unique Key | Purpose |
|---|---|---|---|
| `StoredRouteDoc` | storedRoutes | `id` | Programmatic routes (created via API) |
| `RouteOverrideDoc` | routeOverrides | `routeName` | Hardcoded route enable/disable overrides |
| `ApiTokenDoc` | apiTokens | `id` | API tokens (hashed secrets, scopes, expiry) |
| `VpnServerKeysDoc` | vpnServerKeys | `configId` (singleton) | VPN server Noise + WireGuard keypairs |
| `VpnClientDoc` | vpnClients | `clientId` | VPN client registrations |
| `AcmeCertDoc` | acmeCerts | `domainName` | ACME certificates and keys |
| `ProxyCertDoc` | proxyCerts | `domain` | SmartProxy TLS certificates |
| `CertBackoffDoc` | certBackoff | `domain` | Per-domain cert provision backoff state |
| `RemoteIngressEdgeDoc` | remoteIngressEdges | `id` | Edge node registrations |
| `VlanMappingsDoc` | vlanMappings | `configId` (singleton) | MAC-to-VLAN mapping table |
| `AccountingSessionDoc` | accountingSessions | `sessionId` | RADIUS accounting sessions |
| `CachedEmail` | cachedEmails | `id` | Email metadata (TTL: 30 days) |
| `CachedIPReputation` | cachedIPReputation | `ipAddress` | IP reputation results (TTL: 24 hours) |
## Architecture
```
DcRouterDb (singleton)
├── LocalSmartDb (embedded, Rust) ─── or ─── External MongoDB
└── SmartdataDb (ORM)
└── @Collection(() => getDb())
├── StoredRouteDoc
├── RouteOverrideDoc
├── ApiTokenDoc
├── VpnServerKeysDoc / VpnClientDoc
├── AcmeCertDoc / ProxyCertDoc / CertBackoffDoc
├── RemoteIngressEdgeDoc
├── VlanMappingsDoc / AccountingSessionDoc
├── CachedEmail (TTL)
└── CachedIPReputation (TTL)
```
### TTL Cleanup
`CacheCleaner` runs on a configurable interval (default: 1 hour) and removes expired documents where `expiresAt < now()`.
## Disabling
For tests or lightweight deployments without persistence:
```typescript
dbConfig: { enabled: false }
```

View File

@@ -130,7 +130,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
contactEmail: 'test@example.com' contactEmail: 'test@example.com'
}, },
opsServerPort: 3104, opsServerPort: 3104,
cacheConfig: { dbConfig: {
enabled: false, enabled: false,
} }
}; };

View File

@@ -10,7 +10,7 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
routes: [] routes: []
}, },
opsServerPort: 3100, opsServerPort: 3100,
cacheConfig: { enabled: false } dbConfig: { enabled: false }
}); });
await dcRouter.start(); await dcRouter.start();

View File

@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({ testDcRouter = new DcRouter({
// Minimal config for testing // Minimal config for testing
opsServerPort: 3102, opsServerPort: 3102,
cacheConfig: { enabled: false }, dbConfig: { enabled: false },
}); });
await testDcRouter.start(); await testDcRouter.start();

View File

@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({ testDcRouter = new DcRouter({
// Minimal config for testing // Minimal config for testing
opsServerPort: 3101, opsServerPort: 3101,
cacheConfig: { enabled: false }, dbConfig: { enabled: false },
}); });
await testDcRouter.start(); await testDcRouter.start();

View File

@@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({ testDcRouter = new DcRouter({
// Minimal config for testing // Minimal config for testing
opsServerPort: 3103, opsServerPort: 3103,
cacheConfig: { enabled: false }, dbConfig: { enabled: false },
}); });
await testDcRouter.start(); await testDcRouter.start();

View File

@@ -0,0 +1,371 @@
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 { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Helpers: access private maps for direct unit testing without DB
// ============================================================================
function injectProfile(resolver: ReferenceResolver, profile: ISecurityProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void {
(resolver as any).targets.set(target.id, target);
}
function makeProfile(overrides: Partial<ISecurityProfile> = {}): ISecurityProfile {
return {
id: 'profile-1',
name: 'STANDARD',
description: 'Test profile',
security: {
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8'],
maxConnections: 1000,
},
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'test',
...overrides,
};
}
function makeTarget(overrides: Partial<INetworkTarget> = {}): INetworkTarget {
return {
id: 'target-1',
name: 'INFRA',
description: 'Test target',
host: '192.168.5.247',
port: 443,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'test',
...overrides,
};
}
function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
return {
name: 'test-route',
match: { ports: 443, domains: 'test.example.com' },
action: { type: 'forward', targets: [{ host: 'placeholder', port: 80 }] },
...overrides,
} as IRouteConfig;
}
// ============================================================================
// Resolution tests
// ============================================================================
let resolver: ReferenceResolver;
tap.test('should create ReferenceResolver instance', async () => {
resolver = new ReferenceResolver();
expect(resolver).toBeTruthy();
});
tap.test('should list empty profiles and targets initially', async () => {
expect(resolver.listProfiles()).toBeArray();
expect(resolver.listProfiles().length).toEqual(0);
expect(resolver.listTargets()).toBeArray();
expect(resolver.listTargets().length).toEqual(0);
});
// ---- Security profile resolution ----
tap.test('should resolve security profile onto a route', async () => {
const profile = makeProfile();
injectProfile(resolver, profile);
const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeTruthy();
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.maxConnections).toEqual(1000);
expect(result.metadata.securityProfileName).toEqual('STANDARD');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should merge inline route security with profile security', async () => {
const route = makeRoute({
security: {
ipAllowList: ['127.0.0.1'],
maxConnections: 5000,
},
});
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
// IP lists are unioned
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
// Inline maxConnections overrides profile
expect(result.route.security!.maxConnections).toEqual(5000);
});
tap.test('should deduplicate IP lists during merge', async () => {
const route = makeRoute({
security: {
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
},
});
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
expect(count).toEqual(1);
});
tap.test('should handle missing profile gracefully', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' };
const result = resolver.resolveRoute(route, metadata);
// Route should be unchanged
expect(result.route.security).toBeUndefined();
expect(result.metadata.securityProfileName).toBeUndefined();
});
// ---- Profile inheritance ----
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
const baseProfile = makeProfile({
id: 'base-profile',
name: 'BASE',
security: {
ipAllowList: ['10.0.0.0/8'],
maxConnections: 500,
},
});
injectProfile(resolver, baseProfile);
const extendedProfile = makeProfile({
id: 'extended-profile',
name: 'EXTENDED',
security: {
ipAllowList: ['160.79.104.0/21'],
},
extendsProfiles: ['base-profile'],
});
injectProfile(resolver, extendedProfile);
const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'extended-profile' };
const result = resolver.resolveRoute(route, metadata);
// Should have IPs from both base and extended profiles
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
// maxConnections from base (extended doesn't override)
expect(result.route.security!.maxConnections).toEqual(500);
expect(result.metadata.securityProfileName).toEqual('EXTENDED');
});
tap.test('should detect circular profile inheritance', async () => {
const profileA = makeProfile({
id: 'circular-a',
name: 'A',
security: { ipAllowList: ['1.1.1.1'] },
extendsProfiles: ['circular-b'],
});
const profileB = makeProfile({
id: 'circular-b',
name: 'B',
security: { ipAllowList: ['2.2.2.2'] },
extendsProfiles: ['circular-a'],
});
injectProfile(resolver, profileA);
injectProfile(resolver, profileB);
const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'circular-a' };
// Should not infinite loop — resolves what it can
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeTruthy();
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
});
// ---- Network target resolution ----
tap.test('should resolve network target onto a route', async () => {
const target = makeTarget();
injectTarget(resolver, target);
const route = makeRoute();
const metadata: IRouteMetadata = { networkTargetRef: 'target-1' };
const result = resolver.resolveRoute(route, metadata);
expect(result.route.action.targets).toBeTruthy();
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
expect(result.route.action.targets![0].port).toEqual(443);
expect(result.metadata.networkTargetName).toEqual('INFRA');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should handle missing target gracefully', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
const result = resolver.resolveRoute(route, metadata);
// Route targets should be unchanged (still the placeholder)
expect(result.route.action.targets![0].host).toEqual('placeholder');
expect(result.metadata.networkTargetName).toBeUndefined();
});
// ---- Combined resolution ----
tap.test('should resolve both profile and target simultaneously', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
securityProfileRef: 'profile-1',
networkTargetRef: 'target-1',
};
const result = resolver.resolveRoute(route, metadata);
// Security from profile
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.maxConnections).toEqual(1000);
// Target from network target
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
expect(result.route.action.targets![0].port).toEqual(443);
// Both names recorded
expect(result.metadata.securityProfileName).toEqual('STANDARD');
expect(result.metadata.networkTargetName).toEqual('INFRA');
});
tap.test('should skip resolution when no metadata refs', async () => {
const route = makeRoute({
security: { ipAllowList: ['1.2.3.4'] },
});
const metadata: IRouteMetadata = {};
const result = resolver.resolveRoute(route, metadata);
// Route should be completely unchanged
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
expect(result.route.security!.ipAllowList!.length).toEqual(1);
expect(result.route.action.targets![0].host).toEqual('placeholder');
});
tap.test('should be idempotent — resolving twice gives same result', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
securityProfileRef: 'profile-1',
networkTargetRef: 'target-1',
};
const first = resolver.resolveRoute(route, metadata);
const second = resolver.resolveRoute(first.route, first.metadata);
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
});
// ---- Lookup helpers ----
tap.test('should find routes by profile ref (sync)', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-a', {
id: 'route-a',
route: makeRoute({ name: 'route-a' }),
enabled: true,
metadata: { securityProfileRef: 'profile-1' },
});
storedRoutes.set('route-b', {
id: 'route-b',
route: makeRoute({ name: 'route-b' }),
enabled: true,
metadata: { networkTargetRef: 'target-1' },
});
storedRoutes.set('route-c', {
id: 'route-c',
route: makeRoute({ name: 'route-c' }),
enabled: true,
metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' },
});
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
expect(profileRefs.length).toEqual(2);
expect(profileRefs).toContain('route-a');
expect(profileRefs).toContain('route-c');
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
expect(targetRefs.length).toEqual(2);
expect(targetRefs).toContain('route-b');
expect(targetRefs).toContain('route-c');
});
tap.test('should get profile usage for a specific profile ID', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-x', {
id: 'route-x',
route: makeRoute({ name: 'my-route' }),
enabled: true,
metadata: { securityProfileRef: 'profile-1' },
});
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
expect(usage.length).toEqual(1);
expect(usage[0].id).toEqual('route-x');
expect(usage[0].routeName).toEqual('my-route');
});
tap.test('should get target usage for a specific target ID', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-y', {
id: 'route-y',
route: makeRoute({ name: 'other-route' }),
enabled: true,
metadata: { networkTargetRef: 'target-1' },
});
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
expect(usage.length).toEqual(1);
expect(usage[0].id).toEqual('route-y');
expect(usage[0].routeName).toEqual('other-route');
});
// ---- Profile/target getters ----
tap.test('should get profile by name', async () => {
const profile = resolver.getProfileByName('STANDARD');
expect(profile).toBeTruthy();
expect(profile!.id).toEqual('profile-1');
});
tap.test('should get target by name', async () => {
const target = resolver.getTargetByName('INFRA');
expect(target).toBeTruthy();
expect(target!.id).toEqual('target-1');
});
tap.test('should return undefined for nonexistent profile name', async () => {
const profile = resolver.getProfileByName('NONEXISTENT');
expect(profile).toBeUndefined();
});
tap.test('should return undefined for nonexistent target name', async () => {
const target = resolver.getTargetByName('NONEXISTENT');
expect(target).toBeUndefined();
});
export default tap.start();

View File

@@ -0,0 +1,208 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
const TEST_PORT = 3200;
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
// ============================================================================
// Setup — db disabled, handlers return graceful fallbacks
// ============================================================================
tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({
opsServerPort: TEST_PORT,
dbConfig: { enabled: false },
});
await testDcRouter.start();
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
});
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
TEST_URL,
'adminLoginWithUsernameAndPassword'
);
const response = await loginRequest.fire({
username: 'admin',
password: 'admin',
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
});
// ============================================================================
// Security 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>(
TEST_URL,
'getSecurityProfiles'
);
const response = await req.fire({
identity: adminIdentity,
});
expect(response.profiles).toBeArray();
expect(response.profiles.length).toEqual(0);
});
tap.test('should return null for single profile when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfile>(
TEST_URL,
'getSecurityProfile'
);
const response = await req.fire({
identity: adminIdentity,
id: 'nonexistent',
});
expect(response.profile).toEqual(null);
});
tap.test('should return failure for create profile when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_CreateSecurityProfile>(
TEST_URL,
'createSecurityProfile'
);
const response = await req.fire({
identity: adminIdentity,
name: 'TEST',
security: { ipAllowList: ['*'] },
});
expect(response.success).toBeFalse();
expect(response.message).toBeTruthy();
});
tap.test('should return empty profile usage when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfileUsage>(
TEST_URL,
'getSecurityProfileUsage'
);
const response = await req.fire({
identity: adminIdentity,
id: 'nonexistent',
});
expect(response.routes).toBeArray();
expect(response.routes.length).toEqual(0);
});
// ============================================================================
// Network Target endpoints (graceful fallbacks when resolver unavailable)
// ============================================================================
tap.test('should return empty targets list when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
TEST_URL,
'getNetworkTargets'
);
const response = await req.fire({
identity: adminIdentity,
});
expect(response.targets).toBeArray();
expect(response.targets.length).toEqual(0);
});
tap.test('should return null for single target when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTarget>(
TEST_URL,
'getNetworkTarget'
);
const response = await req.fire({
identity: adminIdentity,
id: 'nonexistent',
});
expect(response.target).toEqual(null);
});
tap.test('should return failure for create target when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_CreateNetworkTarget>(
TEST_URL,
'createNetworkTarget'
);
const response = await req.fire({
identity: adminIdentity,
name: 'TEST',
host: '127.0.0.1',
port: 443,
});
expect(response.success).toBeFalse();
expect(response.message).toBeTruthy();
});
tap.test('should return empty target usage when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargetUsage>(
TEST_URL,
'getNetworkTargetUsage'
);
const response = await req.fire({
identity: adminIdentity,
id: 'nonexistent',
});
expect(response.routes).toBeArray();
expect(response.routes.length).toEqual(0);
});
// ============================================================================
// Auth rejection
// ============================================================================
tap.test('should reject unauthenticated profile requests', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
TEST_URL,
'getSecurityProfiles'
);
try {
await req.fire({} as any);
expect(true).toBeFalse();
} catch (error) {
expect(error).toBeTruthy();
}
});
tap.test('should reject unauthenticated target requests', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
TEST_URL,
'getNetworkTargets'
);
try {
await req.fire({} as any);
expect(true).toBeFalse();
} catch (error) {
expect(error).toBeTruthy();
}
});
// ============================================================================
// Cleanup
// ============================================================================
tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();

View File

@@ -1,289 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as paths from '../ts/paths.js';
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
import { promises as fs } from 'fs';
import * as path from 'path';
// Test data
const testData = {
string: 'Hello, World!',
json: { name: 'test', value: 42, nested: { data: true } },
largeString: 'x'.repeat(10000)
};
tap.test('Storage Manager - Memory Backend', async () => {
// Create StorageManager without config (defaults to memory)
const storage = new StorageManager();
// Test basic get/set
await storage.set('/test/key', testData.string);
const value = await storage.get('/test/key');
expect(value).toEqual(testData.string);
// Test JSON helpers
await storage.setJSON('/test/json', testData.json);
const jsonValue = await storage.getJSON('/test/json');
expect(jsonValue).toEqual(testData.json);
// Test exists
expect(await storage.exists('/test/key')).toEqual(true);
expect(await storage.exists('/nonexistent')).toEqual(false);
// Test delete
await storage.delete('/test/key');
expect(await storage.exists('/test/key')).toEqual(false);
// Test list
await storage.set('/items/1', 'one');
await storage.set('/items/2', 'two');
await storage.set('/other/3', 'three');
const items = await storage.list('/items');
expect(items.length).toEqual(2);
expect(items).toContain('/items/1');
expect(items).toContain('/items/2');
// Verify memory backend
expect(storage.getBackend()).toEqual('memory');
});
tap.test('Storage Manager - Filesystem Backend', async () => {
const testDir = path.join(paths.dataDir, '.test-storage');
// Clean up test directory if it exists
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {}
// Create StorageManager with filesystem path
const storage = new StorageManager({ fsPath: testDir });
// Test basic operations
await storage.set('/test/file', testData.string);
const value = await storage.get('/test/file');
expect(value).toEqual(testData.string);
// Verify file exists on disk
const filePath = path.join(testDir, 'test', 'file');
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
expect(fileExists).toEqual(true);
// Test atomic writes (temp file should not exist)
const tempPath = filePath + '.tmp';
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
expect(tempExists).toEqual(false);
// Test nested paths
await storage.set('/deeply/nested/path/to/file', testData.largeString);
const nestedValue = await storage.get('/deeply/nested/path/to/file');
expect(nestedValue).toEqual(testData.largeString);
// Test list with filesystem
await storage.set('/fs/items/a', 'alpha');
await storage.set('/fs/items/b', 'beta');
await storage.set('/fs/other/c', 'gamma');
// Filesystem backend now properly supports list
const fsItems = await storage.list('/fs/items');
expect(fsItems.length).toEqual(2); // Should find both items
// Clean up
await fs.rm(testDir, { recursive: true, force: true });
});
tap.test('Storage Manager - Custom Function Backend', async () => {
// Create in-memory storage for custom functions
const customStore = new Map<string, string>();
const storage = new StorageManager({
readFunction: async (key: string) => {
return customStore.get(key) || null;
},
writeFunction: async (key: string, value: string) => {
customStore.set(key, value);
}
});
// Test basic operations
await storage.set('/custom/key', testData.string);
expect(customStore.has('/custom/key')).toEqual(true);
const value = await storage.get('/custom/key');
expect(value).toEqual(testData.string);
// Test that delete sets empty value (as per implementation)
await storage.delete('/custom/key');
expect(customStore.get('/custom/key')).toEqual('');
// Verify custom backend (filesystem is implemented as custom backend internally)
expect(storage.getBackend()).toEqual('custom');
});
tap.test('Storage Manager - Key Validation', async () => {
const storage = new StorageManager();
// Test key normalization
await storage.set('test/key', 'value1'); // Missing leading slash
const value1 = await storage.get('/test/key');
expect(value1).toEqual('value1');
// Test dangerous path elements are removed
await storage.set('/test/../danger/key', 'value2');
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
expect(value2).toEqual('value2');
// Test multiple slashes are normalized
await storage.set('/test///multiple////slashes', 'value3');
const value3 = await storage.get('/test/multiple/slashes');
expect(value3).toEqual('value3');
// Test invalid keys throw errors
let emptyKeyError: Error | null = null;
try {
await storage.set('', 'value');
} catch (error) {
emptyKeyError = error as Error;
}
expect(emptyKeyError).toBeTruthy();
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
let nullKeyError: Error | null = null;
try {
await storage.set(null as any, 'value');
} catch (error) {
nullKeyError = error as Error;
}
expect(nullKeyError).toBeTruthy();
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
});
tap.test('Storage Manager - Concurrent Access', async () => {
const storage = new StorageManager();
const promises: Promise<void>[] = [];
// Simulate concurrent writes
for (let i = 0; i < 100; i++) {
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
}
await Promise.all(promises);
// Verify all writes succeeded
for (let i = 0; i < 100; i++) {
const value = await storage.get(`/concurrent/key${i}`);
expect(value).toEqual(`value${i}`);
}
// Test concurrent reads
const readPromises: Promise<string | null>[] = [];
for (let i = 0; i < 100; i++) {
readPromises.push(storage.get(`/concurrent/key${i}`));
}
const results = await Promise.all(readPromises);
for (let i = 0; i < 100; i++) {
expect(results[i]).toEqual(`value${i}`);
}
});
tap.test('Storage Manager - Backend Priority', async () => {
const testDir = path.join(paths.dataDir, '.test-storage-priority');
// Test that custom functions take priority over fsPath
let warningLogged = false;
const originalWarn = console.warn;
console.warn = (message: string) => {
if (message.includes('Using custom read/write functions')) {
warningLogged = true;
}
};
const storage = new StorageManager({
fsPath: testDir,
readFunction: async () => 'custom-value',
writeFunction: async () => {}
});
console.warn = originalWarn;
expect(warningLogged).toEqual(true);
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
// Clean up
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {}
});
tap.test('Storage Manager - Error Handling', async () => {
// Test filesystem errors
const storage = new StorageManager({
readFunction: async () => {
throw new Error('Read error');
},
writeFunction: async () => {
throw new Error('Write error');
}
});
// Read errors should return null
const value = await storage.get('/error/key');
expect(value).toEqual(null);
// Write errors should propagate
let writeError: Error | null = null;
try {
await storage.set('/error/key', 'value');
} catch (error) {
writeError = error as Error;
}
expect(writeError).toBeTruthy();
expect(writeError?.message).toEqual('Write error');
// Test JSON parse errors
const jsonStorage = new StorageManager({
readFunction: async () => 'invalid json',
writeFunction: async () => {}
});
// Test JSON parse errors
let jsonError: Error | null = null;
try {
await jsonStorage.getJSON('/invalid/json');
} catch (error) {
jsonError = error as Error;
}
expect(jsonError).toBeTruthy();
expect(jsonError?.message).toContain('JSON');
});
tap.test('Storage Manager - List Operations', async () => {
const storage = new StorageManager();
// Populate storage with hierarchical data
await storage.set('/app/config/database', 'db-config');
await storage.set('/app/config/cache', 'cache-config');
await storage.set('/app/data/users/1', 'user1');
await storage.set('/app/data/users/2', 'user2');
await storage.set('/app/logs/error.log', 'errors');
// List root
const rootItems = await storage.list('/');
expect(rootItems.length).toBeGreaterThanOrEqual(5);
// List specific paths
const configItems = await storage.list('/app/config');
expect(configItems.length).toEqual(2);
expect(configItems).toContain('/app/config/database');
expect(configItems).toContain('/app/config/cache');
const userItems = await storage.list('/app/data/users');
expect(userItems.length).toEqual(2);
// List non-existent path
const emptyList = await storage.list('/nonexistent/path');
expect(emptyList.length).toEqual(0);
});
export default tap.start();

View File

@@ -1,6 +1,8 @@
import { DcRouter } from '../ts/index.js'; import { DcRouter } from '../ts/index.js';
const devRouter = new DcRouter({ const devRouter = new DcRouter({
// Server public IP (used for VPN AllowedIPs)
publicIp: '203.0.113.1',
// SmartProxy routes for development/demo // SmartProxy routes for development/demo
smartProxyConfig: { smartProxyConfig: {
routes: [ routes: [
@@ -23,10 +25,32 @@ const devRouter = new DcRouter({
tls: { mode: 'passthrough' }, tls: { mode: 'passthrough' },
}, },
}, },
{
name: 'vpn-internal-app',
match: { ports: [18080], domains: ['internal.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
vpn: { enabled: 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'] },
},
] as any[],
},
// VPN with pre-defined clients
vpnConfig: {
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' },
], ],
}, },
// Disable cache/mongo for dev // Disable db/mongo for dev
cacheConfig: { enabled: false }, dbConfig: { enabled: false },
}); });
console.log('Starting DcRouter in development mode...'); console.log('Starting DcRouter in development mode...');

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.10.0', version: '12.2.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -1,155 +0,0 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { defaultTsmDbPath } from '../paths.js';
/**
* Configuration options for CacheDb
*/
export interface ICacheDbOptions {
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
storagePath?: string;
/** Database name (default: dcrouter) */
dbName?: string;
/** Enable debug logging */
debug?: boolean;
}
/**
* CacheDb - Wrapper around LocalTsmDb and smartdata
*
* Provides persistent caching using smartdata as the ORM layer
* and LocalTsmDb as the embedded database engine.
*/
export class CacheDb {
private static instance: CacheDb | null = null;
private localTsmDb: plugins.smartmongo.LocalTsmDb;
private smartdataDb: plugins.smartdata.SmartdataDb;
private options: Required<ICacheDbOptions>;
private isStarted: boolean = false;
constructor(options: ICacheDbOptions = {}) {
this.options = {
storagePath: options.storagePath || defaultTsmDbPath,
dbName: options.dbName || 'dcrouter',
debug: options.debug || false,
};
}
/**
* Get or create the singleton instance
*/
public static getInstance(options?: ICacheDbOptions): CacheDb {
if (!CacheDb.instance) {
CacheDb.instance = new CacheDb(options);
}
return CacheDb.instance;
}
/**
* Reset the singleton instance (useful for testing)
*/
public static resetInstance(): void {
CacheDb.instance = null;
}
/**
* Start the cache database
* - Initializes LocalTsmDb with file persistence
* - Connects smartdata to the LocalTsmDb via Unix socket
*/
public async start(): Promise<void> {
if (this.isStarted) {
logger.log('warn', 'CacheDb already started');
return;
}
try {
// Ensure storage directory exists
await plugins.fsUtils.ensureDir(this.options.storagePath);
// Create LocalTsmDb instance
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
folderPath: this.options.storagePath,
});
// Start LocalTsmDb and get connection info
const connectionInfo = await this.localTsmDb.start();
if (this.options.debug) {
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
}
// Initialize smartdata with the connection URI
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionInfo.connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init();
this.isStarted = true;
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
} catch (error) {
logger.log('error', `Failed to start CacheDb: ${error.message}`);
throw error;
}
}
/**
* Stop the cache database
*/
public async stop(): Promise<void> {
if (!this.isStarted) {
return;
}
try {
// Close smartdata connection
if (this.smartdataDb) {
await this.smartdataDb.close();
}
// Stop LocalTsmDb
if (this.localTsmDb) {
await this.localTsmDb.stop();
}
this.isStarted = false;
logger.log('info', 'CacheDb stopped');
} catch (error) {
logger.log('error', `Error stopping CacheDb: ${error.message}`);
throw error;
}
}
/**
* Get the smartdata database instance
*/
public getDb(): plugins.smartdata.SmartdataDb {
if (!this.isStarted) {
throw new Error('CacheDb not started. Call start() first.');
}
return this.smartdataDb;
}
/**
* Check if the database is ready
*/
public isReady(): boolean {
return this.isStarted;
}
/**
* Get the storage path
*/
public getStoragePath(): string {
return this.options.storagePath;
}
/**
* Get the database name
*/
public getDbName(): string {
return this.options.dbName;
}
}

View File

@@ -1,2 +0,0 @@
export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js';

View File

@@ -1,5 +1,5 @@
import { logger } from './logger.js'; import { logger } from './logger.js';
import type { StorageManager } from './storage/index.js'; import { CertBackoffDoc } from './db/index.js';
interface IBackoffEntry { interface IBackoffEntry {
failures: number; failures: number;
@@ -10,54 +10,68 @@ interface IBackoffEntry {
/** /**
* Manages certificate provisioning scheduling with: * Manages certificate provisioning scheduling with:
* - Per-domain exponential backoff persisted in StorageManager * - Per-domain exponential backoff persisted via CertBackoffDoc
* *
* Note: Serial stagger queue was removed — smartacme v9 handles * Note: Serial stagger queue was removed — smartacme v9 handles
* concurrency, per-domain dedup, and rate limiting internally. * concurrency, per-domain dedup, and rate limiting internally.
*/ */
export class CertProvisionScheduler { export class CertProvisionScheduler {
private storageManager: StorageManager;
private maxBackoffHours: number; private maxBackoffHours: number;
// In-memory backoff cache (mirrors storage for fast lookups) // In-memory backoff cache (mirrors storage for fast lookups)
private backoffCache = new Map<string, IBackoffEntry>(); private backoffCache = new Map<string, IBackoffEntry>();
constructor( constructor(
storageManager: StorageManager,
options?: { maxBackoffHours?: number } options?: { maxBackoffHours?: number }
) { ) {
this.storageManager = storageManager;
this.maxBackoffHours = options?.maxBackoffHours ?? 24; this.maxBackoffHours = options?.maxBackoffHours ?? 24;
} }
/** /**
* Storage key for a domain's backoff entry * Sanitized domain key for storage lookups
*/ */
private backoffKey(domain: string): string { private sanitizeDomain(domain: string): string {
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_'); return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
return `/cert-backoff/${clean}`;
} }
/** /**
* Load backoff entry from storage (with in-memory cache) * Load backoff entry from database (with in-memory cache)
*/ */
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> { private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
const cached = this.backoffCache.get(domain); const cached = this.backoffCache.get(domain);
if (cached) return cached; if (cached) return cached;
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain)); const sanitized = this.sanitizeDomain(domain);
if (entry) { const doc = await CertBackoffDoc.findByDomain(sanitized);
if (doc) {
const entry: IBackoffEntry = {
failures: doc.failures,
lastFailure: doc.lastFailure,
retryAfter: doc.retryAfter,
lastError: doc.lastError,
};
this.backoffCache.set(domain, entry); this.backoffCache.set(domain, entry);
return entry;
} }
return entry; return null;
} }
/** /**
* Save backoff entry to both cache and storage * Save backoff entry to both cache and database
*/ */
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> { private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
this.backoffCache.set(domain, entry); this.backoffCache.set(domain, entry);
await this.storageManager.setJSON(this.backoffKey(domain), entry); const sanitized = this.sanitizeDomain(domain);
let doc = await CertBackoffDoc.findByDomain(sanitized);
if (!doc) {
doc = new CertBackoffDoc();
doc.domain = sanitized;
}
doc.failures = entry.failures;
doc.lastFailure = entry.lastFailure;
doc.retryAfter = entry.retryAfter;
doc.lastError = entry.lastError || '';
await doc.save();
} }
/** /**
@@ -107,9 +121,13 @@ export class CertProvisionScheduler {
async clearBackoff(domain: string): Promise<void> { async clearBackoff(domain: string): Promise<void> {
this.backoffCache.delete(domain); this.backoffCache.delete(domain);
try { try {
await this.storageManager.delete(this.backoffKey(domain)); const sanitized = this.sanitizeDomain(domain);
const doc = await CertBackoffDoc.findByDomain(sanitized);
if (doc) {
await doc.delete();
}
} catch { } catch {
// Ignore delete errors (key may not exist) // Ignore delete errors (doc may not exist)
} }
} }

View File

@@ -11,18 +11,17 @@ import {
type IEmailDomainConfig, type IEmailDomainConfig,
} from '@push.rocks/smartmta'; } from '@push.rocks/smartmta';
import { logger } from './logger.js'; import { logger } from './logger.js';
// Import storage manager
import { StorageManager, type IStorageConfig } from './storage/index.js';
import { StorageBackedCertManager } from './classes.storage-cert-manager.js'; import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js'; import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
// Import cache system // Import unified database
import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js'; import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc } from './db/index.js';
import { OpsServer } from './opsserver/index.js'; import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js'; import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -121,37 +120,27 @@ export interface IDcRouterOptions {
/** Other DNS providers can be added here */ /** Other DNS providers can be added here */
}; };
/** Storage configuration */
storage?: IStorageConfig;
/** /**
* Cache database configuration using smartdata and LocalTsmDb * Unified database configuration.
* Provides persistent caching for emails, IP reputation, bounces, etc. * All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata.
* If mongoDbUrl is provided, connects to external MongoDB.
* Otherwise, starts an embedded LocalSmartDb automatically.
*/ */
cacheConfig?: { dbConfig?: {
/** Enable cache database (default: true) */ /** Enable database (default: true). Set to false in tests to skip DB startup. */
enabled?: boolean; enabled?: boolean;
/** Storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */ /** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */
mongoDbUrl?: string;
/** Storage path for embedded database data (default: ~/.serve.zone/dcrouter/tsmdb) */
storagePath?: string; storagePath?: string;
/** Database name (default: dcrouter) */ /** Database name (default: dcrouter) */
dbName?: string; dbName?: string;
/** Default TTL in days for cached items (default: 30) */ /** Cache cleanup interval in hours (default: 1) */
defaultTTLDays?: number;
/** Cleanup interval in hours (default: 1) */
cleanupIntervalHours?: number; cleanupIntervalHours?: number;
/** TTL configuration per data type (in days) */ /** Seed default security profiles and network targets when DB is empty on first startup. */
ttlConfig?: { seedOnEmpty?: boolean;
/** Email cache TTL (default: 30 days) */ /** Custom seed data for profiles and targets (overrides built-in defaults). */
emails?: number; seedData?: import('./config/classes.db-seeder.js').ISeedData;
/** IP reputation cache TTL (default: 1 day) */
ipReputation?: number;
/** Bounce records TTL (default: 30 days) */
bounces?: number;
/** DKIM keys TTL (default: 90 days) */
dkimKeys?: number;
/** Suppression list TTL (default: 30 days, can be permanent) */
suppression?: number;
};
}; };
/** /**
@@ -188,6 +177,50 @@ export interface IDcRouterOptions {
keyPath?: string; keyPath?: string;
}; };
}; };
/**
* VPN server configuration.
* Enables VPN-based access control: routes with vpn.enabled are only
* accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports.
*/
vpnConfig?: {
/** Enable VPN server (default: false) */
enabled?: boolean;
/** VPN subnet CIDR (default: '10.8.0.0/24') */
subnet?: string;
/** WireGuard UDP listen port (default: 51820) */
wgListenPort?: number;
/** DNS servers pushed to VPN clients */
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Pre-defined VPN clients created on startup */
clients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Destination routing policy for VPN client traffic.
* Default in socket mode: { default: 'forceTarget', target: '127.0.0.1' } (all traffic → SmartProxy).
* Default in tun mode: not set (all traffic passes through). */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
target?: string;
allowList?: string[];
blockList?: string[];
};
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
/** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */
bridgeLanSubnet?: string;
/** Physical network interface for bridge mode (auto-detected if omitted) */
bridgePhysicalInterface?: string;
/** Start of VPN client IP range in LAN subnet (host offset, default: 200) */
bridgeIpRangeStart?: number;
/** End of VPN client IP range in LAN subnet (host offset, default: 250) */
bridgeIpRangeEnd?: number;
};
} }
/** /**
@@ -214,21 +247,33 @@ export class DcRouter {
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer; public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
public emailServer?: UnifiedEmailServer; public emailServer?: UnifiedEmailServer;
public radiusServer?: RadiusServer; public radiusServer?: RadiusServer;
public storageManager: StorageManager; public opsServer!: OpsServer;
public opsServer: OpsServer;
public metricsManager?: MetricsManager; public metricsManager?: MetricsManager;
// Cache system (smartdata + LocalTsmDb) // Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set()
public cacheDb?: CacheDb; public storageManager: any = {
get: async (_key: string) => null,
set: async (_key: string, _value: string) => {
// DKIM keys from smartmta — logged but not yet migrated to smartdata
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
},
};
// Unified database (smartdata + LocalSmartDb or external MongoDB)
public dcRouterDb?: DcRouterDb;
public cacheCleaner?: CacheCleaner; public cacheCleaner?: CacheCleaner;
// Remote Ingress // Remote Ingress
public remoteIngressManager?: RemoteIngressManager; public remoteIngressManager?: RemoteIngressManager;
public tunnelManager?: TunnelManager; public tunnelManager?: TunnelManager;
// VPN
public vpnManager?: VpnManager;
// Programmatic config API // Programmatic config API
public routeConfigManager?: RouteConfigManager; public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager; public apiTokenManager?: ApiTokenManager;
public referenceResolver?: ReferenceResolver;
// Auto-discovered public IP (populated by generateAuthoritativeRecords) // Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null; public detectedPublicIp: string | null = null;
@@ -275,16 +320,6 @@ export class DcRouter {
// Resolve all data paths from baseDir // Resolve all data paths from baseDir
this.resolvedPaths = paths.resolvePaths(this.options.baseDir); this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
// Default storage to filesystem if not configured
if (!this.options.storage) {
this.options.storage = {
fsPath: this.resolvedPaths.defaultStoragePath,
};
}
// Initialize storage manager
this.storageManager = new StorageManager(this.options.storage);
// Initialize service manager and register all services // Initialize service manager and register all services
this.serviceManager = new plugins.taskbuffer.ServiceManager({ this.serviceManager = new plugins.taskbuffer.ServiceManager({
name: 'dcrouter', name: 'dcrouter',
@@ -313,23 +348,23 @@ export class DcRouter {
.withRetry({ maxRetries: 0 }), .withRetry({ maxRetries: 0 }),
); );
// CacheDb: optional, no dependencies // DcRouterDb: optional, no dependencies — unified database for all persistence
if (this.options.cacheConfig?.enabled !== false) { if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService( this.serviceManager.addService(
new plugins.taskbuffer.Service('CacheDb') new plugins.taskbuffer.Service('DcRouterDb')
.optional() .optional()
.withStart(async () => { .withStart(async () => {
await this.setupCacheDb(); await this.setupDcRouterDb();
}) })
.withStop(async () => { .withStop(async () => {
if (this.cacheCleaner) { if (this.cacheCleaner) {
this.cacheCleaner.stop(); this.cacheCleaner.stop();
this.cacheCleaner = undefined; this.cacheCleaner = undefined;
} }
if (this.cacheDb) { if (this.dcRouterDb) {
await this.cacheDb.stop(); await this.dcRouterDb.stop();
CacheDb.resetInstance(); DcRouterDb.resetInstance();
this.cacheDb = undefined; this.dcRouterDb = undefined;
} }
}) })
.withRetry({ maxRetries: 2, baseDelayMs: 1000, maxDelayMs: 5000 }), .withRetry({ maxRetries: 2, baseDelayMs: 1000, maxDelayMs: 5000 }),
@@ -354,10 +389,10 @@ export class DcRouter {
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }), .withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
); );
// SmartProxy: critical, depends on CacheDb (if enabled) // SmartProxy: critical, depends on DcRouterDb (if enabled)
const smartProxyDeps: string[] = []; const smartProxyDeps: string[] = [];
if (this.options.cacheConfig?.enabled !== false) { if (this.options.dbConfig?.enabled !== false) {
smartProxyDeps.push('CacheDb'); smartProxyDeps.push('DcRouterDb');
} }
this.serviceManager.addService( this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartProxy') new plugins.taskbuffer.Service('SmartProxy')
@@ -388,6 +423,23 @@ export class DcRouter {
await this.smartAcme.start(); await this.smartAcme.start();
this.smartAcmeReady = true; this.smartAcmeReady = true;
logger.log('info', 'SmartAcme DNS-01 provider is now ready'); logger.log('info', 'SmartAcme DNS-01 provider is now ready');
// Re-trigger certificate provisioning for all auto-cert routes.
// During startup, certProvisionFunction returned 'http01' (SmartAcme not ready),
// but Rust ACME is disabled when certProvisionFunction is set — so all domains
// 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.certProvisionScheduler) {
this.certProvisionScheduler.clear();
}
const currentRoutes = this.smartProxy.routeManager.getRoutes();
logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
});
}
} }
}) })
.withStop(async () => { .withStop(async () => {
@@ -401,28 +453,51 @@ export class DcRouter {
); );
} }
// ConfigManagers: optional, depends on SmartProxy // ConfigManagers: optional, depends on SmartProxy + DcRouterDb
this.serviceManager.addService( // Requires DcRouterDb to be enabled (document classes need the database)
new plugins.taskbuffer.Service('ConfigManagers') if (this.options.dbConfig?.enabled !== false) {
.optional() this.serviceManager.addService(
.dependsOn('SmartProxy') new plugins.taskbuffer.Service('ConfigManagers')
.withStart(async () => { .optional()
this.routeConfigManager = new RouteConfigManager( .dependsOn('SmartProxy', 'DcRouterDb')
this.storageManager, .withStart(async () => {
() => this.getConstructorRoutes(), // Initialize reference resolver first (profiles + targets)
() => this.smartProxy, this.referenceResolver = new ReferenceResolver();
() => this.options.http3, await this.referenceResolver.initialize();
);
this.apiTokenManager = new ApiTokenManager(this.storageManager); this.routeConfigManager = new RouteConfigManager(
await this.apiTokenManager.initialize(); () => this.getConstructorRoutes(),
await this.routeConfigManager.initialize(); () => this.smartProxy,
}) () => this.options.http3,
.withStop(async () => { this.options.vpnConfig?.enabled
this.routeConfigManager = undefined; ? (tags?: string[]) => {
this.apiTokenManager = undefined; if (tags?.length && this.vpnManager) {
}) return this.vpnManager.getClientIpsForServerDefinedTags(tags);
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }), }
); return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
}
: undefined,
this.referenceResolver,
);
this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize();
// Seed default profiles/targets if DB is empty and seeding is enabled
const seeder = new DbSeeder(this.referenceResolver);
await seeder.seedIfEmpty(
this.options.dbConfig?.seedOnEmpty,
this.options.dbConfig?.seedData,
);
})
.withStop(async () => {
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
this.referenceResolver = undefined;
})
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
);
}
// Email Server: optional, depends on SmartProxy // Email Server: optional, depends on SmartProxy
if (this.options.emailConfig) { if (this.options.emailConfig) {
@@ -448,7 +523,7 @@ export class DcRouter {
} }
// DNS Server: optional, depends on SmartProxy // DNS Server: optional, depends on SmartProxy
if (this.options.dnsNsDomains?.length > 0 && this.options.dnsScopes?.length > 0) { if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 && this.options.dnsScopes && this.options.dnsScopes.length > 0) {
this.serviceManager.addService( this.serviceManager.addService(
new plugins.taskbuffer.Service('DnsServer') new plugins.taskbuffer.Service('DnsServer')
.optional() .optional()
@@ -516,6 +591,25 @@ export class DcRouter {
); );
} }
// VPN Server: optional, depends on SmartProxy
if (this.options.vpnConfig?.enabled) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('VpnServer')
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
await this.setupVpnServer();
})
.withStop(async () => {
if (this.vpnManager) {
await this.vpnManager.stop();
this.vpnManager = undefined;
}
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
}
// Wire up aggregated events for logging // Wire up aggregated events for logging
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => { this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info'; const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info';
@@ -528,10 +622,36 @@ export class DcRouter {
} }
public async start() { public async start() {
await this.checkSystemLimits();
logger.log('info', 'Starting DcRouter Services'); logger.log('info', 'Starting DcRouter Services');
await this.serviceManager.start(); await this.serviceManager.start();
this.logStartupSummary(); this.logStartupSummary();
} }
/**
* Detect OS-level resource limits and warn if they are too low for production use.
* This is detection only — no attempts to raise limits.
*/
private async checkSystemLimits(): Promise<void> {
try {
const fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
const limitsContent = await fs.file('/proc/self/limits').encoding('utf8').read() as string;
const nofileLine = limitsContent.split('\n').find((line: string) => line.startsWith('Max open files'));
if (nofileLine) {
const parts = nofileLine.split(/\s{2,}/);
const softLimit = parseInt(parts[1], 10);
const hardLimit = parseInt(parts[2], 10);
if (softLimit < 65536) {
logger.log('warn', `File descriptor soft limit is ${softLimit} (hard: ${hardLimit}). ` +
`For production use, set --ulimit nofile=65536:65536 on the container runtime.`);
} else {
logger.log('info', `File descriptor limits: soft=${softLimit}, hard=${hardLimit}`);
}
}
} catch {
// Non-Linux or /proc not available — silently skip
}
}
/** /**
* Log comprehensive startup summary * Log comprehensive startup summary
@@ -573,6 +693,14 @@ export class DcRouter {
logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`); logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
} }
// VPN summary
if (this.vpnManager && this.options.vpnConfig?.enabled) {
const subnet = this.vpnManager.getSubnet();
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
const clientCount = this.vpnManager.listClients().length;
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
}
// Remote Ingress summary // Remote Ingress summary
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) { if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0; const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
@@ -580,14 +708,9 @@ export class DcRouter {
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`); logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
} }
// Storage summary // Database summary
if (this.storageManager && this.options.storage) { if (this.dcRouterDb) {
logger.log('info', `Storage: path=${this.options.storage.fsPath || 'default'}`); logger.log('info', `Database: ${this.dcRouterDb.isEmbedded() ? 'embedded' : 'external'}, db=${this.dcRouterDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.dbConfig?.cleanupIntervalHours || 1)}h interval)`);
}
// Cache database summary
if (this.cacheDb) {
logger.log('info', `Cache Database: storage=${this.cacheDb.getStoragePath()}, db=${this.cacheDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
} }
// Service status summary from ServiceManager // Service status summary from ServiceManager
@@ -608,31 +731,32 @@ export class DcRouter {
} }
/** /**
* Set up the cache database (smartdata + LocalTsmDb) * Set up the unified database (smartdata + LocalSmartDb or external MongoDB)
*/ */
private async setupCacheDb(): Promise<void> { private async setupDcRouterDb(): Promise<void> {
logger.log('info', 'Setting up CacheDb...'); logger.log('info', 'Setting up DcRouterDb...');
const cacheConfig = this.options.cacheConfig || {}; const dbConfig = this.options.dbConfig || {};
// Initialize CacheDb singleton // Initialize DcRouterDb singleton
this.cacheDb = CacheDb.getInstance({ this.dcRouterDb = DcRouterDb.getInstance({
storagePath: cacheConfig.storagePath || this.resolvedPaths.defaultTsmDbPath, mongoDbUrl: dbConfig.mongoDbUrl,
dbName: cacheConfig.dbName || 'dcrouter', storagePath: dbConfig.storagePath || this.resolvedPaths.defaultTsmDbPath,
dbName: dbConfig.dbName || 'dcrouter',
debug: false, debug: false,
}); });
await this.cacheDb.start(); await this.dcRouterDb.start();
// Start the cache cleaner // Start the cache cleaner for TTL-based document cleanup
const cleanupIntervalMs = (cacheConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000; const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
this.cacheCleaner = new CacheCleaner(this.cacheDb, { this.cacheCleaner = new CacheCleaner(this.dcRouterDb, {
intervalMs: cleanupIntervalMs, intervalMs: cleanupIntervalMs,
verbose: false, verbose: false,
}); });
this.cacheCleaner.start(); this.cacheCleaner.start();
logger.log('info', `CacheDb initialized at ${this.cacheDb.getStoragePath()}`); logger.log('info', `DcRouterDb ready (${this.dcRouterDb.isEmbedded() ? 'embedded' : 'external'})`);
} }
/** /**
@@ -698,7 +822,8 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
} }
// Cache constructor routes for RouteConfigManager // 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]; this.constructorRoutes = [...routes];
// If we have routes or need a basic SmartProxy instance, create it // If we have routes or need a basic SmartProxy instance, create it
@@ -708,21 +833,37 @@ export class DcRouter {
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start // Track cert entries loaded from cert store so we can populate certificateStatusMap after start
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = []; const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
// Create SmartProxy configuration // Create SmartProxy configuration with sensible gateway defaults.
// User's smartProxyConfig overrides these defaults via spread.
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = { const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
// --- dcrouter gateway defaults ---
maxConnectionsPerIP: 100,
connectionRateLimitPerMinute: 600,
socketTimeout: 120_000,
inactivityTimeout: 120_000,
keepAlive: true,
noDelay: true,
gracefulShutdownTimeout: 30_000,
// --- user overrides ---
...this.options.smartProxyConfig, ...this.options.smartProxyConfig,
// --- deep-merge defaults.security so user can override maxConnections ---
defaults: {
...this.options.smartProxyConfig?.defaults,
security: {
maxConnections: 50_000,
...this.options.smartProxyConfig?.defaults?.security,
},
},
// --- always set by dcrouter (after spread) ---
routes, routes,
acme: acmeConfig, acme: acmeConfig,
certStore: { certStore: {
loadAll: async () => { loadAll: async () => {
const keys = await this.storageManager.list('/proxy-certs/'); const docs = await ProxyCertDoc.findAll();
const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = []; const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = [];
for (const key of keys) { for (const doc of docs) {
const data = await this.storageManager.getJSON(key); certs.push({ domain: doc.domain, publicKey: doc.publicKey, privateKey: doc.privateKey, ca: doc.ca });
if (data) { loadedCertEntries.push({ domain: doc.domain, publicKey: doc.publicKey, validUntil: doc.validUntil, validFrom: doc.validFrom });
certs.push(data);
loadedCertEntries.push({ domain: data.domain, publicKey: data.publicKey, validUntil: data.validUntil, validFrom: data.validFrom });
}
} }
return certs; return certs;
}, },
@@ -734,18 +875,29 @@ export class DcRouter {
validUntil = new Date(x509.validTo).getTime(); validUntil = new Date(x509.validTo).getTime();
validFrom = new Date(x509.validFrom).getTime(); validFrom = new Date(x509.validFrom).getTime();
} catch { /* PEM parsing failed */ } } catch { /* PEM parsing failed */ }
await this.storageManager.setJSON(`/proxy-certs/${domain}`, { let doc = await ProxyCertDoc.findByDomain(domain);
domain, publicKey, privateKey, ca, validUntil, validFrom, if (!doc) {
}); doc = new ProxyCertDoc();
doc.domain = domain;
}
doc.publicKey = publicKey;
doc.privateKey = privateKey;
doc.ca = ca || '';
doc.validUntil = validUntil || 0;
doc.validFrom = validFrom || 0;
await doc.save();
}, },
remove: async (domain: string) => { remove: async (domain: string) => {
await this.storageManager.delete(`/proxy-certs/${domain}`); const doc = await ProxyCertDoc.findByDomain(domain);
if (doc) {
await doc.delete();
}
}, },
}, },
}; };
// Initialize cert provision scheduler // Initialize cert provision scheduler
this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager); this.certProvisionScheduler = new CertProvisionScheduler();
// If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction // If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction
// Note: SmartAcme.start() is NOT called here — it runs as a separate optional service // Note: SmartAcme.start() is NOT called here — it runs as a separate optional service
@@ -760,7 +912,7 @@ export class DcRouter {
} }
this.smartAcme = new plugins.smartacme.SmartAcme({ this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com', accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
certManager: new StorageBackedCertManager(this.storageManager), certManager: new StorageBackedCertManager(),
environment: 'production', environment: 'production',
challengeHandlers: challengeHandlers, challengeHandlers: challengeHandlers,
challengePriority: ['dns-01'], challengePriority: ['dns-01'],
@@ -787,17 +939,25 @@ export class DcRouter {
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`); eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
eventComms.setSource('smartacme-dns-01'); eventComms.setSource('smartacme-dns-01');
const isWildcardDomain = domain.startsWith('*.'); const isWildcardDomain = domain.startsWith('*.');
const cert = await this.smartAcme.getCertificateForDomain(domain, { const cert = await this.smartAcme!.getCertificateForDomain(domain, {
includeWildcard: !isWildcardDomain, includeWildcard: !isWildcardDomain,
}); });
if (cert.validUntil) { // Parse real X509 expiry from PEM (defense-in-depth over SmartAcme's estimate)
eventComms.setExpiryDate(new Date(cert.validUntil)); let realValidUntil = cert.validUntil;
if (cert.publicKey) {
try {
const x509 = new plugins.crypto.X509Certificate(cert.publicKey);
realValidUntil = new Date(x509.validTo).getTime();
} catch { /* fallback to SmartAcme's value */ }
}
if (realValidUntil) {
eventComms.setExpiryDate(new Date(realValidUntil));
} }
const result = { const result = {
id: cert.id, id: cert.id,
domainName: cert.domainName, domainName: cert.domainName,
created: cert.created, created: cert.created,
validUntil: cert.validUntil, validUntil: realValidUntil,
privateKey: cert.privateKey, privateKey: cert.privateKey,
publicKey: cert.publicKey, publicKey: cert.publicKey,
csr: cert.csr, csr: cert.csr,
@@ -806,10 +966,10 @@ export class DcRouter {
// Success — clear any backoff // Success — clear any backoff
await scheduler.clearBackoff(domain); await scheduler.clearBackoff(domain);
return result; return result;
} catch (err) { } catch (err: unknown) {
// Record failure for backoff tracking // Record failure for backoff tracking
await scheduler.recordFailure(domain, err.message); await scheduler.recordFailure(domain, (err as Error).message);
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`); eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`);
return 'http01'; return 'http01';
} }
}; };
@@ -822,6 +982,17 @@ export class DcRouter {
smartProxyConfig.proxyIPs = ['127.0.0.1']; smartProxyConfig.proxyIPs = ['127.0.0.1'];
} }
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
if (this.options.vpnConfig?.enabled) {
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
}
// Create SmartProxy instance // Create SmartProxy instance
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`); logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
@@ -883,16 +1054,16 @@ export class DcRouter {
issuedAt = new Date(entry.validFrom).toISOString(); issuedAt = new Date(entry.validFrom).toISOString();
} }
// Try SmartAcme /certs/ metadata as secondary source // Try SmartAcme AcmeCertDoc metadata as secondary source
if (!expiryDate) { if (!expiryDate) {
try { try {
const cleanDomain = entry.domain.replace(/^\*\.?/, ''); const cleanDomain = entry.domain.replace(/^\*\.?/, '');
const certMeta = await this.storageManager.getJSON(`/certs/${cleanDomain}`); const certDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (certMeta?.validUntil) { if (certDoc?.validUntil) {
expiryDate = new Date(certMeta.validUntil).toISOString(); expiryDate = new Date(certDoc.validUntil).toISOString();
} }
if (certMeta?.created && !issuedAt) { if (certDoc?.created && !issuedAt) {
issuedAt = new Date(certMeta.created).toISOString(); issuedAt = new Date(certDoc.created).toISOString();
} }
} catch { /* no metadata available */ } } catch { /* no metadata available */ }
} }
@@ -1085,23 +1256,6 @@ export class DcRouter {
return false; return false;
} }
/**
* Find the first route name that matches a given domain
*/
private findRouteNameForDomain(domain: string): string | undefined {
if (!this.smartProxy) return undefined;
for (const route of this.smartProxy.routeManager.getRoutes()) {
if (!route.match.domains || !route.name) continue;
const routeDomains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
for (const pattern of routeDomains) {
if (this.isDomainMatch(domain, pattern)) return route.name;
}
}
return undefined;
}
/** /**
* Find ALL route names that match a given domain * Find ALL route names that match a given domain
*/ */
@@ -1248,21 +1402,21 @@ export class DcRouter {
// Wire delivery events to MetricsManager and logger // Wire delivery events to MetricsManager and logger
if (this.metricsManager && this.emailServer.deliverySystem) { if (this.metricsManager && this.emailServer.deliverySystem) {
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => { this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
this.metricsManager.trackEmailReceived(item?.from); this.metricsManager!.trackEmailReceived(item?.from);
logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' }); logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' });
}); });
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => { this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
this.metricsManager.trackEmailSent(item?.to); this.metricsManager!.trackEmailSent(item?.to);
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' }); logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
}); });
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => { this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
this.metricsManager.trackEmailFailed(item?.to, error?.message); this.metricsManager!.trackEmailFailed(item?.to, error?.message);
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' }); logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
}); });
} }
if (this.metricsManager && this.emailServer) { if (this.metricsManager && this.emailServer) {
this.emailServer.on('bounceProcessed', () => { this.emailServer.on('bounceProcessed', () => {
this.metricsManager.trackEmailBounced(); this.metricsManager!.trackEmailBounced();
logger.log('warn', 'Email bounce processed', { zone: 'email' }); logger.log('warn', 'Email bounce processed', { zone: 'email' });
}); });
} }
@@ -1305,12 +1459,12 @@ export class DcRouter {
} }
logger.log('info', 'All unified email components stopped'); logger.log('info', 'All unified email components stopped');
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error stopping unified email components: ${error.message}`); logger.log('error', `Error stopping unified email components: ${(error as Error).message}`);
throw error; throw error;
} }
} }
/** /**
* Update domain rules for email routing * Update domain rules for email routing
* @param rules New domain rules to apply * @param rules New domain rules to apply
@@ -1468,7 +1622,7 @@ export class DcRouter {
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => { this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
// Metrics tracking // Metrics tracking
for (const question of event.questions) { for (const question of event.questions) {
this.metricsManager.trackDnsQuery( this.metricsManager?.trackDnsQuery(
question.type, question.type,
question.name, question.name,
false, false,
@@ -1553,8 +1707,8 @@ export class DcRouter {
// Use the built-in socket handler from smartdns // Use the built-in socket handler from smartdns
// This handles HTTP/2, DoH protocol, etc. // This handles HTTP/2, DoH protocol, etc.
await (this.dnsServer as any).handleHttpsSocket(socket); await (this.dnsServer as any).handleHttpsSocket(socket);
} catch (error) { } catch (error: unknown) {
logger.log('error', `DNS socket handler error: ${error.message}`); logger.log('error', `DNS socket handler error: ${(error as Error).message}`);
if (!socket.destroyed) { if (!socket.destroyed) {
socket.destroy(); socket.destroy();
} }
@@ -1695,14 +1849,14 @@ export class DcRouter {
} else { } else {
logger.log('warn', `Invalid DKIM record structure in ${file}`); logger.log('warn', `Invalid DKIM record structure in ${file}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to load DKIM record from ${file}: ${error.message}`); logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
} }
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to load DKIM records: ${error.message}`); logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
} }
return records; return records;
} }
@@ -1734,11 +1888,11 @@ export class DcRouter {
// This ensures keys are ready even if DNS mode changes later // This ensures keys are ready even if DNS mode changes later
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain); await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`); logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${error.message}`); logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
} }
} }
logger.log('info', 'DKIM initialization complete'); logger.log('info', 'DKIM initialization complete');
} }
@@ -1779,10 +1933,10 @@ export class DcRouter {
} else { } else {
logger.log('warn', 'Could not auto-discover public IPv4 address'); logger.log('warn', 'Could not auto-discover public IPv4 address');
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to auto-discover public IP: ${error.message}`); logger.log('error', `Failed to auto-discover public IP: ${(error as Error).message}`);
} }
if (!publicIp) { if (!publicIp) {
logger.log('warn', 'No public IP available. Nameserver A records require either proxyIps, publicIp, or successful auto-discovery.'); logger.log('warn', 'No public IP available. Nameserver A records require either proxyIps, publicIp, or successful auto-discovery.');
} }
@@ -1876,8 +2030,8 @@ export class DcRouter {
} }
return null; return null;
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Failed to detect public IP: ${error.message}`); logger.log('warn', `Failed to detect public IP: ${(error as Error).message}`);
return null; return null;
} }
} }
@@ -1893,7 +2047,7 @@ export class DcRouter {
logger.log('info', 'Setting up Remote Ingress hub...'); logger.log('info', 'Setting up Remote Ingress hub...');
// Initialize the edge registration manager // Initialize the edge registration manager
this.remoteIngressManager = new RemoteIngressManager(this.storageManager); this.remoteIngressManager = new RemoteIngressManager();
await this.remoteIngressManager.initialize(); await this.remoteIngressManager.initialize();
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes // Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
@@ -1911,15 +2065,15 @@ export class DcRouter {
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8'); const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
tlsConfig = { certPem, keyPem }; tlsConfig = { certPem, keyPem };
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel'); logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
} catch (err) { } catch (err: unknown) {
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${err.message}`); logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${(err as Error).message}`);
} }
} }
// Priority 2: Existing cert from SmartProxy cert store for hubDomain // Priority 2: Existing cert from SmartProxy cert store for hubDomain
if (!tlsConfig && riCfg.hubDomain) { if (!tlsConfig && riCfg.hubDomain) {
try { try {
const stored = await this.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`); const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
if (stored?.publicKey && stored?.privateKey) { if (stored?.publicKey && stored?.privateKey) {
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey }; tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`); logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
@@ -1943,6 +2097,101 @@ export class DcRouter {
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`); logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
} }
/**
* Set up VPN server for VPN-based route access control.
*/
private async setupVpnServer(): Promise<void> {
if (!this.options.vpnConfig?.enabled) {
return;
}
logger.log('info', 'Setting up VPN server...');
this.vpnManager = new VpnManager({
subnet: this.options.vpnConfig.subnet,
wgListenPort: this.options.vpnConfig.wgListenPort,
dns: this.options.vpnConfig.dns,
serverEndpoint: this.options.vpnConfig.serverEndpoint,
initialClients: this.options.vpnConfig.clients,
destinationPolicy: this.options.vpnConfig.destinationPolicy,
forwardingMode: this.options.vpnConfig.forwardingMode,
bridgeLanSubnet: this.options.vpnConfig.bridgeLanSubnet,
bridgePhysicalInterface: this.options.vpnConfig.bridgePhysicalInterface,
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
onClientChanged: () => {
// Re-apply routes so tag-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();
},
getClientAllowedIPs: async (clientTags: 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;
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(/^\*\./, ''));
}
}
}
}
// Resolve DNS A records for matched domains (with caching)
for (const domain of domainsToResolve) {
const resolvedIps = await this.resolveVpnDomainIPs(domain);
for (const ip of resolvedIps) {
ips.add(`${ip}/32`);
}
}
return [...ips];
},
});
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();
}
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
/**
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
*/
private async resolveVpnDomainIPs(domain: string): Promise<string[]> {
const cached = this.vpnDomainIpCache.get(domain);
if (cached && cached.expiresAt > Date.now()) {
return cached.ips;
}
try {
const { promises: dnsPromises } = await import('dns');
const ips = await dnsPromises.resolve4(domain);
this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
return ips;
} catch (err) {
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
return cached?.ips || []; // Return stale cache on failure, or empty
}
}
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
// via the getVpnAllowList callback — no longer a separate method here.
/** /**
* Set up RADIUS server for network authentication * Set up RADIUS server for network authentication
*/ */
@@ -1953,7 +2202,7 @@ export class DcRouter {
logger.log('info', 'Setting up RADIUS server...'); logger.log('info', 'Setting up RADIUS server...');
this.radiusServer = new RadiusServer(this.options.radiusConfig, this.storageManager); this.radiusServer = new RadiusServer(this.options.radiusConfig);
await this.radiusServer.start(); await this.radiusServer.start();
logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`); logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`);

View File

@@ -1,46 +1,58 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { StorageManager } from './storage/index.js'; import { AcmeCertDoc } from './db/index.js';
/** /**
* ICertManager implementation backed by StorageManager. * ICertManager implementation backed by smartdata document classes.
* Persists SmartAcme certificates under a /certs/ key prefix so they * Persists SmartAcme certificates via AcmeCertDoc so they
* survive process restarts without re-hitting ACME. * survive process restarts without re-hitting ACME.
*/ */
export class StorageBackedCertManager implements plugins.smartacme.ICertManager { export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
private keyPrefix = '/certs/'; constructor() {}
constructor(private storageManager: StorageManager) {}
async init(): Promise<void> {} async init(): Promise<void> {}
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> { async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
const data = await this.storageManager.getJSON(this.keyPrefix + domainName); const doc = await AcmeCertDoc.findByDomain(domainName);
if (!data) return null; if (!doc) return null;
return new plugins.smartacme.Cert(data); return new plugins.smartacme.Cert({
} id: doc.id,
domainName: doc.domainName,
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> { created: doc.created,
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, { privateKey: doc.privateKey,
id: cert.id, publicKey: doc.publicKey,
domainName: cert.domainName, csr: doc.csr,
created: cert.created, validUntil: doc.validUntil,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr,
validUntil: cert.validUntil,
}); });
} }
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
if (!doc) {
doc = new AcmeCertDoc();
doc.domainName = cert.domainName;
}
doc.id = cert.id;
doc.created = cert.created;
doc.privateKey = cert.privateKey;
doc.publicKey = cert.publicKey;
doc.csr = cert.csr;
doc.validUntil = cert.validUntil;
await doc.save();
}
async deleteCertificate(domainName: string): Promise<void> { async deleteCertificate(domainName: string): Promise<void> {
await this.storageManager.delete(this.keyPrefix + domainName); const doc = await AcmeCertDoc.findByDomain(domainName);
if (doc) {
await doc.delete();
}
} }
async close(): Promise<void> {} async close(): Promise<void> {}
async wipe(): Promise<void> { async wipe(): Promise<void> {
const keys = await this.storageManager.list(this.keyPrefix); const docs = await AcmeCertDoc.findAll();
for (const key of keys) { for (const doc of docs) {
await this.storageManager.delete(key); await doc.delete();
} }
} }
} }

View File

@@ -1,19 +1,18 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js'; import { ApiTokenDoc } from '../db/index.js';
import type { import type {
IStoredApiToken, IStoredApiToken,
IApiTokenInfo, IApiTokenInfo,
TApiTokenScope, TApiTokenScope,
} from '../../ts_interfaces/data/route-management.js'; } from '../../ts_interfaces/data/route-management.js';
const TOKENS_PREFIX = '/config-api/tokens/';
const TOKEN_PREFIX_STR = 'dcr_'; const TOKEN_PREFIX_STR = 'dcr_';
export class ApiTokenManager { export class ApiTokenManager {
private tokens = new Map<string, IStoredApiToken>(); private tokens = new Map<string, IStoredApiToken>();
constructor(private storageManager: StorageManager) {} constructor() {}
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
await this.loadTokens(); await this.loadTokens();
@@ -117,7 +116,8 @@ export class ApiTokenManager {
if (!this.tokens.has(id)) return false; if (!this.tokens.has(id)) return false;
const token = this.tokens.get(id)!; const token = this.tokens.get(id)!;
this.tokens.delete(id); this.tokens.delete(id);
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`); const doc = await ApiTokenDoc.findById(id);
if (doc) await doc.delete();
logger.log('info', `API token '${token.name}' revoked (id: ${id})`); logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
return true; return true;
} }
@@ -157,17 +157,48 @@ export class ApiTokenManager {
// ========================================================================= // =========================================================================
private async loadTokens(): Promise<void> { private async loadTokens(): Promise<void> {
const keys = await this.storageManager.list(TOKENS_PREFIX); const docs = await ApiTokenDoc.findAll();
for (const key of keys) { for (const doc of docs) {
if (!key.endsWith('.json')) continue; if (doc.id) {
const stored = await this.storageManager.getJSON<IStoredApiToken>(key); this.tokens.set(doc.id, {
if (stored?.id) { id: doc.id,
this.tokens.set(stored.id, stored); name: doc.name,
tokenHash: doc.tokenHash,
scopes: doc.scopes,
createdAt: doc.createdAt,
expiresAt: doc.expiresAt,
lastUsedAt: doc.lastUsedAt,
createdBy: doc.createdBy,
enabled: doc.enabled,
});
} }
} }
} }
private async persistToken(stored: IStoredApiToken): Promise<void> { private async persistToken(stored: IStoredApiToken): Promise<void> {
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored); const existing = await ApiTokenDoc.findById(stored.id);
if (existing) {
existing.name = stored.name;
existing.tokenHash = stored.tokenHash;
existing.scopes = stored.scopes;
existing.createdAt = stored.createdAt;
existing.expiresAt = stored.expiresAt;
existing.lastUsedAt = stored.lastUsedAt;
existing.createdBy = stored.createdBy;
existing.enabled = stored.enabled;
await existing.save();
} else {
const doc = new ApiTokenDoc();
doc.id = stored.id;
doc.name = stored.name;
doc.tokenHash = stored.tokenHash;
doc.scopes = stored.scopes;
doc.createdAt = stored.createdAt;
doc.expiresAt = stored.expiresAt;
doc.lastUsedAt = stored.lastUsedAt;
doc.createdBy = stored.createdBy;
doc.enabled = stored.enabled;
await doc.save();
}
} }
} }

View File

@@ -0,0 +1,95 @@
import { logger } from '../logger.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
import type { IRouteSecurity } from '../../ts_interfaces/data/route-management.js';
export interface ISeedData {
profiles?: Array<{
name: string;
description?: string;
security: IRouteSecurity;
extendsProfiles?: string[];
}>;
targets?: Array<{
name: string;
description?: string;
host: string | string[];
port: number;
}>;
}
export class DbSeeder {
constructor(private referenceResolver: ReferenceResolver) {}
/**
* Check if DB is empty and seed if configured.
* Called once during ConfigManagers service startup, after initialize().
*/
public async seedIfEmpty(
seedOnEmpty?: boolean,
seedData?: ISeedData,
): Promise<void> {
if (!seedOnEmpty) return;
const existingProfiles = this.referenceResolver.listProfiles();
const existingTargets = this.referenceResolver.listTargets();
if (existingProfiles.length > 0 || existingTargets.length > 0) {
logger.log('info', 'DB already contains profiles/targets, skipping seed');
return;
}
logger.log('info', 'Seeding database with initial profiles and targets...');
const profilesToSeed: NonNullable<ISeedData['profiles']> = seedData?.profiles ?? DEFAULT_PROFILES;
const targetsToSeed: NonNullable<ISeedData['targets']> = seedData?.targets ?? DEFAULT_TARGETS;
for (const p of profilesToSeed) {
await this.referenceResolver.createProfile({
name: p.name,
description: p.description,
security: p.security,
extendsProfiles: p.extendsProfiles,
createdBy: 'system-seed',
});
}
for (const t of targetsToSeed) {
await this.referenceResolver.createTarget({
name: t.name,
description: t.description,
host: t.host,
port: t.port,
createdBy: 'system-seed',
});
}
logger.log('info', `Seeded ${profilesToSeed.length} profile(s) and ${targetsToSeed.length} target(s)`);
}
}
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
{
name: 'PUBLIC',
description: 'Allow all traffic — no IP restrictions',
security: {
ipAllowList: ['*'],
},
},
{
name: 'STANDARD',
description: 'Standard internal access with common private subnets',
security: {
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8', '127.0.0.1', '::1'],
maxConnections: 1000,
},
},
];
const DEFAULT_TARGETS: Array<NonNullable<ISeedData['targets']>[number]> = [
{
name: 'LOCALHOST',
description: 'Local machine on port 443',
host: '127.0.0.1',
port: 443,
},
];

View File

@@ -0,0 +1,576 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
import type {
ISecurityProfile,
INetworkTarget,
IRouteMetadata,
IStoredRoute,
IRouteSecurity,
} from '../../ts_interfaces/data/route-management.js';
const MAX_INHERITANCE_DEPTH = 5;
export class ReferenceResolver {
private profiles = new Map<string, ISecurityProfile>();
private targets = new Map<string, INetworkTarget>();
// =========================================================================
// Lifecycle
// =========================================================================
public async initialize(): Promise<void> {
await this.loadProfiles();
await this.loadTargets();
}
// =========================================================================
// Profile CRUD
// =========================================================================
public async createProfile(data: {
name: string;
description?: string;
security: IRouteSecurity;
extendsProfiles?: string[];
createdBy: string;
}): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
const profile: ISecurityProfile = {
id,
name: data.name,
description: data.description,
security: data.security,
extendsProfiles: data.extendsProfiles,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
};
this.profiles.set(id, profile);
await this.persistProfile(profile);
logger.log('info', `Created security profile '${profile.name}' (${id})`);
return id;
}
public async updateProfile(
id: string,
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<{ affectedRouteIds: string[] }> {
const profile = this.profiles.get(id);
if (!profile) {
throw new Error(`Security profile '${id}' not found`);
}
if (patch.name !== undefined) profile.name = patch.name;
if (patch.description !== undefined) profile.description = patch.description;
if (patch.security !== undefined) profile.security = patch.security;
if (patch.extendsProfiles !== undefined) profile.extendsProfiles = patch.extendsProfiles;
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Updated security profile '${profile.name}' (${id})`);
// Find routes referencing this profile
const affectedRouteIds = await this.findRoutesByProfileRef(id);
return { affectedRouteIds };
}
public async deleteProfile(
id: string,
force: boolean,
storedRoutes?: Map<string, IStoredRoute>,
): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id);
if (!profile) {
return { success: false, message: `Security profile '${id}' not found` };
}
// Check usage
const affectedIds = storedRoutes
? this.findRoutesByProfileRefSync(id, storedRoutes)
: await this.findRoutesByProfileRef(id);
if (affectedIds.length > 0 && !force) {
return {
success: false,
message: `Profile '${profile.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
};
}
// Delete from DB
const doc = await SecurityProfileDoc.findById(id);
if (doc) await doc.delete();
this.profiles.delete(id);
// If force-deleting with referencing routes, clear refs but keep resolved values
if (affectedIds.length > 0) {
await this.clearProfileRefsOnRoutes(affectedIds);
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted security profile '${profile.name}' (${id})`);
}
return { success: true };
}
public getProfile(id: string): ISecurityProfile | undefined {
return this.profiles.get(id);
}
public getProfileByName(name: string): ISecurityProfile | undefined {
for (const profile of this.profiles.values()) {
if (profile.name === name) return profile;
}
return undefined;
}
public listProfiles(): ISecurityProfile[] {
return [...this.profiles.values()];
}
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) {
usage.set(profile.id, []);
}
for (const [routeId, stored] of storedRoutes) {
const ref = stored.metadata?.securityProfileRef;
if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
return usage;
}
public getProfileUsageForId(
profileId: string,
storedRoutes: Map<string, IStoredRoute>,
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
return routes;
}
// =========================================================================
// Target CRUD
// =========================================================================
public async createTarget(data: {
name: string;
description?: string;
host: string | string[];
port: number;
createdBy: string;
}): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
const target: INetworkTarget = {
id,
name: data.name,
description: data.description,
host: data.host,
port: data.port,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
};
this.targets.set(id, target);
await this.persistTarget(target);
logger.log('info', `Created network target '${target.name}' (${id})`);
return id;
}
public async updateTarget(
id: string,
patch: Partial<Omit<INetworkTarget, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<{ affectedRouteIds: string[] }> {
const target = this.targets.get(id);
if (!target) {
throw new Error(`Network target '${id}' not found`);
}
if (patch.name !== undefined) target.name = patch.name;
if (patch.description !== undefined) target.description = patch.description;
if (patch.host !== undefined) target.host = patch.host;
if (patch.port !== undefined) target.port = patch.port;
target.updatedAt = Date.now();
await this.persistTarget(target);
logger.log('info', `Updated network target '${target.name}' (${id})`);
const affectedRouteIds = await this.findRoutesByTargetRef(id);
return { affectedRouteIds };
}
public async deleteTarget(
id: string,
force: boolean,
storedRoutes?: Map<string, IStoredRoute>,
): Promise<{ success: boolean; message?: string }> {
const target = this.targets.get(id);
if (!target) {
return { success: false, message: `Network target '${id}' not found` };
}
const affectedIds = storedRoutes
? this.findRoutesByTargetRefSync(id, storedRoutes)
: await this.findRoutesByTargetRef(id);
if (affectedIds.length > 0 && !force) {
return {
success: false,
message: `Target '${target.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
};
}
const doc = await NetworkTargetDoc.findById(id);
if (doc) await doc.delete();
this.targets.delete(id);
if (affectedIds.length > 0) {
await this.clearTargetRefsOnRoutes(affectedIds);
logger.log('warn', `Force-deleted target '${target.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted network target '${target.name}' (${id})`);
}
return { success: true };
}
public getTarget(id: string): INetworkTarget | undefined {
return this.targets.get(id);
}
public getTargetByName(name: string): INetworkTarget | undefined {
for (const target of this.targets.values()) {
if (target.name === name) return target;
}
return undefined;
}
public listTargets(): INetworkTarget[] {
return [...this.targets.values()];
}
public getTargetUsageForId(
targetId: string,
storedRoutes: Map<string, IStoredRoute>,
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.networkTargetRef === targetId) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
return routes;
}
// =========================================================================
// Resolution
// =========================================================================
/**
* Resolve references for a single route.
* Materializes security profile and/or network target into the route's fields.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
route: plugins.smartproxy.IRouteConfig,
metadata?: IRouteMetadata,
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.securityProfileRef) {
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef);
if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.securityProfileRef);
// Merge: profile provides base, route's inline values override
route = {
...route,
security: this.mergeSecurityFields(resolvedSecurity, route.security),
};
resolvedMetadata.securityProfileName = profile?.name;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`);
}
}
if (resolvedMetadata.networkTargetRef) {
const target = this.targets.get(resolvedMetadata.networkTargetRef);
if (target) {
route = {
...route,
action: {
...route.action,
targets: [{
host: target.host as string,
port: target.port,
}],
},
};
resolvedMetadata.networkTargetName = target.name;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Network target '${resolvedMetadata.networkTargetRef}' not found during resolution`);
}
}
return { route, metadata: resolvedMetadata };
}
// =========================================================================
// Reference lookup helpers
// =========================================================================
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.securityProfileRef === profileId)
.map((doc) => doc.id);
}
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
.map((doc) => doc.id);
}
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) {
ids.push(routeId);
}
}
return ids;
}
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.networkTargetRef === targetId) {
ids.push(routeId);
}
}
return ids;
}
// =========================================================================
// Private: security profile resolution with inheritance
// =========================================================================
private resolveSecurityProfile(
profileId: string,
visited: Set<string> = new Set(),
depth: number = 0,
): IRouteSecurity | null {
if (depth > MAX_INHERITANCE_DEPTH) {
logger.log('warn', `Max inheritance depth (${MAX_INHERITANCE_DEPTH}) exceeded resolving profile '${profileId}'`);
return null;
}
if (visited.has(profileId)) {
logger.log('warn', `Circular inheritance detected for profile '${profileId}'`);
return null;
}
const profile = this.profiles.get(profileId);
if (!profile) return null;
visited.add(profileId);
// Start with an empty base
let baseSecurity: IRouteSecurity = {};
// Resolve parent profiles first (top-down, later overrides earlier)
if (profile.extendsProfiles?.length) {
for (const parentId of profile.extendsProfiles) {
const parentSecurity = this.resolveSecurityProfile(parentId, new Set(visited), depth + 1);
if (parentSecurity) {
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
}
}
}
// Apply this profile's security on top
return this.mergeSecurityFields(baseSecurity, profile.security);
}
/**
* Merge two IRouteSecurity objects.
* `override` values take precedence over `base` values.
* For ipAllowList/ipBlockList: union arrays and deduplicate.
* For scalar/object fields: override wins if present.
*/
private mergeSecurityFields(
base: IRouteSecurity | undefined,
override: IRouteSecurity | undefined,
): IRouteSecurity {
if (!base && !override) return {};
if (!base) return { ...override };
if (!override) return { ...base };
const merged: IRouteSecurity = { ...base };
// IP lists: union
if (override.ipAllowList || base.ipAllowList) {
merged.ipAllowList = [...new Set([
...(base.ipAllowList || []),
...(override.ipAllowList || []),
])];
}
if (override.ipBlockList || base.ipBlockList) {
merged.ipBlockList = [...new Set([
...(base.ipBlockList || []),
...(override.ipBlockList || []),
])];
}
// Scalar/object fields: override wins
if (override.maxConnections !== undefined) merged.maxConnections = override.maxConnections;
if (override.rateLimit !== undefined) merged.rateLimit = override.rateLimit;
if (override.authentication !== undefined) merged.authentication = override.authentication;
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
return merged;
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadProfiles(): Promise<void> {
const docs = await SecurityProfileDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.profiles.set(doc.id, {
id: doc.id,
name: doc.name,
description: doc.description,
security: doc.security,
extendsProfiles: doc.extendsProfiles,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
});
}
}
if (this.profiles.size > 0) {
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`);
}
}
private async loadTargets(): Promise<void> {
const docs = await NetworkTargetDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.targets.set(doc.id, {
id: doc.id,
name: doc.name,
description: doc.description,
host: doc.host,
port: doc.port,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
});
}
}
if (this.targets.size > 0) {
logger.log('info', `Loaded ${this.targets.size} network target(s) from storage`);
}
}
private async persistProfile(profile: ISecurityProfile): Promise<void> {
const existingDoc = await SecurityProfileDoc.findById(profile.id);
if (existingDoc) {
existingDoc.name = profile.name;
existingDoc.description = profile.description;
existingDoc.security = profile.security;
existingDoc.extendsProfiles = profile.extendsProfiles;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
const doc = new SecurityProfileDoc();
doc.id = profile.id;
doc.name = profile.name;
doc.description = profile.description;
doc.security = profile.security;
doc.extendsProfiles = profile.extendsProfiles;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
await doc.save();
}
}
private async persistTarget(target: INetworkTarget): Promise<void> {
const existingDoc = await NetworkTargetDoc.findById(target.id);
if (existingDoc) {
existingDoc.name = target.name;
existingDoc.description = target.description;
existingDoc.host = target.host;
existingDoc.port = target.port;
existingDoc.updatedAt = target.updatedAt;
await existingDoc.save();
} else {
const doc = new NetworkTargetDoc();
doc.id = target.id;
doc.name = target.name;
doc.description = target.description;
doc.host = target.host;
doc.port = target.port;
doc.createdAt = target.createdAt;
doc.updatedAt = target.updatedAt;
doc.createdBy = target.createdBy;
await doc.save();
}
}
// =========================================================================
// Private: ref cleanup on force-delete
// =========================================================================
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
securityProfileRef: undefined,
securityProfileName: undefined,
};
doc.updatedAt = Date.now();
await doc.save();
}
}
}
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
networkTargetRef: undefined,
networkTargetName: undefined,
};
doc.updatedAt = Date.now();
await doc.save();
}
}
}
}

View File

@@ -1,16 +1,16 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js'; import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
import type { import type {
IStoredRoute, IStoredRoute,
IRouteOverride, IRouteOverride,
IMergedRoute, IMergedRoute,
IRouteWarning, IRouteWarning,
IRouteMetadata,
} from '../../ts_interfaces/data/route-management.js'; } from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js'; import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
const ROUTES_PREFIX = '/config-api/routes/';
const OVERRIDES_PREFIX = '/config-api/overrides/';
export class RouteConfigManager { export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>(); private storedRoutes = new Map<string, IStoredRoute>();
@@ -18,12 +18,18 @@ export class RouteConfigManager {
private warnings: IRouteWarning[] = []; private warnings: IRouteWarning[] = [];
constructor( constructor(
private storageManager: StorageManager,
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined, private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnAllowList?: (tags?: string[]) => string[],
private referenceResolver?: ReferenceResolver,
) {} ) {}
/** Expose stored routes map for reference resolution lookups. */
public getStoredRoutes(): Map<string, IStoredRoute> {
return this.storedRoutes;
}
/** /**
* Load persisted routes and overrides, compute warnings, apply to SmartProxy. * Load persisted routes and overrides, compute warnings, apply to SmartProxy.
*/ */
@@ -64,6 +70,7 @@ export class RouteConfigManager {
storedRouteId: stored.id, storedRouteId: stored.id,
createdAt: stored.createdAt, createdAt: stored.createdAt,
updatedAt: stored.updatedAt, updatedAt: stored.updatedAt,
metadata: stored.metadata,
}); });
} }
@@ -78,6 +85,7 @@ export class RouteConfigManager {
route: plugins.smartproxy.IRouteConfig, route: plugins.smartproxy.IRouteConfig,
createdBy: string, createdBy: string,
enabled = true, enabled = true,
metadata?: IRouteMetadata,
): Promise<string> { ): Promise<string> {
const id = plugins.uuid.v4(); const id = plugins.uuid.v4();
const now = Date.now(); const now = Date.now();
@@ -87,6 +95,14 @@ export class RouteConfigManager {
route.name = `programmatic-${id.slice(0, 8)}`; route.name = `programmatic-${id.slice(0, 8)}`;
} }
// Resolve references if metadata has refs and resolver is available
let resolvedMetadata = metadata;
if (metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(route, metadata);
route = resolved.route;
resolvedMetadata = resolved.metadata;
}
const stored: IStoredRoute = { const stored: IStoredRoute = {
id, id,
route, route,
@@ -94,6 +110,7 @@ export class RouteConfigManager {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy, createdBy,
metadata: resolvedMetadata,
}; };
this.storedRoutes.set(id, stored); this.storedRoutes.set(id, stored);
@@ -104,7 +121,11 @@ export class RouteConfigManager {
public async updateRoute( public async updateRoute(
id: string, id: string,
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean }, patch: {
route?: Partial<plugins.smartproxy.IRouteConfig>;
enabled?: boolean;
metadata?: Partial<IRouteMetadata>;
},
): Promise<boolean> { ): Promise<boolean> {
const stored = this.storedRoutes.get(id); const stored = this.storedRoutes.get(id);
if (!stored) return false; if (!stored) return false;
@@ -115,6 +136,17 @@ export class RouteConfigManager {
if (patch.enabled !== undefined) { if (patch.enabled !== undefined) {
stored.enabled = patch.enabled; stored.enabled = patch.enabled;
} }
if (patch.metadata !== undefined) {
stored.metadata = { ...stored.metadata, ...patch.metadata };
}
// Re-resolve if metadata refs exist and resolver is available
if (stored.metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route;
stored.metadata = resolved.metadata;
}
stored.updatedAt = Date.now(); stored.updatedAt = Date.now();
await this.persistRoute(stored); await this.persistRoute(stored);
@@ -125,7 +157,8 @@ export class RouteConfigManager {
public async deleteRoute(id: string): Promise<boolean> { public async deleteRoute(id: string): Promise<boolean> {
if (!this.storedRoutes.has(id)) return false; if (!this.storedRoutes.has(id)) return false;
this.storedRoutes.delete(id); this.storedRoutes.delete(id);
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`); const doc = await StoredRouteDoc.findById(id);
if (doc) await doc.delete();
await this.applyRoutes(); await this.applyRoutes();
return true; return true;
} }
@@ -146,7 +179,20 @@ export class RouteConfigManager {
updatedBy, updatedBy,
}; };
this.overrides.set(routeName, override); this.overrides.set(routeName, override);
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override); const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
if (existingDoc) {
existingDoc.enabled = override.enabled;
existingDoc.updatedAt = override.updatedAt;
existingDoc.updatedBy = override.updatedBy;
await existingDoc.save();
} else {
const doc = new RouteOverrideDoc();
doc.routeName = override.routeName;
doc.enabled = override.enabled;
doc.updatedAt = override.updatedAt;
doc.updatedBy = override.updatedBy;
await doc.save();
}
this.computeWarnings(); this.computeWarnings();
await this.applyRoutes(); await this.applyRoutes();
} }
@@ -154,7 +200,8 @@ export class RouteConfigManager {
public async removeOverride(routeName: string): Promise<boolean> { public async removeOverride(routeName: string): Promise<boolean> {
if (!this.overrides.has(routeName)) return false; if (!this.overrides.has(routeName)) return false;
this.overrides.delete(routeName); this.overrides.delete(routeName);
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`); const doc = await RouteOverrideDoc.findByRouteName(routeName);
if (doc) await doc.delete();
this.computeWarnings(); this.computeWarnings();
await this.applyRoutes(); await this.applyRoutes();
return true; return true;
@@ -165,12 +212,18 @@ export class RouteConfigManager {
// ========================================================================= // =========================================================================
private async loadStoredRoutes(): Promise<void> { private async loadStoredRoutes(): Promise<void> {
const keys = await this.storageManager.list(ROUTES_PREFIX); const docs = await StoredRouteDoc.findAll();
for (const key of keys) { for (const doc of docs) {
if (!key.endsWith('.json')) continue; if (doc.id) {
const stored = await this.storageManager.getJSON<IStoredRoute>(key); this.storedRoutes.set(doc.id, {
if (stored?.id) { id: doc.id,
this.storedRoutes.set(stored.id, stored); route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
metadata: doc.metadata,
});
} }
} }
if (this.storedRoutes.size > 0) { if (this.storedRoutes.size > 0) {
@@ -179,12 +232,15 @@ export class RouteConfigManager {
} }
private async loadOverrides(): Promise<void> { private async loadOverrides(): Promise<void> {
const keys = await this.storageManager.list(OVERRIDES_PREFIX); const docs = await RouteOverrideDoc.findAll();
for (const key of keys) { for (const doc of docs) {
if (!key.endsWith('.json')) continue; if (doc.routeName) {
const override = await this.storageManager.getJSON<IRouteOverride>(key); this.overrides.set(doc.routeName, {
if (override?.routeName) { routeName: doc.routeName,
this.overrides.set(override.routeName, override); enabled: doc.enabled,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
});
} }
} }
if (this.overrides.size > 0) { if (this.overrides.size > 0) {
@@ -193,7 +249,25 @@ export class RouteConfigManager {
} }
private async persistRoute(stored: IStoredRoute): Promise<void> { private async persistRoute(stored: IStoredRoute): Promise<void> {
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored); const existingDoc = await StoredRouteDoc.findById(stored.id);
if (existingDoc) {
existingDoc.route = stored.route;
existingDoc.enabled = stored.enabled;
existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy;
existingDoc.metadata = stored.metadata;
await existingDoc.save();
} else {
const doc = new StoredRouteDoc();
doc.id = stored.id;
doc.route = stored.route;
doc.enabled = stored.enabled;
doc.createdAt = stored.createdAt;
doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy;
doc.metadata = stored.metadata;
await doc.save();
}
} }
// ========================================================================= // =========================================================================
@@ -240,35 +314,81 @@ export class RouteConfigManager {
} }
} }
// =========================================================================
// Re-resolve routes after profile/target changes
// =========================================================================
/**
* Re-resolve specific routes by ID (after a profile or target is updated).
* Persists each route and calls applyRoutes() once at the end.
*/
public async reResolveRoutes(routeIds: string[]): Promise<void> {
if (!this.referenceResolver || routeIds.length === 0) return;
for (const routeId of routeIds) {
const stored = this.storedRoutes.get(routeId);
if (!stored?.metadata) continue;
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route;
stored.metadata = resolved.metadata;
stored.updatedAt = Date.now();
await this.persistRoute(stored);
}
await this.applyRoutes();
logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`);
}
// ========================================================================= // =========================================================================
// Private: apply merged routes to SmartProxy // Private: apply merged routes to SmartProxy
// ========================================================================= // =========================================================================
private async applyRoutes(): Promise<void> { public async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy(); const smartProxy = this.getSmartProxy();
if (!smartProxy) return; if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Add enabled hardcoded routes (respecting overrides) 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()) { for (const route of this.getHardcodedRoutes()) {
const name = route.name || ''; const name = route.name || '';
const override = this.overrides.get(name); const override = this.overrides.get(name);
if (override && !override.enabled) { if (override && !override.enabled) {
continue; // Skip disabled hardcoded route continue; // Skip disabled hardcoded route
} }
enabledRoutes.push(route); enabledRoutes.push(injectVpn(route));
} }
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled) // Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
const http3Config = this.getHttp3Config?.();
for (const stored of this.storedRoutes.values()) { for (const stored of this.storedRoutes.values()) {
if (stored.enabled) { if (stored.enabled) {
let route = stored.route;
if (http3Config && http3Config.enabled !== false) { if (http3Config && http3Config.enabled !== false) {
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config })); route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
} else {
enabledRoutes.push(stored.route);
} }
enabledRoutes.push(injectVpn(route));
} }
} }

View File

@@ -1,4 +1,6 @@
// Export validation tools only // Export validation tools only
export * from './validator.js'; export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js'; export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js'; export { ApiTokenManager } from './classes.api-token-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js';
export { DbSeeder } from './classes.db-seeder.js';

View File

@@ -170,7 +170,7 @@ export class ConfigValidator {
} else if (rules.items.schema && itemType === 'object') { } else if (rules.items.schema && itemType === 'object') {
const itemResult = this.validate(value[i], rules.items.schema); const itemResult = this.validate(value[i], rules.items.schema);
if (!itemResult.valid) { if (!itemResult.valid) {
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`)); errors.push(...itemResult.errors!.map(err => `${key}[${i}].${err}`));
} }
} }
} }
@@ -181,7 +181,7 @@ export class ConfigValidator {
if (rules.schema) { if (rules.schema) {
const nestedResult = this.validate(value, rules.schema); const nestedResult = this.validate(value, rules.schema);
if (!nestedResult.valid) { if (!nestedResult.valid) {
errors.push(...nestedResult.errors.map(err => `${key}.${err}`)); errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
} }
validatedConfig[key] = nestedResult.config; validatedConfig[key] = nestedResult.config;
} }
@@ -233,8 +233,8 @@ export class ConfigValidator {
// Apply defaults to array items // Apply defaults to array items
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) { if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
result[key] = result[key].map(item => result[key] = result[key].map(item =>
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
); );
} }
} }
@@ -255,7 +255,7 @@ export class ConfigValidator {
if (!result.valid) { if (!result.valid) {
throw new ValidationError( throw new ValidationError(
`Configuration validation failed: ${result.errors.join(', ')}`, `Configuration validation failed: ${result.errors!.join(', ')}`,
'CONFIG_VALIDATION_ERROR', 'CONFIG_VALIDATION_ERROR',
{ data: { errors: result.errors } } { data: { errors: result.errors } }
); );

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { CacheDb } from './classes.cachedb.js'; import { DcRouterDb } from './classes.dcrouter-db.js';
// Import document classes for cleanup // Import document classes for cleanup
import { CachedEmail } from './documents/classes.cached.email.js'; import { CachedEmail } from './documents/classes.cached.email.js';
@@ -26,10 +26,10 @@ export class CacheCleaner {
private cleanupInterval: ReturnType<typeof setInterval> | null = null; private cleanupInterval: ReturnType<typeof setInterval> | null = null;
private isRunning: boolean = false; private isRunning: boolean = false;
private options: Required<ICacheCleanerOptions>; private options: Required<ICacheCleanerOptions>;
private cacheDb: CacheDb; private dcRouterDb: DcRouterDb;
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) { constructor(dcRouterDb: DcRouterDb, options: ICacheCleanerOptions = {}) {
this.cacheDb = cacheDb; this.dcRouterDb = dcRouterDb;
this.options = { this.options = {
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
verbose: options.verbose || false, verbose: options.verbose || false,
@@ -48,14 +48,14 @@ export class CacheCleaner {
this.isRunning = true; this.isRunning = true;
// Run cleanup immediately on start // Run cleanup immediately on start
this.runCleanup().catch((error) => { this.runCleanup().catch((error: unknown) => {
logger.log('error', `Initial cache cleanup failed: ${error.message}`); logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
}); });
// Schedule periodic cleanup // Schedule periodic cleanup
this.cleanupInterval = setInterval(() => { this.cleanupInterval = setInterval(() => {
this.runCleanup().catch((error) => { this.runCleanup().catch((error: unknown) => {
logger.log('error', `Cache cleanup failed: ${error.message}`); logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
}); });
}, this.options.intervalMs); }, this.options.intervalMs);
@@ -86,8 +86,8 @@ export class CacheCleaner {
* Run a single cleanup cycle * Run a single cleanup cycle
*/ */
public async runCleanup(): Promise<void> { public async runCleanup(): Promise<void> {
if (!this.cacheDb.isReady()) { if (!this.dcRouterDb.isReady()) {
logger.log('warn', 'CacheDb not ready, skipping cleanup'); logger.log('warn', 'DcRouterDb not ready, skipping cleanup');
return; return;
} }
@@ -113,8 +113,8 @@ export class CacheCleaner {
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}` `Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
); );
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Cache cleanup error: ${error.message}`); logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
throw error; throw error;
} }
} }
@@ -138,14 +138,14 @@ export class CacheCleaner {
try { try {
await doc.delete(); await doc.delete();
deletedCount++; deletedCount++;
} catch (deleteError) { } catch (deleteError: unknown) {
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`); logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
} }
} }
return deletedCount; return deletedCount;
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error cleaning collection: ${error.message}`); logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
return 0; return 0;
} }
} }

View File

@@ -22,7 +22,7 @@ export abstract class CachedDocument<T extends CachedDocument<T>> extends plugin
* Timestamp when the document expires and should be cleaned up * Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator * NOTE: Subclasses must add @svDb() decorator
*/ */
public expiresAt: Date; public expiresAt!: Date;
/** /**
* Timestamp of last access (for LRU-style eviction if needed) * Timestamp of last access (for LRU-style eviction if needed)

View File

@@ -0,0 +1,179 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { defaultTsmDbPath } from '../paths.js';
/**
* Configuration options for the unified DCRouter database
*/
export interface IDcRouterDbConfig {
/** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */
mongoDbUrl?: string;
/** Storage path for embedded LocalSmartDb data (default: ~/.serve.zone/dcrouter/tsmdb) */
storagePath?: string;
/** Database name (default: dcrouter) */
dbName?: string;
/** Enable debug logging */
debug?: boolean;
}
/**
* DcRouterDb - Unified database layer for DCRouter
*
* Replaces both StorageManager (flat-file key-value) and CacheDb (embedded MongoDB).
* All data is stored as smartdata document classes in a single database.
*
* Two modes:
* - **Embedded** (default): Spawns a LocalSmartDb (Rust-based MongoDB-compatible engine)
* - **External**: Connects to a provided MongoDB URL
*/
export class DcRouterDb {
private static instance: DcRouterDb | null = null;
private localSmartDb: plugins.smartdb.LocalSmartDb | null = null;
private smartdataDb!: plugins.smartdata.SmartdataDb;
private options: Required<IDcRouterDbConfig>;
private isStarted: boolean = false;
constructor(options: IDcRouterDbConfig = {}) {
this.options = {
mongoDbUrl: options.mongoDbUrl || '',
storagePath: options.storagePath || defaultTsmDbPath,
dbName: options.dbName || 'dcrouter',
debug: options.debug || false,
};
}
/**
* Get or create the singleton instance
*/
public static getInstance(options?: IDcRouterDbConfig): DcRouterDb {
if (!DcRouterDb.instance) {
DcRouterDb.instance = new DcRouterDb(options);
}
return DcRouterDb.instance;
}
/**
* Reset the singleton instance (useful for testing)
*/
public static resetInstance(): void {
DcRouterDb.instance = null;
}
/**
* Start the database
* - If mongoDbUrl is provided, connects directly to external MongoDB
* - Otherwise, starts an embedded LocalSmartDb instance
*/
public async start(): Promise<void> {
if (this.isStarted) {
logger.log('warn', 'DcRouterDb already started');
return;
}
try {
let connectionUri: string;
if (this.options.mongoDbUrl) {
// External MongoDB mode
connectionUri = this.options.mongoDbUrl;
logger.log('info', `DcRouterDb connecting to external MongoDB`);
} else {
// Embedded LocalSmartDb mode
await plugins.fsUtils.ensureDir(this.options.storagePath);
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
folderPath: this.options.storagePath,
});
const connectionInfo = await this.localSmartDb.start();
connectionUri = connectionInfo.connectionUri;
if (this.options.debug) {
logger.log('debug', `LocalSmartDb started with URI: ${connectionUri}`);
}
logger.log('info', `DcRouterDb started embedded instance at ${this.options.storagePath}`);
}
// Initialize smartdata ORM
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init();
this.isStarted = true;
logger.log('info', `DcRouterDb ready (db: ${this.options.dbName})`);
} catch (error: unknown) {
logger.log('error', `Failed to start DcRouterDb: ${(error as Error).message}`);
throw error;
}
}
/**
* Stop the database
*/
public async stop(): Promise<void> {
if (!this.isStarted) {
return;
}
try {
// Close smartdata connection
if (this.smartdataDb) {
await this.smartdataDb.close();
}
// Stop embedded LocalSmartDb if running
if (this.localSmartDb) {
await this.localSmartDb.stop();
this.localSmartDb = null;
}
this.isStarted = false;
logger.log('info', 'DcRouterDb stopped');
} catch (error: unknown) {
logger.log('error', `Error stopping DcRouterDb: ${(error as Error).message}`);
throw error;
}
}
/**
* Get the smartdata database instance for @Collection decorators
*/
public getDb(): plugins.smartdata.SmartdataDb {
if (!this.isStarted) {
throw new Error('DcRouterDb not started. Call start() first.');
}
return this.smartdataDb;
}
/**
* Check if the database is ready
*/
public isReady(): boolean {
return this.isStarted;
}
/**
* Whether running in embedded mode (LocalSmartDb) vs external MongoDB
*/
public isEmbedded(): boolean {
return !this.options.mongoDbUrl;
}
/**
* Get the storage path (only relevant for embedded mode)
*/
public getStoragePath(): string {
return this.options.storagePath;
}
/**
* Get the database name
*/
public getDbName(): string {
return this.options.dbName;
}
}

View File

@@ -0,0 +1,106 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class AccountingSessionDoc extends plugins.smartdata.SmartDataDbDoc<AccountingSessionDoc, AccountingSessionDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public sessionId!: string;
@plugins.smartdata.svDb()
public username!: string;
@plugins.smartdata.svDb()
public macAddress!: string;
@plugins.smartdata.svDb()
public nasIpAddress!: string;
@plugins.smartdata.svDb()
public nasPort!: number;
@plugins.smartdata.svDb()
public nasPortType!: string;
@plugins.smartdata.svDb()
public nasIdentifier!: string;
@plugins.smartdata.svDb()
public vlanId!: number;
@plugins.smartdata.svDb()
public framedIpAddress!: string;
@plugins.smartdata.svDb()
public calledStationId!: string;
@plugins.smartdata.svDb()
public callingStationId!: string;
@plugins.smartdata.svDb()
public startTime!: number;
@plugins.smartdata.svDb()
public endTime!: number;
@plugins.smartdata.svDb()
public lastUpdateTime!: number;
@plugins.smartdata.index()
@plugins.smartdata.svDb()
public status!: 'active' | 'stopped' | 'terminated';
@plugins.smartdata.svDb()
public terminateCause!: string;
@plugins.smartdata.svDb()
public inputOctets!: number;
@plugins.smartdata.svDb()
public outputOctets!: number;
@plugins.smartdata.svDb()
public inputPackets!: number;
@plugins.smartdata.svDb()
public outputPackets!: number;
@plugins.smartdata.svDb()
public sessionTime!: number;
@plugins.smartdata.svDb()
public serviceType!: string;
constructor() {
super();
}
public static async findBySessionId(sessionId: string): Promise<AccountingSessionDoc | null> {
return await AccountingSessionDoc.getInstance({ sessionId });
}
public static async findActive(): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({ status: 'active' });
}
public static async findByUsername(username: string): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({ username });
}
public static async findByNas(nasIpAddress: string): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({ nasIpAddress });
}
public static async findByVlan(vlanId: number): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({ vlanId });
}
public static async findStoppedBefore(cutoffTime: number): Promise<AccountingSessionDoc[]> {
return await AccountingSessionDoc.getInstances({
status: { $in: ['stopped', 'terminated'] } as any,
endTime: { $lt: cutoffTime, $gt: 0 } as any,
});
}
}

View File

@@ -0,0 +1,41 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class AcmeCertDoc extends plugins.smartdata.SmartDataDbDoc<AcmeCertDoc, AcmeCertDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public domainName!: string;
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public created!: number;
@plugins.smartdata.svDb()
public privateKey!: string;
@plugins.smartdata.svDb()
public publicKey!: string;
@plugins.smartdata.svDb()
public csr!: string;
@plugins.smartdata.svDb()
public validUntil!: number;
constructor() {
super();
}
public static async findByDomain(domainName: string): Promise<AcmeCertDoc | null> {
return await AcmeCertDoc.getInstance({ domainName });
}
public static async findAll(): Promise<AcmeCertDoc[]> {
return await AcmeCertDoc.getInstances({});
}
}

View File

@@ -0,0 +1,56 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, ApiTokenDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public tokenHash!: string;
@plugins.smartdata.svDb()
public scopes!: TApiTokenScope[];
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public expiresAt!: number | null;
@plugins.smartdata.svDb()
public lastUsedAt!: number | null;
@plugins.smartdata.svDb()
public createdBy!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
constructor() {
super();
}
public static async findById(id: string): Promise<ApiTokenDoc | null> {
return await ApiTokenDoc.getInstance({ id });
}
public static async findByTokenHash(tokenHash: string): Promise<ApiTokenDoc | null> {
return await ApiTokenDoc.getInstance({ tokenHash });
}
public static async findAll(): Promise<ApiTokenDoc[]> {
return await ApiTokenDoc.getInstances({});
}
public static async findEnabled(): Promise<ApiTokenDoc[]> {
return await ApiTokenDoc.getInstances({ enabled: true });
}
}

View File

@@ -1,6 +1,6 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js'; import { CachedDocument, TTL } from '../classes.cached.document.js';
import { CacheDb } from '../classes.cachedb.js'; import { DcRouterDb } from '../classes.dcrouter-db.js';
/** /**
* Email status in the cache * Email status in the cache
@@ -10,7 +10,7 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'faile
/** /**
* Helper to get the smartdata database instance * Helper to get the smartdata database instance
*/ */
const getDb = () => CacheDb.getInstance().getDb(); const getDb = () => DcRouterDb.getInstance().getDb();
/** /**
* CachedEmail - Stores email queue items in the cache * CachedEmail - Stores email queue items in the cache
@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
*/ */
@plugins.smartdata.unI() @plugins.smartdata.unI()
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public id: string; public id!: string;
/** /**
* Email message ID (RFC 822 Message-ID header) * Email message ID (RFC 822 Message-ID header)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public messageId: string; public messageId!: string;
/** /**
* Sender email address (envelope from) * Sender email address (envelope from)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public from: string; public from!: string;
/** /**
* Recipient email addresses * Recipient email addresses
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public to: string[]; public to!: string[];
/** /**
* CC recipients * CC recipients
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public cc: string[]; public cc!: string[];
/** /**
* BCC recipients * BCC recipients
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public bcc: string[]; public bcc!: string[];
/** /**
* Email subject * Email subject
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public subject: string; public subject!: string;
/** /**
* Raw RFC822 email content * Raw RFC822 email content
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public rawContent: string; public rawContent!: string;
/** /**
* Current status of the email * Current status of the email
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public status: TCachedEmailStatus; public status!: TCachedEmailStatus;
/** /**
* Number of delivery attempts * Number of delivery attempts
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
* Timestamp for next delivery attempt * Timestamp for next delivery attempt
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public nextAttempt: Date; public nextAttempt!: Date;
/** /**
* Last error message if delivery failed * Last error message if delivery failed
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public lastError: string; public lastError!: string;
/** /**
* Timestamp when the email was successfully delivered * Timestamp when the email was successfully delivered
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public deliveredAt: Date; public deliveredAt!: Date;
/** /**
* Sender domain (for querying/filtering) * Sender domain (for querying/filtering)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public senderDomain: string; public senderDomain!: string;
/** /**
* Priority level (higher = more important) * Priority level (higher = more important)
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
* JSON-serialized route data * JSON-serialized route data
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public routeData: string; public routeData!: string;
/** /**
* DKIM signature status * DKIM signature status

View File

@@ -1,11 +1,11 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js'; import { CachedDocument, TTL } from '../classes.cached.document.js';
import { CacheDb } from '../classes.cachedb.js'; import { DcRouterDb } from '../classes.dcrouter-db.js';
/** /**
* Helper to get the smartdata database instance * Helper to get the smartdata database instance
*/ */
const getDb = () => CacheDb.getInstance().getDb(); const getDb = () => DcRouterDb.getInstance().getDb();
/** /**
* IP reputation result data * IP reputation result data
@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
*/ */
@plugins.smartdata.unI() @plugins.smartdata.unI()
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public ipAddress: string; public ipAddress!: string;
/** /**
* Reputation score (0-100, higher = better) * Reputation score (0-100, higher = better)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public score: number; public score!: number;
/** /**
* Whether the IP is flagged as spam source * Whether the IP is flagged as spam source
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public isSpam: boolean; public isSpam!: boolean;
/** /**
* Whether the IP is a known proxy * Whether the IP is a known proxy
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public isProxy: boolean; public isProxy!: boolean;
/** /**
* Whether the IP is a Tor exit node * Whether the IP is a Tor exit node
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public isTor: boolean; public isTor!: boolean;
/** /**
* Whether the IP is a VPN endpoint * Whether the IP is a VPN endpoint
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public isVPN: boolean; public isVPN!: boolean;
/** /**
* Country code (ISO 3166-1 alpha-2) * Country code (ISO 3166-1 alpha-2)
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public country: string; public country!: string;
/** /**
* Autonomous System Number * Autonomous System Number
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public asn: string; public asn!: string;
/** /**
* Organization name * Organization name
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public org: string; public org!: string;
/** /**
* List of blacklists the IP appears on * List of blacklists the IP appears on
*/ */
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public blacklists: string[]; public blacklists!: string[];
/** /**
* Number of times this IP has been checked * Number of times this IP has been checked

View File

@@ -0,0 +1,35 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class CertBackoffDoc extends plugins.smartdata.SmartDataDbDoc<CertBackoffDoc, CertBackoffDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public domain!: string;
@plugins.smartdata.svDb()
public failures!: number;
@plugins.smartdata.svDb()
public lastFailure!: string;
@plugins.smartdata.svDb()
public retryAfter!: string;
@plugins.smartdata.svDb()
public lastError!: string;
constructor() {
super();
}
public static async findByDomain(domain: string): Promise<CertBackoffDoc | null> {
return await CertBackoffDoc.getInstance({ domain });
}
public static async findAll(): Promise<CertBackoffDoc[]> {
return await CertBackoffDoc.getInstances({});
}
}

View File

@@ -0,0 +1,48 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class NetworkTargetDoc extends plugins.smartdata.SmartDataDbDoc<NetworkTargetDoc, NetworkTargetDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public host!: string | string[];
@plugins.smartdata.svDb()
public port!: number;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<NetworkTargetDoc | null> {
return await NetworkTargetDoc.getInstance({ id });
}
public static async findByName(name: string): Promise<NetworkTargetDoc | null> {
return await NetworkTargetDoc.getInstance({ name });
}
public static async findAll(): Promise<NetworkTargetDoc[]> {
return await NetworkTargetDoc.getInstances({});
}
}

View File

@@ -0,0 +1,38 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class ProxyCertDoc extends plugins.smartdata.SmartDataDbDoc<ProxyCertDoc, ProxyCertDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public domain!: string;
@plugins.smartdata.svDb()
public publicKey!: string;
@plugins.smartdata.svDb()
public privateKey!: string;
@plugins.smartdata.svDb()
public ca!: string;
@plugins.smartdata.svDb()
public validUntil!: number;
@plugins.smartdata.svDb()
public validFrom!: number;
constructor() {
super();
}
public static async findByDomain(domain: string): Promise<ProxyCertDoc | null> {
return await ProxyCertDoc.getInstance({ domain });
}
public static async findAll(): Promise<ProxyCertDoc[]> {
return await ProxyCertDoc.getInstances({});
}
}

View File

@@ -0,0 +1,54 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressEdgeDoc, RemoteIngressEdgeDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public secret!: string;
@plugins.smartdata.svDb()
public listenPorts!: number[];
@plugins.smartdata.svDb()
public listenPortsUdp!: number[];
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public autoDerivePorts!: boolean;
@plugins.smartdata.svDb()
public tags!: string[];
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
constructor() {
super();
}
public static async findById(id: string): Promise<RemoteIngressEdgeDoc | null> {
return await RemoteIngressEdgeDoc.getInstance({ id });
}
public static async findAll(): Promise<RemoteIngressEdgeDoc[]> {
return await RemoteIngressEdgeDoc.getInstances({});
}
public static async findEnabled(): Promise<RemoteIngressEdgeDoc[]> {
return await RemoteIngressEdgeDoc.getInstances({ enabled: true });
}
}

View File

@@ -0,0 +1,32 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public routeName!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public updatedBy!: string;
constructor() {
super();
}
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
return await RouteOverrideDoc.getInstance({ routeName });
}
public static async findAll(): Promise<RouteOverrideDoc[]> {
return await RouteOverrideDoc.getInstances({});
}
}

View File

@@ -0,0 +1,49 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRouteSecurity } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<SecurityProfileDoc, SecurityProfileDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public security!: IRouteSecurity;
@plugins.smartdata.svDb()
public extendsProfiles?: string[];
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<SecurityProfileDoc | null> {
return await SecurityProfileDoc.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({});
}
}

View File

@@ -0,0 +1,42 @@
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({});
}
}

View File

@@ -0,0 +1,32 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
export interface IMacVlanMapping {
mac: string;
vlan: number;
description?: string;
enabled: boolean;
createdAt: number;
updatedAt: number;
}
@plugins.smartdata.Collection(() => getDb())
export class VlanMappingsDoc extends plugins.smartdata.SmartDataDbDoc<VlanMappingsDoc, VlanMappingsDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public configId: string = 'vlan-mappings';
@plugins.smartdata.svDb()
public mappings!: IMacVlanMapping[];
constructor() {
super();
this.mappings = [];
}
public static async load(): Promise<VlanMappingsDoc | null> {
return await VlanMappingsDoc.getInstance({ configId: 'vlan-mappings' });
}
}

View File

@@ -0,0 +1,81 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc, VpnClientDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public clientId!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public serverDefinedClientTags?: string[];
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public assignedIp?: string;
@plugins.smartdata.svDb()
public noisePublicKey!: string;
@plugins.smartdata.svDb()
public wgPublicKey!: string;
@plugins.smartdata.svDb()
public wgPrivateKey?: string;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public expiresAt?: string;
@plugins.smartdata.svDb()
public forceDestinationSmartproxy: boolean = true;
@plugins.smartdata.svDb()
public destinationAllowList?: string[];
@plugins.smartdata.svDb()
public destinationBlockList?: string[];
@plugins.smartdata.svDb()
public useHostIp?: boolean;
@plugins.smartdata.svDb()
public useDhcp?: boolean;
@plugins.smartdata.svDb()
public staticIp?: string;
@plugins.smartdata.svDb()
public forceVlan?: boolean;
@plugins.smartdata.svDb()
public vlanId?: number;
constructor() {
super();
}
public static async 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 });
}
}

View File

@@ -0,0 +1,31 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class VpnServerKeysDoc extends plugins.smartdata.SmartDataDbDoc<VpnServerKeysDoc, VpnServerKeysDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public configId: string = 'vpn-server-keys';
@plugins.smartdata.svDb()
public noisePrivateKey!: string;
@plugins.smartdata.svDb()
public noisePublicKey!: string;
@plugins.smartdata.svDb()
public wgPrivateKey!: string;
@plugins.smartdata.svDb()
public wgPublicKey!: string;
constructor() {
super();
}
public static async load(): Promise<VpnServerKeysDoc | null> {
return await VpnServerKeysDoc.getInstance({ configId: 'vpn-server-keys' });
}
}

26
ts/db/documents/index.ts Normal file
View File

@@ -0,0 +1,26 @@
// Cached/TTL document classes
export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js';
// Config document classes
export * from './classes.stored-route.doc.js';
export * from './classes.route-override.doc.js';
export * from './classes.api-token.doc.js';
export * from './classes.security-profile.doc.js';
export * from './classes.network-target.doc.js';
// VPN document classes
export * from './classes.vpn-server-keys.doc.js';
export * from './classes.vpn-client.doc.js';
// Certificate document classes
export * from './classes.acme-cert.doc.js';
export * from './classes.proxy-cert.doc.js';
export * from './classes.cert-backoff.doc.js';
// Remote ingress document classes
export * from './classes.remote-ingress-edge.doc.js';
// RADIUS document classes
export * from './classes.vlan-mappings.doc.js';
export * from './classes.accounting-session.doc.js';

View File

@@ -1,6 +1,10 @@
// Core cache infrastructure // Unified database manager
export * from './classes.cachedb.js'; export * from './classes.dcrouter-db.js';
// TTL base class and constants
export * from './classes.cached.document.js'; export * from './classes.cached.document.js';
// Cache cleaner
export * from './classes.cache.cleaner.js'; export * from './classes.cache.cleaner.js';
// Document classes // Document classes

View File

@@ -227,7 +227,7 @@ export class PlatformError extends Error {
const { retry } = this.context; const { retry } = this.context;
if (!retry) return false; if (!retry) return false;
return retry.currentRetry < retry.maxRetries; return (retry.currentRetry ?? 0) < (retry.maxRetries ?? 0);
} }
/** /**

View File

@@ -296,11 +296,11 @@ export class MetricsManager {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null; const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) { if (!proxyMetrics) {
return []; return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
} }
const connectionsByRoute = proxyMetrics.connections.byRoute(); const connectionsByRoute = proxyMetrics.connections.byRoute();
const connectionInfo = []; const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
for (const [routeName, count] of connectionsByRoute) { for (const [routeName, count] of connectionsByRoute) {
connectionInfo.push({ connectionInfo.push({
@@ -595,47 +595,84 @@ export class MetricsManager {
const backendMetrics = proxyMetrics.backends.byBackend(); const backendMetrics = proxyMetrics.backends.byBackend();
const protocolCache = proxyMetrics.backends.detectedProtocols(); const protocolCache = proxyMetrics.backends.detectedProtocols();
// Index protocol cache by "host:port" // Group protocol cache entries by host:port so we can match them to backend metrics.
const cacheByKey = new Map<string, (typeof protocolCache)[number]>(); // The protocol cache is keyed by (host, port, domain) in Rust, so the same host:port
// can have multiple entries for different domains.
const cacheByBackend = new Map<string, (typeof protocolCache)[number][]>();
for (const entry of protocolCache) { for (const entry of protocolCache) {
cacheByKey.set(`${entry.host}:${entry.port}`, entry); const backendKey = `${entry.host}:${entry.port}`;
let entries = cacheByBackend.get(backendKey);
if (!entries) {
entries = [];
cacheByBackend.set(backendKey, entries);
}
entries.push(entry);
} }
const backends: Array<any> = []; const backends: Array<any> = [];
const seen = new Set<string>(); const seenCacheKeys = new Set<string>();
for (const [key, bm] of backendMetrics) { for (const [key, bm] of backendMetrics) {
seen.add(key); const cacheEntries = cacheByBackend.get(key);
const cache = cacheByKey.get(key); if (!cacheEntries || cacheEntries.length === 0) {
backends.push({ // No protocol cache entry — emit one row with backend metrics only
backend: key, backends.push({
domain: cache?.domain ?? null, backend: key,
protocol: bm.protocol, domain: null,
activeConnections: bm.activeConnections, protocol: bm.protocol,
totalConnections: bm.totalConnections, activeConnections: bm.activeConnections,
connectErrors: bm.connectErrors, totalConnections: bm.totalConnections,
handshakeErrors: bm.handshakeErrors, connectErrors: bm.connectErrors,
requestErrors: bm.requestErrors, handshakeErrors: bm.handshakeErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10, requestErrors: bm.requestErrors,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000, avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
h2Failures: bm.h2Failures, poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Suppressed: cache?.h2Suppressed ?? false, h2Failures: bm.h2Failures,
h3Suppressed: cache?.h3Suppressed ?? false, h2Suppressed: false,
h2CooldownRemainingSecs: cache?.h2CooldownRemainingSecs ?? null, h3Suppressed: false,
h3CooldownRemainingSecs: cache?.h3CooldownRemainingSecs ?? null, h2CooldownRemainingSecs: null,
h2ConsecutiveFailures: cache?.h2ConsecutiveFailures ?? null, h3CooldownRemainingSecs: null,
h3ConsecutiveFailures: cache?.h3ConsecutiveFailures ?? null, h2ConsecutiveFailures: null,
h3Port: cache?.h3Port ?? null, h3ConsecutiveFailures: null,
cacheAgeSecs: cache?.ageSecs ?? null, h3Port: null,
}); cacheAgeSecs: null,
});
} else {
// One row per domain, each enriched with the shared backend metrics
for (const cache of cacheEntries) {
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
seenCacheKeys.add(compositeKey);
backends.push({
backend: key,
domain: cache.domain ?? null,
protocol: cache.protocol ?? bm.protocol,
activeConnections: bm.activeConnections,
totalConnections: bm.totalConnections,
connectErrors: bm.connectErrors,
handshakeErrors: bm.handshakeErrors,
requestErrors: bm.requestErrors,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
h2Failures: bm.h2Failures,
h2Suppressed: cache.h2Suppressed,
h3Suppressed: cache.h3Suppressed,
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
h3CooldownRemainingSecs: cache.h3CooldownRemainingSecs,
h2ConsecutiveFailures: cache.h2ConsecutiveFailures,
h3ConsecutiveFailures: cache.h3ConsecutiveFailures,
h3Port: cache.h3Port,
cacheAgeSecs: cache.ageSecs,
});
}
}
} }
// Include protocol cache entries with no matching backend metric // Include protocol cache entries with no matching backend metric
for (const entry of protocolCache) { for (const entry of protocolCache) {
const key = `${entry.host}:${entry.port}`; const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
if (!seen.has(key)) { if (!seenCacheKeys.has(compositeKey)) {
backends.push({ backends.push({
backend: key, backend: `${entry.host}:${entry.port}`,
domain: entry.domain, domain: entry.domain,
protocol: entry.protocol, protocol: entry.protocol,
activeConnections: 0, activeConnections: 0,

View File

@@ -7,7 +7,7 @@ import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js'
export class OpsServer { export class OpsServer {
public dcRouterRef: DcRouter; public dcRouterRef: DcRouter;
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer; public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer;
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers // Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -17,17 +17,20 @@ export class OpsServer {
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>(); public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
// Handler instances // Handler instances
public adminHandler: handlers.AdminHandler; public adminHandler!: handlers.AdminHandler;
private configHandler: handlers.ConfigHandler; private configHandler!: handlers.ConfigHandler;
private logsHandler: handlers.LogsHandler; private logsHandler!: handlers.LogsHandler;
private securityHandler: handlers.SecurityHandler; private securityHandler!: handlers.SecurityHandler;
private statsHandler: handlers.StatsHandler; private statsHandler!: handlers.StatsHandler;
private radiusHandler: handlers.RadiusHandler; private radiusHandler!: handlers.RadiusHandler;
private emailOpsHandler: handlers.EmailOpsHandler; private emailOpsHandler!: handlers.EmailOpsHandler;
private certificateHandler: handlers.CertificateHandler; private certificateHandler!: handlers.CertificateHandler;
private remoteIngressHandler: handlers.RemoteIngressHandler; private remoteIngressHandler!: handlers.RemoteIngressHandler;
private routeManagementHandler: handlers.RouteManagementHandler; private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler: handlers.ApiTokenHandler; private apiTokenHandler!: handlers.ApiTokenHandler;
private vpnHandler!: handlers.VpnHandler;
private securityProfileHandler!: handlers.SecurityProfileHandler;
private networkTargetHandler!: handlers.NetworkTargetHandler;
constructor(dcRouterRefArg: DcRouter) { constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg; this.dcRouterRef = dcRouterRefArg;
@@ -39,7 +42,7 @@ export class OpsServer {
public async start() { public async start() {
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({ this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost', domain: 'localhost',
feedMetadata: null, feedMetadata: undefined,
serveDir: paths.distServe, serveDir: paths.distServe,
}); });
@@ -86,6 +89,9 @@ export class OpsServer {
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this); this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this); this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this); this.apiTokenHandler = new handlers.ApiTokenHandler(this);
this.vpnHandler = new handlers.VpnHandler(this);
this.securityProfileHandler = new handlers.SecurityProfileHandler(this);
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized'); console.log('✅ OpsServer TypedRequest handlers initialized');
} }

View File

@@ -12,7 +12,7 @@ export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
// JWT instance // JWT instance
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>; public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
// Simple in-memory user storage (in production, use proper database) // Simple in-memory user storage (in production, use proper database)
private users = new Map<string, { private users = new Map<string, {

View File

@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js'; import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
export class CertificateHandler { export class CertificateHandler {
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
@@ -187,30 +188,28 @@ export class CertificateHandler {
} }
} }
// Check persisted cert data from StorageManager // Check persisted cert data from smartdata document classes
if (status === 'unknown') { if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, ''); const cleanDomain = domain.replace(/^\*\.?/, '');
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`); const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (!certData) { const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
// Also check certStore path (proxy-certs)
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`); if (acmeDoc?.validUntil) {
} expiryDate = new Date(acmeDoc.validUntil).toISOString();
if (certData?.validUntil) { if (acmeDoc.created) {
expiryDate = new Date(certData.validUntil).toISOString(); issuedAt = new Date(acmeDoc.created).toISOString();
if (certData.created) {
issuedAt = new Date(certData.created).toISOString();
} }
issuer = 'smartacme-dns-01'; issuer = 'smartacme-dns-01';
} else if (certData?.publicKey) { } else if (proxyDoc?.publicKey) {
// certStore has the cert — parse PEM for expiry // certStore has the cert — parse PEM for expiry
try { try {
const x509 = new plugins.crypto.X509Certificate(certData.publicKey); const x509 = new plugins.crypto.X509Certificate(proxyDoc.publicKey);
expiryDate = new Date(x509.validTo).toISOString(); expiryDate = new Date(x509.validTo).toISOString();
issuedAt = new Date(x509.validFrom).toISOString(); issuedAt = new Date(x509.validFrom).toISOString();
} catch { /* PEM parsing failed */ } } catch { /* PEM parsing failed */ }
status = 'valid'; status = 'valid';
issuer = 'cert-store'; issuer = 'cert-store';
} else if (certData) { } else if (acmeDoc || proxyDoc) {
status = 'valid'; status = 'valid';
issuer = 'cert-store'; issuer = 'cert-store';
} }
@@ -311,8 +310,8 @@ export class CertificateHandler {
} }
} }
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` }; return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err) { } catch (err: unknown) {
return { success: false, message: err.message || 'Failed to reprovision certificate' }; return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
} }
} }
@@ -340,8 +339,8 @@ export class CertificateHandler {
try { try {
await dcRouter.smartAcme.getCertificateForDomain(domain); await dcRouter.smartAcme.getCertificateForDomain(domain);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` }; return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err) { } catch (err: unknown) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` }; return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
} }
} }
@@ -351,8 +350,8 @@ export class CertificateHandler {
try { try {
await smartProxy.provisionCertificate(routeNames[0]); await smartProxy.provisionCertificate(routeNames[0]);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` }; return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
} catch (err) { } catch (err: unknown) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` }; return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
} }
} }
@@ -366,18 +365,17 @@ export class CertificateHandler {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, ''); const cleanDomain = domain.replace(/^\*\.?/, '');
// Delete from all known storage paths // Delete from smartdata document classes
const paths = [ const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
`/proxy-certs/${domain}`, if (acmeDoc) {
`/proxy-certs/${cleanDomain}`, await acmeDoc.delete();
`/certs/${cleanDomain}`, }
];
for (const path of paths) { // Try both original domain and clean domain for proxy certs
try { for (const d of [domain, cleanDomain]) {
await dcRouter.storageManager.delete(path); const proxyDoc = await ProxyCertDoc.findByDomain(d);
} catch { if (proxyDoc) {
// Path may not exist — ignore await proxyDoc.delete();
} }
} }
@@ -408,43 +406,41 @@ export class CertificateHandler {
}; };
message?: string; message?: string;
}> { }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, ''); const cleanDomain = domain.replace(/^\*\.?/, '');
// Try SmartAcme /certs/ path first (has full ICert fields) // Try AcmeCertDoc first (has full ICert fields)
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`); const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (certData && certData.publicKey && certData.privateKey) { if (acmeDoc && acmeDoc.publicKey && acmeDoc.privateKey) {
return { return {
success: true, success: true,
cert: { cert: {
id: certData.id || plugins.crypto.randomUUID(), id: acmeDoc.id || plugins.crypto.randomUUID(),
domainName: certData.domainName || domain, domainName: acmeDoc.domainName || domain,
created: certData.created || Date.now(), created: acmeDoc.created || Date.now(),
validUntil: certData.validUntil || 0, validUntil: acmeDoc.validUntil || 0,
privateKey: certData.privateKey, privateKey: acmeDoc.privateKey,
publicKey: certData.publicKey, publicKey: acmeDoc.publicKey,
csr: certData.csr || '', csr: acmeDoc.csr || '',
}, },
}; };
} }
// Fallback: try /proxy-certs/ with original domain // Fallback: try ProxyCertDoc with original domain, then clean domain
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`); let proxyDoc = await ProxyCertDoc.findByDomain(domain);
if (!certData || !certData.publicKey) { if (!proxyDoc || !proxyDoc.publicKey) {
// Try with clean domain proxyDoc = await ProxyCertDoc.findByDomain(cleanDomain);
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
} }
if (certData && certData.publicKey && certData.privateKey) { if (proxyDoc && proxyDoc.publicKey && proxyDoc.privateKey) {
return { return {
success: true, success: true,
cert: { cert: {
id: plugins.crypto.randomUUID(), id: plugins.crypto.randomUUID(),
domainName: domain, domainName: domain,
created: certData.validFrom || Date.now(), created: proxyDoc.validFrom || Date.now(),
validUntil: certData.validUntil || 0, validUntil: proxyDoc.validUntil || 0,
privateKey: certData.privateKey, privateKey: proxyDoc.privateKey,
publicKey: certData.publicKey, publicKey: proxyDoc.publicKey,
csr: '', csr: '',
}, },
}; };
@@ -476,26 +472,32 @@ export class CertificateHandler {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = cert.domainName.replace(/^\*\.?/, ''); const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
// Save to /certs/ (SmartAcme-compatible path) // Save to AcmeCertDoc (SmartAcme-compatible)
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, { let acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
id: cert.id, if (!acmeDoc) {
domainName: cert.domainName, acmeDoc = new AcmeCertDoc();
created: cert.created, acmeDoc.domainName = cleanDomain;
validUntil: cert.validUntil, }
privateKey: cert.privateKey, acmeDoc.id = cert.id;
publicKey: cert.publicKey, acmeDoc.created = cert.created;
csr: cert.csr || '', acmeDoc.validUntil = cert.validUntil;
}); acmeDoc.privateKey = cert.privateKey;
acmeDoc.publicKey = cert.publicKey;
acmeDoc.csr = cert.csr || '';
await acmeDoc.save();
// Also save to /proxy-certs/ (proxy-cert format) // Also save to ProxyCertDoc (proxy-cert format)
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, { let proxyDoc = await ProxyCertDoc.findByDomain(cert.domainName);
domain: cert.domainName, if (!proxyDoc) {
publicKey: cert.publicKey, proxyDoc = new ProxyCertDoc();
privateKey: cert.privateKey, proxyDoc.domain = cert.domainName;
ca: undefined, }
validUntil: cert.validUntil, proxyDoc.publicKey = cert.publicKey;
validFrom: cert.created, proxyDoc.privateKey = cert.privateKey;
}); proxyDoc.ca = '';
proxyDoc.validUntil = cert.validUntil;
proxyDoc.validFrom = cert.created;
await proxyDoc.save();
// Update in-memory status map // Update in-memory status map
dcRouter.certificateStatusMap.set(cert.domainName, { dcRouter.certificateStatusMap.set(cert.domainName, {

View File

@@ -33,11 +33,9 @@ export class ConfigHandler {
const resolvedPaths = dcRouter.resolvedPaths; const resolvedPaths = dcRouter.resolvedPaths;
// --- System --- // --- System ---
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.dbConfig?.mongoDbUrl
? 'custom' ? 'custom'
: opts.storage?.fsPath : 'filesystem';
? 'filesystem'
: 'memory';
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts // Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
let proxyIps = opts.proxyIps || []; let proxyIps = opts.proxyIps || [];
@@ -55,7 +53,7 @@ export class ConfigHandler {
proxyIps, proxyIps,
uptime: Math.floor(process.uptime()), uptime: Math.floor(process.uptime()),
storageBackend, storageBackend,
storagePath: opts.storage?.fsPath || null, storagePath: opts.dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
}; };
// --- SmartProxy --- // --- SmartProxy ---
@@ -151,15 +149,15 @@ export class ConfigHandler {
keyPath: opts.tls?.keyPath || null, keyPath: opts.tls?.keyPath || null,
}; };
// --- Cache --- // --- Database ---
const cacheConfig = opts.cacheConfig; const dbConfig = opts.dbConfig;
const cache: interfaces.requests.IConfigData['cache'] = { const cache: interfaces.requests.IConfigData['cache'] = {
enabled: cacheConfig?.enabled !== false, enabled: dbConfig?.enabled !== false,
storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath, storagePath: dbConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
dbName: cacheConfig?.dbName || 'dcrouter', dbName: dbConfig?.dbName || 'dcrouter',
defaultTTLDays: cacheConfig?.defaultTTLDays || 30, defaultTTLDays: 30,
cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1, cleanupIntervalHours: dbConfig?.cleanupIntervalHours || 1,
ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record<string, number> : {}, ttlConfig: {},
}; };
// --- RADIUS --- // --- RADIUS ---
@@ -185,7 +183,8 @@ export class ConfigHandler {
tlsMode = 'custom'; tlsMode = 'custom';
} else if (riCfg?.hubDomain) { } else if (riCfg?.hubDomain) {
try { try {
const stored = await dcRouter.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`); const { ProxyCertDoc } = await import('../../db/index.js');
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
if (stored?.publicKey && stored?.privateKey) { if (stored?.publicKey && stored?.privateKey) {
tlsMode = 'acme'; tlsMode = 'acme';
} }

View File

@@ -8,4 +8,7 @@ export * from './email-ops.handler.js';
export * from './certificate.handler.js'; export * from './certificate.handler.js';
export * from './remoteingress.handler.js'; export * from './remoteingress.handler.js';
export * from './route-management.handler.js'; export * from './route-management.handler.js';
export * from './api-token.handler.js'; export * from './api-token.handler.js';
export * from './vpn.handler.js';
export * from './security-profile.handler.js';
export * from './network-target.handler.js';

View File

@@ -0,0 +1,167 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class NetworkTargetHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get all network targets
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
'getNetworkTargets',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { targets: [] };
}
return { targets: resolver.listTargets() };
},
),
);
// Get a single network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTarget>(
'getNetworkTarget',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { target: null };
}
return { target: resolver.getTarget(dataArg.id) || null };
},
),
);
// Create a network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateNetworkTarget>(
'createNetworkTarget',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'targets:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { success: false, message: 'Reference resolver not initialized' };
}
const id = await resolver.createTarget({
name: dataArg.name,
description: dataArg.description,
host: dataArg.host,
port: dataArg.port,
createdBy: userId,
});
return { success: true, id };
},
),
);
// Update a network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateNetworkTarget>(
'updateNetworkTarget',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { success: false, message: 'Not initialized' };
}
const { affectedRouteIds } = await resolver.updateTarget(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
host: dataArg.host,
port: dataArg.port,
});
if (affectedRouteIds.length > 0) {
await manager.reResolveRoutes(affectedRouteIds);
}
return { success: true, affectedRouteCount: affectedRouteIds.length };
},
),
);
// Delete a network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteNetworkTarget>(
'deleteNetworkTarget',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { success: false, message: 'Not initialized' };
}
const result = await resolver.deleteTarget(
dataArg.id,
dataArg.force ?? false,
manager.getStoredRoutes(),
);
if (result.success && dataArg.force) {
await manager.applyRoutes();
}
return result;
},
),
);
// Get routes using a network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargetUsage>(
'getNetworkTargetUsage',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { routes: [] };
}
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
},
),
);
}
}

View File

@@ -52,8 +52,8 @@ export class RadiusHandler {
try { try {
await radiusServer.addClient(dataArg.client); await radiusServer.addClient(dataArg.client);
return { success: true }; return { success: true };
} catch (error) { } catch (error: unknown) {
return { success: false, message: error.message }; return { success: false, message: (error as Error).message };
} }
} }
) )
@@ -144,8 +144,8 @@ export class RadiusHandler {
updatedAt: mapping.updatedAt, updatedAt: mapping.updatedAt,
}, },
}; };
} catch (error) { } catch (error: unknown) {
return { success: false, message: error.message }; return { success: false, message: (error as Error).message };
} }
} }
) )

View File

@@ -71,7 +71,7 @@ export class RouteManagementHandler {
if (!manager) { if (!manager) {
return { success: false, message: 'Route management not initialized' }; return { success: false, message: 'Route management not initialized' };
} }
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true); const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
return { success: true, storedRouteId: id }; return { success: true, storedRouteId: id };
}, },
), ),
@@ -90,6 +90,7 @@ export class RouteManagementHandler {
const ok = await manager.updateRoute(dataArg.id, { const ok = await manager.updateRoute(dataArg.id, {
route: dataArg.route as any, route: dataArg.route as any,
enabled: dataArg.enabled, enabled: dataArg.enabled,
metadata: dataArg.metadata,
}); });
return { success: ok, message: ok ? undefined : 'Route not found' }; return { success: ok, message: ok ? undefined : 'Route not found' };
}, },

View File

@@ -0,0 +1,169 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class SecurityProfileHandler {
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 security profiles
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfiles>(
'getSecurityProfiles',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { profiles: [] };
}
return { profiles: resolver.listProfiles() };
},
),
);
// Get a single security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfile>(
'getSecurityProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { profile: null };
}
return { profile: resolver.getProfile(dataArg.id) || null };
},
),
);
// Create a security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityProfile>(
'createSecurityProfile',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { success: false, message: 'Reference resolver not initialized' };
}
const id = await resolver.createProfile({
name: dataArg.name,
description: dataArg.description,
security: dataArg.security,
extendsProfiles: dataArg.extendsProfiles,
createdBy: userId,
});
return { success: true, id };
},
),
);
// Update a security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityProfile>(
'updateSecurityProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { success: false, message: 'Not initialized' };
}
const { affectedRouteIds } = await resolver.updateProfile(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
security: dataArg.security,
extendsProfiles: dataArg.extendsProfiles,
});
// Propagate to affected routes
if (affectedRouteIds.length > 0) {
await manager.reResolveRoutes(affectedRouteIds);
}
return { success: true, affectedRouteCount: affectedRouteIds.length };
},
),
);
// Delete a security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityProfile>(
'deleteSecurityProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { success: false, message: 'Not initialized' };
}
const result = await resolver.deleteProfile(
dataArg.id,
dataArg.force ?? false,
manager.getStoredRoutes(),
);
// If force-deleted with affected routes, re-apply
if (result.success && dataArg.force) {
await manager.applyRoutes();
}
return result;
},
),
);
// Get routes using a security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfileUsage>(
'getSecurityProfileUsage',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { routes: [] };
}
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
},
),
);
}
}

View File

@@ -279,7 +279,7 @@ export class StatsHandler {
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) { if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
promises.push( promises.push(
(async () => { (async () => {
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats(); const stats = await this.opsServerRef.dcRouterRef.metricsManager!.getNetworkStats();
const serverStats = await this.collectServerStats(); const serverStats = await this.collectServerStats();
// Build per-IP bandwidth lookup from throughputByIP // Build per-IP bandwidth lookup from throughputByIP

View File

@@ -0,0 +1,340 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class VpnHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get all registered VPN clients
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
'getVpnClients',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { clients: [] };
}
const clients = manager.listClients().map((c) => ({
clientId: c.clientId,
enabled: c.enabled,
serverDefinedClientTags: c.serverDefinedClientTags,
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,
useDhcp: c.useDhcp,
staticIp: c.staticIp,
forceVlan: c.forceVlan,
vlanId: c.vlanId,
}));
return { clients };
},
),
);
// Get VPN server status
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
'getVpnStatus',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
if (!manager) {
return {
status: {
running: false,
subnet: vpnConfig?.subnet || '10.8.0.0/24',
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: null,
registeredClients: 0,
connectedClients: 0,
},
};
}
const connected = await manager.getConnectedClients();
return {
status: {
running: manager.running,
subnet: manager.getSubnet(),
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: manager.getServerPublicKeys(),
registeredClients: manager.listClients().length,
connectedClients: connected.length,
},
};
},
),
);
// Get currently connected VPN clients
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnConnectedClients>(
'getVpnConnectedClients',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { connectedClients: [] };
}
const connected = await manager.getConnectedClients();
return {
connectedClients: connected.map((c) => ({
clientId: c.registeredClientId || c.clientId,
assignedIp: c.assignedIp,
connectedSince: c.connectedSince,
bytesSent: c.bytesSent,
bytesReceived: c.bytesReceived,
transport: c.transportType,
})),
};
},
),
);
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Create a new VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
'createVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const bundle = await manager.createClient({
clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
description: dataArg.description,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp,
useDhcp: dataArg.useDhcp,
staticIp: dataArg.staticIp,
forceVlan: dataArg.forceVlan,
vlanId: dataArg.vlanId,
});
// Retrieve the persisted doc to get dcrouter-level fields
const persistedClient = manager.listClients().find(
(c) => c.clientId === bundle.entry.clientId,
);
return {
success: true,
client: {
clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true,
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
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,
useDhcp: persistedClient?.useDhcp,
staticIp: persistedClient?.staticIp,
forceVlan: persistedClient?.forceVlan,
vlanId: persistedClient?.vlanId,
},
wireguardConfig: bundle.wireguardConfig,
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Update a VPN client's metadata
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVpnClient>(
'updateVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.updateClient(dataArg.clientId, {
description: dataArg.description,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp,
useDhcp: dataArg.useDhcp,
staticIp: dataArg.staticIp,
forceVlan: dataArg.forceVlan,
vlanId: dataArg.vlanId,
});
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Delete a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
'deleteVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.removeClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Enable a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
'enableVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.enableClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Disable a VPN client
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
'disableVpnClient',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
await manager.disableClient(dataArg.clientId);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Rotate a VPN client's keys
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
'rotateVpnClientKey',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const bundle = await manager.rotateClientKey(dataArg.clientId);
return {
success: true,
wireguardConfig: bundle.wireguardConfig,
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Export a VPN client config
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
'exportVpnClientConfig',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const config = await manager.exportClientConfig(dataArg.clientId, dataArg.format);
return { success: true, config };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Get telemetry for a specific VPN client
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
'getVpnClientTelemetry',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
}
try {
const telemetry = await manager.getClientTelemetry(dataArg.clientId);
if (!telemetry) {
return { success: false, message: 'Client not found or not connected' };
}
return {
success: true,
telemetry: {
clientId: telemetry.clientId,
assignedIp: telemetry.assignedIp,
bytesSent: telemetry.bytesSent,
bytesReceived: telemetry.bytesReceived,
packetsDropped: telemetry.packetsDropped,
bytesDropped: telemetry.bytesDropped,
lastKeepaliveAt: telemetry.lastKeepaliveAt,
keepalivesReceived: telemetry.keepalivesReceived,
rateLimitBytesPerSec: telemetry.rateLimitBytesPerSec,
burstBytes: telemetry.burstBytes,
},
};
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}

View File

@@ -34,7 +34,6 @@ export function resolvePaths(baseDir?: string) {
dcrouterHomeDir: root, dcrouterHomeDir: root,
dataDir: resolvedDataDir, dataDir: resolvedDataDir,
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'), defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
defaultStoragePath: plugins.path.join(root, 'storage'),
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'), dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
}; };
} }

View File

@@ -47,24 +47,25 @@ import * as qenv from '@push.rocks/qenv';
import * as smartacme from '@push.rocks/smartacme'; import * as smartacme from '@push.rocks/smartacme';
import * as smartdata from '@push.rocks/smartdata'; import * as smartdata from '@push.rocks/smartdata';
import * as smartdns from '@push.rocks/smartdns'; import * as smartdns from '@push.rocks/smartdns';
import * as smartfile from '@push.rocks/smartfile'; import * as smartfs from '@push.rocks/smartfs';
import * as smartguard from '@push.rocks/smartguard'; import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt'; import * as smartjwt from '@push.rocks/smartjwt';
import * as smartlog from '@push.rocks/smartlog'; import * as smartlog from '@push.rocks/smartlog';
import * as smartmetrics from '@push.rocks/smartmetrics'; import * as smartmetrics from '@push.rocks/smartmetrics';
import * as smartmta from '@push.rocks/smartmta'; import * as smartmta from '@push.rocks/smartmta';
import * as smartmongo from '@push.rocks/smartmongo'; import * as smartdb from '@push.rocks/smartdb';
import * as smartnetwork from '@push.rocks/smartnetwork'; import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy'; import * as smartproxy from '@push.rocks/smartproxy';
import * as smartpromise from '@push.rocks/smartpromise'; import * as smartpromise from '@push.rocks/smartpromise';
import * as smartvpn from '@push.rocks/smartvpn';
import * as smartradius from '@push.rocks/smartradius'; import * as smartradius from '@push.rocks/smartradius';
import * as smartrequest from '@push.rocks/smartrequest'; import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrx from '@push.rocks/smartrx'; import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique'; import * as smartunique from '@push.rocks/smartunique';
import * as taskbuffer from '@push.rocks/taskbuffer'; import * as taskbuffer from '@push.rocks/taskbuffer';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer }; export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
// Define SmartLog types for use in error handling // Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug'; export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
@@ -90,7 +91,7 @@ export {
uuid, uuid,
} }
// Filesystem utilities (compatibility helpers for smartfile v13+) // Filesystem utilities
export const fsUtils = { export const fsUtils = {
/** /**
* Ensure a directory exists, creating it recursively if needed (sync) * Ensure a directory exists, creating it recursively if needed (sync)

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js'; import { AccountingSessionDoc } from '../db/index.js';
/** /**
* RADIUS accounting session * RADIUS accounting session
@@ -84,8 +84,6 @@ export interface IAccountingSummary {
* Accounting manager configuration * Accounting manager configuration
*/ */
export interface IAccountingManagerConfig { export interface IAccountingManagerConfig {
/** Storage key prefix */
storagePrefix?: string;
/** Session retention period in days (default: 30) */ /** Session retention period in days (default: 30) */
retentionDays?: number; retentionDays?: number;
/** Enable detailed session logging */ /** Enable detailed session logging */
@@ -106,7 +104,6 @@ export interface IAccountingManagerConfig {
export class AccountingManager { export class AccountingManager {
private activeSessions: Map<string, IAccountingSession> = new Map(); private activeSessions: Map<string, IAccountingSession> = new Map();
private config: Required<IAccountingManagerConfig>; private config: Required<IAccountingManagerConfig>;
private storageManager?: StorageManager;
private staleSessionSweepTimer?: ReturnType<typeof setInterval>; private staleSessionSweepTimer?: ReturnType<typeof setInterval>;
// Counters for statistics // Counters for statistics
@@ -118,24 +115,20 @@ export class AccountingManager {
interimUpdatesReceived: 0, interimUpdatesReceived: 0,
}; };
constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) { constructor(config?: IAccountingManagerConfig) {
this.config = { this.config = {
storagePrefix: config?.storagePrefix ?? '/radius/accounting',
retentionDays: config?.retentionDays ?? 30, retentionDays: config?.retentionDays ?? 30,
detailedLogging: config?.detailedLogging ?? false, detailedLogging: config?.detailedLogging ?? false,
maxActiveSessions: config?.maxActiveSessions ?? 10000, maxActiveSessions: config?.maxActiveSessions ?? 10000,
staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24, staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24,
}; };
this.storageManager = storageManager;
} }
/** /**
* Initialize the accounting manager * Initialize the accounting manager
*/ */
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.storageManager) { await this.loadActiveSessions();
await this.loadActiveSessions();
}
// Start periodic sweep to evict stale sessions (every 15 minutes) // Start periodic sweep to evict stale sessions (every 15 minutes)
this.staleSessionSweepTimer = setInterval(() => { this.staleSessionSweepTimer = setInterval(() => {
@@ -176,9 +169,7 @@ export class AccountingManager {
session.endTime = Date.now(); session.endTime = Date.now();
session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000); session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000);
if (this.storageManager) { this.persistSession(session).catch(() => {});
this.archiveSession(session).catch(() => {});
}
this.activeSessions.delete(sessionId); this.activeSessions.delete(sessionId);
swept++; swept++;
@@ -250,9 +241,7 @@ export class AccountingManager {
} }
// Persist session // Persist session
if (this.storageManager) { await this.persistSession(session);
await this.persistSession(session);
}
} }
/** /**
@@ -298,9 +287,7 @@ export class AccountingManager {
} }
// Update persisted session // Update persisted session
if (this.storageManager) { await this.persistSession(session);
await this.persistSession(session);
}
} }
/** /**
@@ -353,10 +340,8 @@ export class AccountingManager {
logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`); logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`);
} }
// Archive the session // Update status in the database (single collection, no active->archive move needed)
if (this.storageManager) { await this.persistSession(session);
await this.archiveSession(session);
}
// Remove from active sessions // Remove from active sessions
this.activeSessions.delete(data.sessionId); this.activeSessions.delete(data.sessionId);
@@ -493,23 +478,16 @@ export class AccountingManager {
* Clean up old archived sessions based on retention policy * Clean up old archived sessions based on retention policy
*/ */
async cleanupOldSessions(): Promise<number> { async cleanupOldSessions(): Promise<number> {
if (!this.storageManager) {
return 0;
}
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000; const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
let deletedCount = 0; let deletedCount = 0;
try { try {
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`); const oldDocs = await AccountingSessionDoc.findStoppedBefore(cutoffTime);
for (const key of keys) { for (const doc of oldDocs) {
try { try {
const session = await this.storageManager.getJSON<IAccountingSession>(key); await doc.delete();
if (session && session.endTime > 0 && session.endTime < cutoffTime) { deletedCount++;
await this.storageManager.delete(key);
deletedCount++;
}
} catch (error) { } catch (error) {
// Ignore individual errors // Ignore individual errors
} }
@@ -518,8 +496,8 @@ export class AccountingManager {
if (deletedCount > 0) { if (deletedCount > 0) {
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`); logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to cleanup old sessions: ${error.message}`); logger.log('error', `Failed to cleanup old sessions: ${(error as Error).message}`);
} }
return deletedCount; return deletedCount;
@@ -552,9 +530,7 @@ export class AccountingManager {
session.terminateCause = 'SessionEvicted'; session.terminateCause = 'SessionEvicted';
session.endTime = Date.now(); session.endTime = Date.now();
if (this.storageManager) { await this.persistSession(session);
await this.archiveSession(session);
}
this.activeSessions.delete(sessionId); this.activeSessions.delete(sessionId);
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`); logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
@@ -562,99 +538,101 @@ export class AccountingManager {
} }
/** /**
* Load active sessions from storage * Load active sessions from database
*/ */
private async loadActiveSessions(): Promise<void> { private async loadActiveSessions(): Promise<void> {
if (!this.storageManager) {
return;
}
try { try {
const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`); const docs = await AccountingSessionDoc.findActive();
for (const key of keys) { for (const doc of docs) {
try { const session: IAccountingSession = {
const session = await this.storageManager.getJSON<IAccountingSession>(key); sessionId: doc.sessionId,
if (session && session.status === 'active') { username: doc.username,
this.activeSessions.set(session.sessionId, session); macAddress: doc.macAddress,
} nasIpAddress: doc.nasIpAddress,
} catch (error) { nasPort: doc.nasPort,
// Ignore individual errors nasPortType: doc.nasPortType,
} nasIdentifier: doc.nasIdentifier,
vlanId: doc.vlanId,
framedIpAddress: doc.framedIpAddress,
calledStationId: doc.calledStationId,
callingStationId: doc.callingStationId,
startTime: doc.startTime,
endTime: doc.endTime,
lastUpdateTime: doc.lastUpdateTime,
status: doc.status,
terminateCause: doc.terminateCause,
inputOctets: doc.inputOctets,
outputOctets: doc.outputOctets,
inputPackets: doc.inputPackets,
outputPackets: doc.outputPackets,
sessionTime: doc.sessionTime,
serviceType: doc.serviceType,
};
this.activeSessions.set(session.sessionId, session);
} }
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Failed to load active sessions: ${error.message}`); logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`);
} }
} }
/** /**
* Persist a session to storage * Persist a session to the database (create or update)
*/ */
private async persistSession(session: IAccountingSession): Promise<void> { private async persistSession(session: IAccountingSession): Promise<void> {
if (!this.storageManager) {
return;
}
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
try { try {
await this.storageManager.setJSON(key, session); let doc = await AccountingSessionDoc.findBySessionId(session.sessionId);
} catch (error) { if (!doc) {
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`); doc = new AccountingSessionDoc();
}
Object.assign(doc, session);
await doc.save();
} catch (error: unknown) {
logger.log('error', `Failed to persist session ${session.sessionId}: ${(error as Error).message}`);
} }
} }
/** /**
* Archive a completed session * Get archived (stopped/terminated) sessions for a time period
*/
private async archiveSession(session: IAccountingSession): Promise<void> {
if (!this.storageManager) {
return;
}
try {
// Remove from active
const activeKey = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
await this.storageManager.delete(activeKey);
// Add to archive with date-based path
const date = new Date(session.endTime);
const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`;
await this.storageManager.setJSON(archiveKey, session);
} catch (error) {
logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`);
}
}
/**
* Get archived sessions for a time period
*/ */
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> { private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
if (!this.storageManager) {
return [];
}
const sessions: IAccountingSession[] = []; const sessions: IAccountingSession[] = [];
try { try {
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`); const docs = await AccountingSessionDoc.getInstances({
status: { $in: ['stopped', 'terminated'] } as any,
endTime: { $gt: 0, $gte: startTime } as any,
startTime: { $lte: endTime } as any,
});
for (const key of keys) { for (const doc of docs) {
try { sessions.push({
const session = await this.storageManager.getJSON<IAccountingSession>(key); sessionId: doc.sessionId,
if ( username: doc.username,
session && macAddress: doc.macAddress,
session.endTime > 0 && nasIpAddress: doc.nasIpAddress,
session.startTime <= endTime && nasPort: doc.nasPort,
session.endTime >= startTime nasPortType: doc.nasPortType,
) { nasIdentifier: doc.nasIdentifier,
sessions.push(session); vlanId: doc.vlanId,
} framedIpAddress: doc.framedIpAddress,
} catch (error) { calledStationId: doc.calledStationId,
// Ignore individual errors callingStationId: doc.callingStationId,
} startTime: doc.startTime,
endTime: doc.endTime,
lastUpdateTime: doc.lastUpdateTime,
status: doc.status,
terminateCause: doc.terminateCause,
inputOctets: doc.inputOctets,
outputOctets: doc.outputOctets,
inputPackets: doc.inputPackets,
outputPackets: doc.outputPackets,
sessionTime: doc.sessionTime,
serviceType: doc.serviceType,
});
} }
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Failed to get archived sessions: ${error.message}`); logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`);
} }
return sessions; return sessions;

View File

@@ -1,6 +1,5 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js'; import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js'; import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
@@ -92,7 +91,6 @@ export class RadiusServer {
private vlanManager: VlanManager; private vlanManager: VlanManager;
private accountingManager: AccountingManager; private accountingManager: AccountingManager;
private config: IRadiusServerConfig; private config: IRadiusServerConfig;
private storageManager?: StorageManager;
private clientSecrets: Map<string, string> = new Map(); private clientSecrets: Map<string, string> = new Map();
private running: boolean = false; private running: boolean = false;
@@ -105,20 +103,19 @@ export class RadiusServer {
startTime: 0, startTime: 0,
}; };
constructor(config: IRadiusServerConfig, storageManager?: StorageManager) { constructor(config: IRadiusServerConfig) {
this.config = { this.config = {
authPort: config.authPort ?? 1812, authPort: config.authPort ?? 1812,
acctPort: config.acctPort ?? 1813, acctPort: config.acctPort ?? 1813,
bindAddress: config.bindAddress ?? '0.0.0.0', bindAddress: config.bindAddress ?? '0.0.0.0',
...config, ...config,
}; };
this.storageManager = storageManager;
// Initialize VLAN manager // Initialize VLAN manager
this.vlanManager = new VlanManager(config.vlanAssignment, storageManager); this.vlanManager = new VlanManager(config.vlanAssignment);
// Initialize accounting manager // Initialize accounting manager
this.accountingManager = new AccountingManager(config.accounting, storageManager); this.accountingManager = new AccountingManager(config.accounting);
} }
/** /**
@@ -310,8 +307,8 @@ export class RadiusServer {
default: default:
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`); logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `RADIUS accounting error: ${error.message}`); logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
} }
return { code: plugins.smartradius.ERadiusCode.AccountingResponse }; return { code: plugins.smartradius.ERadiusCode.AccountingResponse };

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js'; import { VlanMappingsDoc } from '../db/index.js';
/** /**
* MAC address to VLAN mapping * MAC address to VLAN mapping
@@ -42,8 +42,6 @@ export interface IVlanManagerConfig {
defaultVlan?: number; defaultVlan?: number;
/** Whether to allow unknown MACs (assign default VLAN) or reject */ /** Whether to allow unknown MACs (assign default VLAN) or reject */
allowUnknownMacs?: boolean; allowUnknownMacs?: boolean;
/** Storage key prefix for persistence */
storagePrefix?: string;
} }
/** /**
@@ -56,27 +54,22 @@ export interface IVlanManagerConfig {
export class VlanManager { export class VlanManager {
private mappings: Map<string, IMacVlanMapping> = new Map(); private mappings: Map<string, IMacVlanMapping> = new Map();
private config: Required<IVlanManagerConfig>; private config: Required<IVlanManagerConfig>;
private storageManager?: StorageManager;
// Cache for normalized MAC lookups // Cache for normalized MAC lookups
private normalizedMacCache: Map<string, string> = new Map(); private normalizedMacCache: Map<string, string> = new Map();
constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) { constructor(config?: IVlanManagerConfig) {
this.config = { this.config = {
defaultVlan: config?.defaultVlan ?? 1, defaultVlan: config?.defaultVlan ?? 1,
allowUnknownMacs: config?.allowUnknownMacs ?? true, allowUnknownMacs: config?.allowUnknownMacs ?? true,
storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings',
}; };
this.storageManager = storageManager;
} }
/** /**
* Initialize the VLAN manager and load persisted mappings * Initialize the VLAN manager and load persisted mappings
*/ */
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.storageManager) { await this.loadMappings();
await this.loadMappings();
}
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`); logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
} }
@@ -104,7 +97,7 @@ export class VlanManager {
if (this.normalizedMacCache.size > 10000) { if (this.normalizedMacCache.size > 10000) {
const iterator = this.normalizedMacCache.keys(); const iterator = this.normalizedMacCache.keys();
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
this.normalizedMacCache.delete(iterator.next().value); this.normalizedMacCache.delete(iterator.next().value!);
} }
} }
@@ -157,10 +150,8 @@ export class VlanManager {
this.mappings.set(normalizedMac, fullMapping); this.mappings.set(normalizedMac, fullMapping);
// Persist to storage // Persist to database
if (this.storageManager) { await this.saveMappings();
await this.saveMappings();
}
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`); logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
return fullMapping; return fullMapping;
@@ -173,7 +164,7 @@ export class VlanManager {
const normalizedMac = this.normalizeMac(mac); const normalizedMac = this.normalizeMac(mac);
const removed = this.mappings.delete(normalizedMac); const removed = this.mappings.delete(normalizedMac);
if (removed && this.storageManager) { if (removed) {
await this.saveMappings(); await this.saveMappings();
logger.log('info', `VLAN mapping removed: ${normalizedMac}`); logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
} }
@@ -333,39 +324,36 @@ export class VlanManager {
} }
/** /**
* Load mappings from storage * Load mappings from database
*/ */
private async loadMappings(): Promise<void> { private async loadMappings(): Promise<void> {
if (!this.storageManager) {
return;
}
try { try {
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(this.config.storagePrefix); const doc = await VlanMappingsDoc.load();
if (data && Array.isArray(data)) { if (doc && Array.isArray(doc.mappings)) {
for (const mapping of data) { for (const mapping of doc.mappings) {
this.mappings.set(this.normalizeMac(mapping.mac), mapping); this.mappings.set(this.normalizeMac(mapping.mac), mapping);
} }
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`); logger.log('info', `Loaded ${doc.mappings.length} VLAN mappings from database`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`); logger.log('warn', `Failed to load VLAN mappings from database: ${(error as Error).message}`);
} }
} }
/** /**
* Save mappings to storage * Save mappings to database
*/ */
private async saveMappings(): Promise<void> { private async saveMappings(): Promise<void> {
if (!this.storageManager) {
return;
}
try { try {
const mappings = Array.from(this.mappings.values()); const mappings = Array.from(this.mappings.values());
await this.storageManager.setJSON(this.config.storagePrefix, mappings); let doc = await VlanMappingsDoc.load();
} catch (error) { if (!doc) {
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`); doc = new VlanMappingsDoc();
}
doc.mappings = mappings;
await doc.save();
} catch (error: unknown) {
logger.log('error', `Failed to save VLAN mappings to database: ${(error as Error).message}`);
} }
} }
} }

View File

@@ -6,7 +6,7 @@
* - VLAN assignment based on MAC addresses * - VLAN assignment based on MAC addresses
* - OUI (vendor prefix) pattern matching for device categorization * - OUI (vendor prefix) pattern matching for device categorization
* - RADIUS accounting for session tracking and billing * - RADIUS accounting for session tracking and billing
* - Integration with StorageManager for persistence * - Integration with smartdata document classes for persistence
*/ */
export * from './classes.radius.server.js'; export * from './classes.radius.server.js';

View File

@@ -136,7 +136,7 @@ Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks c
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

@@ -1,8 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type { StorageManager } from '../storage/classes.storagemanager.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc } from '../db/index.js';
const STORAGE_PREFIX = '/remote-ingress/';
/** /**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array. * Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
@@ -27,33 +25,40 @@ function extractPorts(portRange: number | Array<number | { from: number; to: num
/** /**
* Manages CRUD for remote ingress edge registrations. * Manages CRUD for remote ingress edge registrations.
* Persists edge configs via StorageManager and provides * Persists edge configs via smartdata document classes and provides
* the allowed edges list for the Rust hub. * the allowed edges list for the Rust hub.
*/ */
export class RemoteIngressManager { export class RemoteIngressManager {
private storageManager: StorageManager;
private edges: Map<string, IRemoteIngress> = new Map(); private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = []; private routes: IDcRouterRouteConfig[] = [];
constructor(storageManager: StorageManager) { constructor() {
this.storageManager = storageManager;
} }
/** /**
* Load all edge registrations from storage into memory. * Load all edge registrations from the database into memory.
*/ */
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
const keys = await this.storageManager.list(STORAGE_PREFIX); const docs = await RemoteIngressEdgeDoc.findAll();
for (const key of keys) { for (const doc of docs) {
const edge = await this.storageManager.getJSON<IRemoteIngress>(key); // Migration: old edges without autoDerivePorts default to true
if (edge) { if ((doc as any).autoDerivePorts === undefined) {
// Migration: old edges without autoDerivePorts default to true doc.autoDerivePorts = true;
if ((edge as any).autoDerivePorts === undefined) { await doc.save();
edge.autoDerivePorts = true;
await this.storageManager.setJSON(key, edge);
}
this.edges.set(edge.id, edge);
} }
const edge: IRemoteIngress = {
id: doc.id,
name: doc.name,
secret: doc.secret,
listenPorts: doc.listenPorts,
listenPortsUdp: doc.listenPortsUdp,
enabled: doc.enabled,
autoDerivePorts: doc.autoDerivePorts,
tags: doc.tags,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
this.edges.set(edge.id, edge);
} }
} }
@@ -189,7 +194,9 @@ export class RemoteIngressManager {
updatedAt: now, updatedAt: now,
}; };
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge); const doc = new RemoteIngressEdgeDoc();
Object.assign(doc, edge);
await doc.save();
this.edges.set(id, edge); this.edges.set(id, edge);
return edge; return edge;
} }
@@ -233,7 +240,11 @@ export class RemoteIngressManager {
if (updates.tags !== undefined) edge.tags = updates.tags; if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now(); edge.updatedAt = Date.now();
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge); const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
Object.assign(doc, edge);
await doc.save();
}
this.edges.set(id, edge); this.edges.set(id, edge);
return edge; return edge;
} }
@@ -245,7 +256,10 @@ export class RemoteIngressManager {
if (!this.edges.has(id)) { if (!this.edges.has(id)) {
return false; return false;
} }
await this.storageManager.delete(`${STORAGE_PREFIX}${id}`); const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
await doc.delete();
}
this.edges.delete(id); this.edges.delete(id);
return true; return true;
} }
@@ -262,7 +276,11 @@ export class RemoteIngressManager {
edge.secret = plugins.crypto.randomBytes(32).toString('hex'); edge.secret = plugins.crypto.randomBytes(32).toString('hex');
edge.updatedAt = Date.now(); edge.updatedAt = Date.now();
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge); const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
Object.assign(doc, edge);
await doc.save();
}
this.edges.set(id, edge); this.edges.set(id, edge);
return edge.secret; return edge.secret;
} }

View File

@@ -60,7 +60,7 @@ export enum ThreatCategory {
* Content Scanner for detecting malicious email content * Content Scanner for detecting malicious email content
*/ */
export class ContentScanner { export class ContentScanner {
private static instance: ContentScanner; private static instance: ContentScanner | undefined;
private scanCache: LRUCache<string, IScanResult>; private scanCache: LRUCache<string, IScanResult>;
private options: Required<IContentScannerOptions>; private options: Required<IContentScannerOptions>;
@@ -258,12 +258,12 @@ export class ContentScanner {
} }
return result; return result;
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error scanning email: ${error.message}`, { logger.log('error', `Error scanning email: ${(error as Error).message}`, {
messageId: email.getMessageId(), messageId: email.getMessageId(),
error: error.stack error: (error as Error).stack
}); });
// Return a safe default with error indication // Return a safe default with error indication
return { return {
isClean: true, // Let it pass if scanner fails (configure as desired) isClean: true, // Let it pass if scanner fails (configure as desired)
@@ -271,7 +271,7 @@ export class ContentScanner {
scannedElements: ['error'], scannedElements: ['error'],
timestamp: Date.now(), timestamp: Date.now(),
threatType: 'scan_error', threatType: 'scan_error',
threatDetails: `Scan error: ${error.message}` threatDetails: `Scan error: ${(error as Error).message}`
}; };
} }
} }
@@ -625,8 +625,8 @@ export class ContentScanner {
return sample.toString('utf8') return sample.toString('utf8')
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars .replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
.replace(/\uFFFD/g, ''); // Remove replacement char .replace(/\uFFFD/g, ''); // Remove replacement char
} catch (error) { } catch (error: unknown) {
logger.log('warn', `Error extracting text from buffer: ${error.message}`); logger.log('warn', `Error extracting text from buffer: ${(error as Error).message}`);
return ''; return '';
} }
} }
@@ -699,10 +699,10 @@ export class ContentScanner {
subject: email.subject subject: email.subject
}, },
success: false, success: false,
domain: email.getFromDomain() domain: email.getFromDomain() ?? undefined
}); });
} }
/** /**
* Log a threat finding to the security logger * Log a threat finding to the security logger
* @param email The email containing the threat * @param email The email containing the threat
@@ -722,10 +722,10 @@ export class ContentScanner {
subject: email.subject subject: email.subject
}, },
success: false, success: false,
domain: email.getFromDomain() domain: email.getFromDomain() ?? undefined
}); });
} }
/** /**
* Get threat level description based on score * Get threat level description based on score
* @param score Threat score * @param score Threat score

View File

@@ -1,8 +1,8 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js'; import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { CachedIPReputation } from '../db/documents/classes.cached.ip.reputation.js';
/** /**
* Reputation check result information * Reputation check result information
@@ -52,7 +52,7 @@ export interface IIPReputationOptions {
highRiskThreshold?: number; // Score below this is high risk highRiskThreshold?: number; // Score below this is high risk
mediumRiskThreshold?: number; // Score below this is medium risk mediumRiskThreshold?: number; // Score below this is medium risk
lowRiskThreshold?: number; // Score below this is low risk lowRiskThreshold?: number; // Score below this is low risk
enableLocalCache?: boolean; // Whether to persist cache to disk (default: true) enableLocalCache?: boolean; // Whether to persist cache to database (default: true)
enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true) enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true)
enableIPInfo?: boolean; // Whether to use IP info service (default: true) enableIPInfo?: boolean; // Whether to use IP info service (default: true)
} }
@@ -61,13 +61,10 @@ export interface IIPReputationOptions {
* Class for checking IP reputation of inbound email senders * Class for checking IP reputation of inbound email senders
*/ */
export class IPReputationChecker { export class IPReputationChecker {
private static instance: IPReputationChecker; private static instance: IPReputationChecker | undefined;
private reputationCache: LRUCache<string, IReputationResult>; private reputationCache: LRUCache<string, IReputationResult>;
private options: Required<IIPReputationOptions>; private options: Required<IIPReputationOptions>;
private storageManager?: any; // StorageManager instance
private saveCacheTimer: ReturnType<typeof setTimeout> | null = null;
private static readonly SAVE_CACHE_DEBOUNCE_MS = 30_000;
// Default DNSBL servers // Default DNSBL servers
private static readonly DEFAULT_DNSBL_SERVERS = [ private static readonly DEFAULT_DNSBL_SERVERS = [
'zen.spamhaus.org', // Spamhaus 'zen.spamhaus.org', // Spamhaus
@@ -75,13 +72,13 @@ export class IPReputationChecker {
'b.barracudacentral.org', // Barracuda 'b.barracudacentral.org', // Barracuda
'spam.dnsbl.sorbs.net', // SORBS 'spam.dnsbl.sorbs.net', // SORBS
'dnsbl.sorbs.net', // SORBS (expanded) 'dnsbl.sorbs.net', // SORBS (expanded)
'cbl.abuseat.org', // Composite Blocking List 'cbl.abuseat.org', // Composite Blocking List
'xbl.spamhaus.org', // Spamhaus XBL 'xbl.spamhaus.org', // Spamhaus XBL
'pbl.spamhaus.org', // Spamhaus PBL 'pbl.spamhaus.org', // Spamhaus PBL
'dnsbl-1.uceprotect.net', // UCEPROTECT 'dnsbl-1.uceprotect.net', // UCEPROTECT
'psbl.surriel.com' // PSBL 'psbl.surriel.com' // PSBL
]; ];
// Default options // Default options
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = { private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
maxCacheSize: 10000, maxCacheSize: 10000,
@@ -94,54 +91,40 @@ export class IPReputationChecker {
enableDNSBL: true, enableDNSBL: true,
enableIPInfo: true enableIPInfo: true
}; };
/** /**
* Constructor for IPReputationChecker * Constructor for IPReputationChecker
* @param options Configuration options * @param options Configuration options
* @param storageManager Optional StorageManager instance for persistence
*/ */
constructor(options: IIPReputationOptions = {}, storageManager?: any) { constructor(options: IIPReputationOptions = {}) {
// Merge with default options // Merge with default options
this.options = { this.options = {
...IPReputationChecker.DEFAULT_OPTIONS, ...IPReputationChecker.DEFAULT_OPTIONS,
...options ...options
}; };
this.storageManager = storageManager;
// If no storage manager provided, log warning
if (!storageManager && this.options.enableLocalCache) {
logger.log('warn',
'⚠️ WARNING: IPReputationChecker initialized without StorageManager.\n' +
' IP reputation cache will only be stored to filesystem.\n' +
' Consider passing a StorageManager instance for better storage flexibility.'
);
}
// Initialize reputation cache // Initialize reputation cache
this.reputationCache = new LRUCache<string, IReputationResult>({ this.reputationCache = new LRUCache<string, IReputationResult>({
max: this.options.maxCacheSize, max: this.options.maxCacheSize,
ttl: this.options.cacheTTL, // Cache TTL ttl: this.options.cacheTTL, // Cache TTL
}); });
// Load cache from disk if enabled // Load persisted reputations into in-memory cache
if (this.options.enableLocalCache) { if (this.options.enableLocalCache) {
// Fire and forget the load operation this.loadCacheFromDb().catch((error: unknown) => {
this.loadCache().catch(error => { logger.log('error', `Failed to load IP reputation cache during initialization: ${(error as Error).message}`);
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
}); });
} }
} }
/** /**
* Get the singleton instance of the checker * Get the singleton instance of the checker
* @param options Configuration options * @param options Configuration options
* @param storageManager Optional StorageManager instance for persistence
* @returns Singleton instance * @returns Singleton instance
*/ */
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker { public static getInstance(options: IIPReputationOptions = {}): IPReputationChecker {
if (!IPReputationChecker.instance) { if (!IPReputationChecker.instance) {
IPReputationChecker.instance = new IPReputationChecker(options, storageManager); IPReputationChecker.instance = new IPReputationChecker(options);
} }
return IPReputationChecker.instance; return IPReputationChecker.instance;
} }
@@ -150,12 +133,6 @@ export class IPReputationChecker {
* Reset the singleton instance (for shutdown/testing) * Reset the singleton instance (for shutdown/testing)
*/ */
public static resetInstance(): void { public static resetInstance(): void {
if (IPReputationChecker.instance) {
if (IPReputationChecker.instance.saveCacheTimer) {
clearTimeout(IPReputationChecker.instance.saveCacheTimer);
IPReputationChecker.instance.saveCacheTimer = null;
}
}
IPReputationChecker.instance = undefined; IPReputationChecker.instance = undefined;
} }
@@ -171,8 +148,8 @@ export class IPReputationChecker {
logger.log('warn', `Invalid IP address format: ${ip}`); logger.log('warn', `Invalid IP address format: ${ip}`);
return this.createErrorResult(ip, 'Invalid IP address format'); return this.createErrorResult(ip, 'Invalid IP address format');
} }
// Check cache first // Check in-memory LRU cache first (fast path)
const cachedResult = this.reputationCache.get(ip); const cachedResult = this.reputationCache.get(ip);
if (cachedResult) { if (cachedResult) {
logger.log('info', `Using cached reputation data for IP ${ip}`, { logger.log('info', `Using cached reputation data for IP ${ip}`, {
@@ -181,7 +158,7 @@ export class IPReputationChecker {
}); });
return cachedResult; return cachedResult;
} }
// Initialize empty result // Initialize empty result
const result: IReputationResult = { const result: IReputationResult = {
score: 100, // Start with perfect score score: 100, // Start with perfect score
@@ -191,62 +168,64 @@ export class IPReputationChecker {
isVPN: false, isVPN: false,
timestamp: Date.now() timestamp: Date.now()
}; };
// Check IP against DNS blacklists if enabled // Check IP against DNS blacklists if enabled
if (this.options.enableDNSBL) { if (this.options.enableDNSBL) {
const dnsblResult = await this.checkDNSBL(ip); const dnsblResult = await this.checkDNSBL(ip);
// Update result with DNSBL information // Update result with DNSBL information
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
result.isSpam = dnsblResult.listCount > 0; result.isSpam = dnsblResult.listCount > 0;
result.blacklists = dnsblResult.lists; result.blacklists = dnsblResult.lists;
} }
// Get additional IP information if enabled // Get additional IP information if enabled
if (this.options.enableIPInfo) { if (this.options.enableIPInfo) {
const ipInfo = await this.getIPInfo(ip); const ipInfo = await this.getIPInfo(ip);
// Update result with IP info // Update result with IP info
result.country = ipInfo.country; result.country = ipInfo.country;
result.asn = ipInfo.asn; result.asn = ipInfo.asn;
result.org = ipInfo.org; result.org = ipInfo.org;
// Adjust score based on IP type // Adjust score based on IP type
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) { if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
// Set proxy flags // Set proxy flags
result.isProxy = ipInfo.type === IPType.PROXY; result.isProxy = ipInfo.type === IPType.PROXY;
result.isTor = ipInfo.type === IPType.TOR; result.isTor = ipInfo.type === IPType.TOR;
result.isVPN = ipInfo.type === IPType.VPN; result.isVPN = ipInfo.type === IPType.VPN;
} }
} }
// Ensure score is between 0 and 100 // Ensure score is between 0 and 100
result.score = Math.max(0, Math.min(100, result.score)); result.score = Math.max(0, Math.min(100, result.score));
// Update cache with result // Update in-memory LRU cache
this.reputationCache.set(ip, result); this.reputationCache.set(ip, result);
// Schedule debounced cache save if enabled // Persist to database if enabled (fire and forget)
if (this.options.enableLocalCache) { if (this.options.enableLocalCache) {
this.debouncedSaveCache(); this.persistReputationToDb(ip, result).catch((error: unknown) => {
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
});
} }
// Log the reputation check // Log the reputation check
this.logReputationCheck(ip, result); this.logReputationCheck(ip, result);
return result; return result;
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, { logger.log('error', `Error checking IP reputation for ${ip}: ${(error as Error).message}`, {
ip, ip,
stack: error.stack stack: (error as Error).stack
}); });
return this.createErrorResult(ip, error.message); return this.createErrorResult(ip, (error as Error).message);
} }
} }
/** /**
* Check an IP against DNS blacklists * Check an IP against DNS blacklists
* @param ip IP address to check * @param ip IP address to check
@@ -259,42 +238,42 @@ export class IPReputationChecker {
try { try {
// Reverse the IP for DNSBL queries // Reverse the IP for DNSBL queries
const reversedIP = this.reverseIP(ip); const reversedIP = this.reverseIP(ip);
const results = await Promise.allSettled( const results = await Promise.allSettled(
this.options.dnsblServers.map(async (server) => { this.options.dnsblServers.map(async (server) => {
try { try {
const lookupDomain = `${reversedIP}.${server}`; const lookupDomain = `${reversedIP}.${server}`;
await plugins.dns.promises.resolve(lookupDomain); await plugins.dns.promises.resolve(lookupDomain);
return server; // IP is listed in this DNSBL return server; // IP is listed in this DNSBL
} catch (error) { } catch (error: unknown) {
if (error.code === 'ENOTFOUND') { if ((error as any).code === 'ENOTFOUND') {
return null; // IP is not listed in this DNSBL return null; // IP is not listed in this DNSBL
} }
throw error; // Other error throw error; // Other error
} }
}) })
); );
// Extract successful lookups (listed in DNSBL) // Extract successful lookups (listed in DNSBL)
const lists = results const lists = results
.filter((result): result is PromiseFulfilledResult<string> => .filter((result): result is PromiseFulfilledResult<string> =>
result.status === 'fulfilled' && result.value !== null result.status === 'fulfilled' && result.value !== null
) )
.map(result => result.value); .map(result => result.value);
return { return {
listCount: lists.length, listCount: lists.length,
lists lists
}; };
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`); logger.log('error', `Error checking DNSBL for ${ip}: ${(error as Error).message}`);
return { return {
listCount: 0, listCount: 0,
lists: [] lists: []
}; };
} }
} }
/** /**
* Get information about an IP address * Get information about an IP address
* @param ip IP address to check * @param ip IP address to check
@@ -309,16 +288,16 @@ export class IPReputationChecker {
try { try {
// In a real implementation, this would use an IP data service API // In a real implementation, this would use an IP data service API
// For this implementation, we'll use a simplified approach // For this implementation, we'll use a simplified approach
// Check if it's a known Tor exit node (simplified) // Check if it's a known Tor exit node (simplified)
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.'); const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
// Check if it's a known VPN (simplified) // Check if it's a known VPN (simplified)
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.'); const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
// Check if it's a known proxy (simplified) // Check if it's a known proxy (simplified)
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.'); const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
// Determine IP type // Determine IP type
let type = IPType.UNKNOWN; let type = IPType.UNKNOWN;
if (isTor) { if (isTor) {
@@ -341,7 +320,7 @@ export class IPReputationChecker {
type = IPType.RESIDENTIAL; type = IPType.RESIDENTIAL;
} }
} }
// Return the information // Return the information
return { return {
country: this.determineCountry(ip), // Simplified, would use geolocation service country: this.determineCountry(ip), // Simplified, would use geolocation service
@@ -349,14 +328,14 @@ export class IPReputationChecker {
org: this.determineOrg(ip), // Simplified, would use real org data org: this.determineOrg(ip), // Simplified, would use real org data
type type
}; };
} catch (error) { } catch (error: unknown) {
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`); logger.log('error', `Error getting IP info for ${ip}: ${(error as Error).message}`);
return { return {
type: IPType.UNKNOWN type: IPType.UNKNOWN
}; };
} }
} }
/** /**
* Simplified method to determine country from IP * Simplified method to determine country from IP
* In a real implementation, this would use a geolocation database or service * In a real implementation, this would use a geolocation database or service
@@ -371,7 +350,7 @@ export class IPReputationChecker {
if (ip.startsWith('171.')) return 'DE'; if (ip.startsWith('171.')) return 'DE';
return 'XX'; // Unknown return 'XX'; // Unknown
} }
/** /**
* Simplified method to determine organization from IP * Simplified method to determine organization from IP
* In a real implementation, this would use an IP-to-org database or service * In a real implementation, this would use an IP-to-org database or service
@@ -387,7 +366,7 @@ export class IPReputationChecker {
if (ip.startsWith('185.220.')) return 'Tor Exit Node'; if (ip.startsWith('185.220.')) return 'Tor Exit Node';
return 'Unknown'; return 'Unknown';
} }
/** /**
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1) * Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
* @param ip IP address to reverse * @param ip IP address to reverse
@@ -396,7 +375,7 @@ export class IPReputationChecker {
private reverseIP(ip: string): string { private reverseIP(ip: string): string {
return ip.split('.').reverse().join('.'); return ip.split('.').reverse().join('.');
} }
/** /**
* Create an error result for when reputation check fails * Create an error result for when reputation check fails
* @param ip IP address * @param ip IP address
@@ -414,7 +393,7 @@ export class IPReputationChecker {
error: errorMessage error: errorMessage
}; };
} }
/** /**
* Validate IP address format * Validate IP address format
* @param ip IP address to validate * @param ip IP address to validate
@@ -425,7 +404,7 @@ export class IPReputationChecker {
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipv4Pattern.test(ip); return ipv4Pattern.test(ip);
} }
/** /**
* Log reputation check to security logger * Log reputation check to security logger
* @param ip IP address * @param ip IP address
@@ -439,7 +418,7 @@ export class IPReputationChecker {
} else if (result.score < this.options.mediumRiskThreshold) { } else if (result.score < this.options.mediumRiskThreshold) {
logLevel = SecurityLogLevel.INFO; logLevel = SecurityLogLevel.INFO;
} }
// Log the check // Log the check
SecurityLogger.getInstance().logEvent({ SecurityLogger.getInstance().logEvent({
level: logLevel, level: logLevel,
@@ -458,131 +437,76 @@ export class IPReputationChecker {
success: !result.isSpam success: !result.isSpam
}); });
} }
/** /**
* Schedule a debounced cache save (at most once per SAVE_CACHE_DEBOUNCE_MS) * Persist a single IP reputation result to the database via CachedIPReputation
*/ */
private debouncedSaveCache(): void { private async persistReputationToDb(ip: string, result: IReputationResult): Promise<void> {
if (this.saveCacheTimer) { try {
return; // already scheduled const data = {
score: result.score,
isSpam: result.isSpam,
isProxy: result.isProxy,
isTor: result.isTor,
isVPN: result.isVPN,
country: result.country,
asn: result.asn,
org: result.org,
blacklists: result.blacklists,
};
const existing = await CachedIPReputation.findByIP(ip);
if (existing) {
existing.updateReputation(data);
await existing.save();
} else {
const doc = CachedIPReputation.fromReputationData(ip, data);
await doc.save();
}
} catch (error: unknown) {
logger.log('error', `Failed to persist IP reputation for ${ip}: ${(error as Error).message}`);
} }
this.saveCacheTimer = setTimeout(() => {
this.saveCacheTimer = null;
this.saveCache().catch(error => {
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
});
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
} }
/** /**
* Save cache to disk or storage manager * Load persisted reputations from CachedIPReputation documents into the in-memory LRU cache
*/ */
private async saveCache(): Promise<void> { private async loadCacheFromDb(): Promise<void> {
try { try {
// Convert cache entries to serializable array const docs = await CachedIPReputation.getInstances({});
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({ let loadedCount = 0;
ip,
data for (const doc of docs) {
})); // Skip expired documents
if (doc.isExpired()) {
// Only save if we have entries continue;
if (entries.length === 0) { }
return;
const result: IReputationResult = {
score: doc.score,
isSpam: doc.isSpam,
isProxy: doc.isProxy,
isTor: doc.isTor,
isVPN: doc.isVPN,
country: doc.country || undefined,
asn: doc.asn || undefined,
org: doc.org || undefined,
blacklists: doc.blacklists || [],
timestamp: doc.lastAccessedAt?.getTime() ?? doc.createdAt?.getTime() ?? Date.now(),
};
this.reputationCache.set(doc.ipAddress, result);
loadedCount++;
} }
const cacheData = JSON.stringify(entries); if (loadedCount > 0) {
logger.log('info', `Loaded ${loadedCount} IP reputation cache entries from database`);
// Save to storage manager if available
if (this.storageManager) {
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
} else {
// Fall back to filesystem
const cacheDir = plugins.path.join(paths.dataDir, 'security');
plugins.fsUtils.ensureDirSync(cacheDir);
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
plugins.fsUtils.toFsSync(cacheData, cacheFile);
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
} }
} catch (error) { } catch (error: unknown) {
logger.log('error', `Failed to save IP reputation cache: ${error.message}`); logger.log('error', `Failed to load IP reputation cache from database: ${(error as Error).message}`);
} }
} }
/**
* Load cache from disk or storage manager
*/
private async loadCache(): Promise<void> {
try {
let cacheData: string | null = null;
let fromFilesystem = false;
// Try to load from storage manager first
if (this.storageManager) {
try {
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
if (!cacheData) {
// Check if data exists in filesystem and migrate it
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
if (plugins.fs.existsSync(cacheFile)) {
logger.log('info', 'Migrating IP reputation cache from filesystem to StorageManager');
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
fromFilesystem = true;
// Migrate to storage manager
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
logger.log('info', 'IP reputation cache migrated to StorageManager successfully');
// Optionally delete the old file after successful migration
try {
plugins.fs.unlinkSync(cacheFile);
logger.log('info', 'Old cache file removed after migration');
} catch (deleteError) {
logger.log('warn', `Could not delete old cache file: ${deleteError.message}`);
}
}
}
} catch (error) {
logger.log('error', `Error loading from StorageManager: ${error.message}`);
}
} else {
// No storage manager, load from filesystem
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
if (plugins.fs.existsSync(cacheFile)) {
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
fromFilesystem = true;
}
}
// Parse and restore cache if data was found
if (cacheData) {
const entries = JSON.parse(cacheData);
// Validate and filter entries
const now = Date.now();
const validEntries = entries.filter(entry => {
const age = now - entry.data.timestamp;
return age < this.options.cacheTTL; // Only load entries that haven't expired
});
// Restore cache
for (const entry of validEntries) {
this.reputationCache.set(entry.ip, entry.data);
}
const source = fromFilesystem ? 'disk' : 'StorageManager';
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`);
}
} catch (error) {
logger.log('error', `Failed to load IP reputation cache: ${error.message}`);
}
}
/** /**
* Get the risk level for a reputation score * Get the risk level for a reputation score
* @param score Reputation score (0-100) * @param score Reputation score (0-100)
@@ -599,21 +523,4 @@ export class IPReputationChecker {
return 'trusted'; return 'trusted';
} }
} }
}
/**
* Update the storage manager after instantiation
* This is useful when the storage manager is not available at construction time
* @param storageManager The StorageManager instance to use
*/
public updateStorageManager(storageManager: any): void {
this.storageManager = storageManager;
logger.log('info', 'IPReputationChecker storage manager updated');
// If cache is enabled and we have entries, save them to the new storage manager
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
this.saveCache().catch(error => {
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
});
}
}
}

View File

@@ -58,7 +58,7 @@ export interface ISecurityEvent {
* Security logger for enhanced security monitoring * Security logger for enhanced security monitoring
*/ */
export class SecurityLogger { export class SecurityLogger {
private static instance: SecurityLogger; private static instance: SecurityLogger | undefined;
private securityEvents: ISecurityEvent[] = []; private securityEvents: ISecurityEvent[] = [];
private maxEventHistory: number; private maxEventHistory: number;
private enableNotifications: boolean; private enableNotifications: boolean;
@@ -154,11 +154,13 @@ export class SecurityLogger {
} }
if (filter.fromTimestamp) { if (filter.fromTimestamp) {
filteredEvents = filteredEvents.filter(event => event.timestamp >= filter.fromTimestamp); const fromTs = filter.fromTimestamp;
filteredEvents = filteredEvents.filter(event => event.timestamp >= fromTs);
} }
if (filter.toTimestamp) { if (filter.toTimestamp) {
filteredEvents = filteredEvents.filter(event => event.timestamp <= filter.toTimestamp); const toTs = filter.toTimestamp;
filteredEvents = filteredEvents.filter(event => event.timestamp <= toTs);
} }
} }

View File

@@ -7,7 +7,7 @@ import { smsConfigSchema } from './config/sms.schema.js';
import { ConfigValidator } from '../config/validator.js'; import { ConfigValidator } from '../config/validator.js';
export class SmsService { export class SmsService {
public projectinfo: plugins.projectinfo.ProjectInfo; public projectinfo!: plugins.projectinfo.ProjectInfo;
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
public config: ISmsConfig; public config: ISmsConfig;
@@ -16,7 +16,7 @@ export class SmsService {
const validationResult = ConfigValidator.validate(options, smsConfigSchema); const validationResult = ConfigValidator.validate(options, smsConfigSchema);
if (!validationResult.valid) { if (!validationResult.valid) {
logger.warn(`SMS service configuration has validation errors: ${validationResult.errors.join(', ')}`); logger.warn(`SMS service configuration has validation errors: ${validationResult.errors!.join(', ')}`);
} }
// Set configuration with defaults // Set configuration with defaults
@@ -30,7 +30,7 @@ export class SmsService {
*/ */
public async start() { public async start() {
logger.log('info', `starting sms service`); logger.log('info', `starting sms service`);
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir); this.projectinfo = await plugins.projectinfo.ProjectInfo.create(paths.packageDir);
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendSms>( new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendSms>(
'sendSms', 'sendSms',

View File

@@ -1,406 +0,0 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
// Promisify filesystem operations
const readFile = plugins.util.promisify(plugins.fs.readFile);
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
const unlink = plugins.util.promisify(plugins.fs.unlink);
const rename = plugins.util.promisify(plugins.fs.rename);
const readdir = plugins.util.promisify(plugins.fs.readdir);
/**
* Storage configuration interface
*/
export interface IStorageConfig {
/** Filesystem path for storage */
fsPath?: string;
/** Custom read function */
readFunction?: (key: string) => Promise<string>;
/** Custom write function */
writeFunction?: (key: string, value: string) => Promise<void>;
}
/**
* Storage backend type
*/
export type StorageBackend = 'filesystem' | 'custom' | 'memory';
/**
* Central storage manager for DcRouter
* Provides unified key-value storage with multiple backend support
*/
export class StorageManager {
private static readonly MAX_MEMORY_ENTRIES = 10_000;
private backend: StorageBackend;
private memoryStore: Map<string, string> = new Map();
private config: IStorageConfig;
private fsBasePath?: string;
constructor(config?: IStorageConfig) {
this.config = config || {};
// Check if both fsPath and custom functions are provided
if (config?.fsPath && (config?.readFunction || config?.writeFunction)) {
console.warn(
'⚠️ WARNING: Both fsPath and custom read/write functions are configured.\n' +
' Using custom read/write functions. fsPath will be ignored.'
);
}
// Determine backend based on configuration
if (config?.readFunction && config?.writeFunction) {
this.backend = 'custom';
} else if (config?.fsPath) {
// Set up internal read/write functions for filesystem
this.backend = 'custom'; // Use custom backend with internal functions
this.fsBasePath = plugins.path.resolve(config.fsPath);
this.ensureDirectory(this.fsBasePath);
// Set up internal filesystem read/write functions
this.config.readFunction = async (key: string) => {
return this.fsRead(key);
};
this.config.writeFunction = async (key: string, value: string) => {
await this.fsWrite(key, value);
};
} else {
this.backend = 'memory';
this.showMemoryWarning();
}
logger.log('info', `StorageManager initialized with ${this.backend} backend`);
}
/**
* Show warning when using memory backend
*/
private showMemoryWarning(): void {
console.warn(
'⚠️ WARNING: StorageManager is using in-memory storage.\n' +
' Data will be lost when the process restarts.\n' +
' Configure storage.fsPath or storage functions for persistence.'
);
}
/**
* Ensure directory exists for filesystem backend
*/
private async ensureDirectory(dirPath: string): Promise<void> {
try {
await plugins.fsUtils.ensureDir(dirPath);
} catch (error) {
logger.log('error', `Failed to create storage directory: ${error.message}`);
throw error;
}
}
/**
* Validate and sanitize storage key
*/
private validateKey(key: string): string {
if (!key || typeof key !== 'string') {
throw new Error('Storage key must be a non-empty string');
}
// Ensure key starts with /
if (!key.startsWith('/')) {
key = '/' + key;
}
// Remove any dangerous path elements
key = key.replace(/\.\./g, '').replace(/\/+/g, '/');
return key;
}
/**
* Convert key to filesystem path
*/
private keyToPath(key: string): string {
if (!this.fsBasePath) {
throw new Error('Filesystem base path not configured');
}
// Remove leading slash and convert to path
const relativePath = key.substring(1);
return plugins.path.join(this.fsBasePath, relativePath);
}
/**
* Internal filesystem read function
*/
private async fsRead(key: string): Promise<string> {
const filePath = this.keyToPath(key);
try {
const content = await readFile(filePath, 'utf8');
return content;
} catch (error) {
if (error.code === 'ENOENT') {
return null;
}
throw error;
}
}
/**
* Internal filesystem write function
*/
private async fsWrite(key: string, value: string): Promise<void> {
const filePath = this.keyToPath(key);
const dir = plugins.path.dirname(filePath);
// Ensure directory exists
await plugins.fsUtils.ensureDir(dir);
// Write atomically with temp file
const tempPath = `${filePath}.tmp`;
await writeFile(tempPath, value, 'utf8');
await rename(tempPath, filePath);
}
/**
* Get value by key
*/
async get(key: string): Promise<string | null> {
key = this.validateKey(key);
try {
switch (this.backend) {
case 'custom': {
if (!this.config.readFunction) {
throw new Error('Read function not configured');
}
try {
return await this.config.readFunction(key);
} catch (error) {
// Assume null if read fails (key doesn't exist)
return null;
}
}
case 'memory': {
return this.memoryStore.get(key) || null;
}
default:
throw new Error(`Unknown backend: ${this.backend}`);
}
} catch (error) {
logger.log('error', `Storage get error for key ${key}: ${error.message}`);
throw error;
}
}
/**
* Set value by key
*/
async set(key: string, value: string): Promise<void> {
key = this.validateKey(key);
if (typeof value !== 'string') {
throw new Error('Storage value must be a string');
}
try {
switch (this.backend) {
case 'filesystem': {
const filePath = this.keyToPath(key);
const dirPath = plugins.path.dirname(filePath);
// Ensure directory exists
await plugins.fsUtils.ensureDir(dirPath);
// Write atomically
const tempPath = filePath + '.tmp';
await writeFile(tempPath, value, 'utf8');
await rename(tempPath, filePath);
break;
}
case 'custom': {
if (!this.config.writeFunction) {
throw new Error('Write function not configured');
}
await this.config.writeFunction(key, value);
break;
}
case 'memory': {
this.memoryStore.set(key, value);
// Evict oldest entries if memory store exceeds limit
while (this.memoryStore.size > StorageManager.MAX_MEMORY_ENTRIES) {
const firstKey = this.memoryStore.keys().next().value;
this.memoryStore.delete(firstKey);
}
break;
}
default:
throw new Error(`Unknown backend: ${this.backend}`);
}
} catch (error) {
logger.log('error', `Storage set error for key ${key}: ${error.message}`);
throw error;
}
}
/**
* Delete value by key
*/
async delete(key: string): Promise<void> {
key = this.validateKey(key);
try {
switch (this.backend) {
case 'filesystem': {
const filePath = this.keyToPath(key);
try {
await unlink(filePath);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
break;
}
case 'custom': {
// Try to delete by setting empty value
if (this.config.writeFunction) {
await this.config.writeFunction(key, '');
}
break;
}
case 'memory': {
this.memoryStore.delete(key);
break;
}
default:
throw new Error(`Unknown backend: ${this.backend}`);
}
} catch (error) {
logger.log('error', `Storage delete error for key ${key}: ${error.message}`);
throw error;
}
}
/**
* List keys by prefix
*/
async list(prefix?: string): Promise<string[]> {
prefix = prefix ? this.validateKey(prefix) : '/';
try {
switch (this.backend) {
case 'custom': {
// If we have fsBasePath, this is actually filesystem backend
if (this.fsBasePath) {
const basePath = this.keyToPath(prefix);
const keys: string[] = [];
const walkDir = async (dir: string, baseDir: string): Promise<void> => {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = plugins.path.join(dir, entry.name);
if (entry.isDirectory()) {
await walkDir(fullPath, baseDir);
} else if (entry.isFile()) {
// Convert path back to key
const relativePath = plugins.path.relative(this.fsBasePath!, fullPath);
const key = '/' + relativePath.replace(/\\/g, '/');
if (key.startsWith(prefix)) {
keys.push(key);
}
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
};
await walkDir(basePath, basePath);
return keys.sort();
} else {
// True custom backends need to implement their own listing
logger.log('warn', 'List operation not supported for custom backend');
return [];
}
}
case 'memory': {
const keys: string[] = [];
for (const key of this.memoryStore.keys()) {
if (key.startsWith(prefix)) {
keys.push(key);
}
}
return keys.sort();
}
default:
throw new Error(`Unknown backend: ${this.backend}`);
}
} catch (error) {
logger.log('error', `Storage list error for prefix ${prefix}: ${error.message}`);
throw error;
}
}
/**
* Check if key exists
*/
async exists(key: string): Promise<boolean> {
key = this.validateKey(key);
try {
const value = await this.get(key);
return value !== null;
} catch (error) {
return false;
}
}
/**
* Get storage backend type
*/
getBackend(): StorageBackend {
// If we're using custom backend with fsBasePath, report it as filesystem
if (this.backend === 'custom' && this.fsBasePath) {
return 'filesystem' as StorageBackend;
}
return this.backend;
}
/**
* JSON helper: Get and parse JSON value
*/
async getJSON<T = any>(key: string): Promise<T | null> {
const value = await this.get(key);
if (value === null || value.trim() === '') {
return null;
}
try {
return JSON.parse(value) as T;
} catch (error) {
logger.log('error', `Failed to parse JSON for key ${key}: ${error.message}`);
throw error;
}
}
/**
* JSON helper: Set value as JSON
*/
async setJSON(key: string, value: any): Promise<void> {
const jsonString = JSON.stringify(value, null, 2);
await this.set(key, jsonString);
}
}

View File

@@ -1,2 +0,0 @@
// Storage module exports
export * from './classes.storagemanager.js';

View File

@@ -0,0 +1,567 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { VpnServerKeysDoc, VpnClientDoc } from '../db/index.js';
export interface IVpnManagerConfig {
/** VPN subnet CIDR (default: '10.8.0.0/24') */
subnet?: string;
/** WireGuard UDP listen port (default: 51820) */
wgListenPort?: number;
/** DNS servers pushed to VPN clients */
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
initialClients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
target?: string;
allowList?: string[];
blockList?: string[];
};
/** Compute per-client AllowedIPs based on the client's server-defined tags.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
/** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */
bridgeLanSubnet?: string;
/** Physical network interface for bridge mode (auto-detected if omitted) */
bridgePhysicalInterface?: string;
/** Start of VPN client IP range in LAN subnet (host offset, default: 200) */
bridgeIpRangeStart?: number;
/** End of VPN client IP range in LAN subnet (host offset, default: 250) */
bridgeIpRangeEnd?: number;
}
/**
* Manages the SmartVPN server lifecycle and VPN client CRUD.
* Persists server keys and client registrations via smartdata document classes.
*/
export class VpnManager {
private config: IVpnManagerConfig;
private vpnServer?: plugins.smartvpn.VpnServer;
private clients: Map<string, VpnClientDoc> = new Map();
private serverKeys?: VpnServerKeysDoc;
constructor(config: IVpnManagerConfig) {
this.config = config;
}
/** The VPN subnet CIDR. */
public getSubnet(): string {
return this.config.subnet || '10.8.0.0/24';
}
/** Whether the VPN server is running. */
public get running(): boolean {
return this.vpnServer?.running ?? false;
}
/**
* Start the VPN server.
* Loads or generates server keys, loads persisted clients, starts VpnServer.
*/
public async start(): Promise<void> {
// Load or generate server keys
this.serverKeys = await this.loadOrGenerateServerKeys();
// Load persisted clients
await this.loadPersistedClients();
// Build client entries for the daemon
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
let anyClientUsesHostIp = false;
for (const client of this.clients.values()) {
if (client.useHostIp) {
anyClientUsesHostIp = true;
}
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),
};
// Pass per-client bridge fields if present (for hybrid/bridge mode)
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
clientEntries.push(entry);
}
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
let configuredMode = this.config.forwardingMode ?? 'socket';
if (anyClientUsesHostIp && configuredMode === 'socket') {
configuredMode = 'hybrid';
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
}
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
const isBridge = forwardingMode === 'bridge';
// Create and start VpnServer
this.vpnServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
// Default destination policy: bridge mode allows traffic through directly,
// socket mode forces traffic to SmartProxy on 127.0.0.1
const defaultDestinationPolicy: plugins.smartvpn.IDestinationPolicy = isBridge
? { default: 'allow' as const }
: { default: 'forceTarget' as const, target: '127.0.0.1' };
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: forwardingMode as any,
transportMode: 'all',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: !isBridge,
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
serverEndpoint: this.config.serverEndpoint
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
clientAllowedIPs: [subnet],
// Bridge-specific config
...(isBridge ? {
bridgeLanSubnet: this.config.bridgeLanSubnet,
bridgePhysicalInterface: this.config.bridgePhysicalInterface,
bridgeIpRangeStart: this.config.bridgeIpRangeStart,
bridgeIpRangeEnd: this.config.bridgeIpRangeEnd,
} : {}),
};
await this.vpnServer.start(serverConfig);
// Create initial clients from config (idempotent — skip already-persisted)
if (this.config.initialClients) {
for (const initial of this.config.initialClients) {
if (!this.clients.has(initial.clientId)) {
const bundle = await this.createClient({
clientId: initial.clientId,
serverDefinedClientTags: initial.serverDefinedClientTags,
description: initial.description,
});
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
}
}
}
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
/**
* Stop the VPN server.
*/
public async stop(): Promise<void> {
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
} catch {
// Ignore stop errors
}
this.vpnServer.stop();
this.vpnServer = undefined;
}
logger.log('info', 'VPN server stopped');
}
// ── Client CRUD ────────────────────────────────────────────────────────
/**
* Create a new VPN client. Returns the config bundle (secrets only shown once).
*/
public async createClient(opts: {
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
}): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
const bundle = await this.vpnServer.createClient({
clientId: opts.clientId,
serverDefinedClientTags: opts.serverDefinedClientTags,
description: opts.description,
});
// Override AllowedIPs with per-client values based on tag-matched routes
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
// 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 });
}
this.config.onClientChanged?.();
return bundle;
}
/**
* Remove a VPN client.
*/
public async removeClient(clientId: string): Promise<void> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
await this.vpnServer.removeClient(clientId);
const doc = this.clients.get(clientId);
this.clients.delete(clientId);
if (doc) {
await doc.delete();
}
this.config.onClientChanged?.();
}
/**
* List all registered clients (without secrets).
*/
public listClients(): VpnClientDoc[] {
return [...this.clients.values()];
}
/**
* Enable a client.
*/
public async enableClient(clientId: string): Promise<void> {
if (!this.vpnServer) throw new Error('VPN server not running');
await this.vpnServer.enableClient(clientId);
const client = this.clients.get(clientId);
if (client) {
client.enabled = true;
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.config.onClientChanged?.();
}
/**
* Disable a client.
*/
public async disableClient(clientId: string): Promise<void> {
if (!this.vpnServer) throw new Error('VPN server not running');
await this.vpnServer.disableClient(clientId);
const client = this.clients.get(clientId);
if (client) {
client.enabled = false;
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.config.onClientChanged?.();
}
/**
* Update a client's metadata (description, tags) without rotating keys.
*/
public async updateClient(clientId: string, update: {
description?: string;
serverDefinedClientTags?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
}): Promise<void> {
const client = this.clients.get(clientId);
if (!client) throw new Error(`Client not found: ${clientId}`);
if (update.description !== undefined) client.description = update.description;
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
if (update.useDhcp !== undefined) client.useDhcp = update.useDhcp;
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
client.updatedAt = Date.now();
await this.persistClient(client);
// Sync per-client security to the running daemon
if (this.vpnServer) {
const security = this.buildClientSecurity(client);
await this.vpnServer.updateClient(clientId, { security });
}
this.config.onClientChanged?.();
}
/**
* Rotate a client's keys. Returns the new config bundle.
*/
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) throw new Error('VPN server not running');
const bundle = await this.vpnServer.rotateClientKey(clientId);
// Update persisted entry with new keys (including private key for export/QR)
const client = this.clients.get(clientId);
if (client) {
client.noisePublicKey = bundle.entry.publicKey;
client.wgPublicKey = bundle.entry.wgPublicKey || '';
client.wgPrivateKey = bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
client.updatedAt = Date.now();
await this.persistClient(client);
}
return bundle;
}
/**
* Export a client config. Injects stored WG private key and per-client AllowedIPs.
*/
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
if (!this.vpnServer) throw new Error('VPN server not running');
let config = await this.vpnServer.exportClientConfig(clientId, format);
if (format === 'wireguard') {
const persisted = this.clients.get(clientId);
// Inject stored WG private key so exports produce valid, scannable configs
if (persisted?.wgPrivateKey) {
config = config.replace(
'[Interface]\n',
`[Interface]\nPrivateKey = ${persisted.wgPrivateKey}\n`,
);
}
// Override AllowedIPs with per-client values based on tag-matched routes
if (this.config.getClientAllowedIPs) {
const clientTags = persisted?.serverDefinedClientTags || [];
const allowedIPs = await this.config.getClientAllowedIPs(clientTags);
config = config.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
}
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 ───────────────────────────────────────────────
/**
* Get server status.
*/
public async getStatus(): Promise<plugins.smartvpn.IVpnStatus | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getStatus();
}
/**
* Get server statistics.
*/
public async getStatistics(): Promise<plugins.smartvpn.IVpnServerStatistics | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getStatistics();
}
/**
* List currently connected clients.
*/
public async getConnectedClients(): Promise<plugins.smartvpn.IVpnClientInfo[]> {
if (!this.vpnServer) return [];
return this.vpnServer.listClients();
}
/**
* Get telemetry for a specific client.
*/
public async getClientTelemetry(clientId: string): Promise<plugins.smartvpn.IVpnClientTelemetry | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getClientTelemetry(clientId);
}
/**
* Get server public keys (for display/info).
*/
public getServerPublicKeys(): { noisePublicKey: string; wgPublicKey: string } | null {
if (!this.serverKeys) return null;
return {
noisePublicKey: this.serverKeys.noisePublicKey,
wgPublicKey: this.serverKeys.wgPublicKey,
};
}
// ── Per-client security ────────────────────────────────────────────────
/**
* Build per-client security settings for the smartvpn daemon.
* Maps dcrouter-level fields (forceDestinationSmartproxy, allow/block lists)
* to smartvpn's IClientSecurity with a destinationPolicy.
*/
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
const security: plugins.smartvpn.IClientSecurity = {};
const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
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
return security;
}
// ── Private helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
const stored = await VpnServerKeysDoc.load();
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
logger.log('info', 'Loaded VPN server keys from storage');
return stored;
}
// Generate new keys via the daemon
const tempServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
await tempServer.start();
const noiseKeys = await tempServer.generateKeypair();
const wgKeys = await tempServer.generateWgKeypair();
tempServer.stop();
const doc = stored || new VpnServerKeysDoc();
doc.noisePrivateKey = noiseKeys.privateKey;
doc.noisePublicKey = noiseKeys.publicKey;
doc.wgPrivateKey = wgKeys.privateKey;
doc.wgPublicKey = wgKeys.publicKey;
await doc.save();
logger.log('info', 'Generated and persisted new VPN server keys');
return doc;
}
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.clients.set(doc.clientId, doc);
}
if (this.clients.size > 0) {
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
}
}
private async persistClient(client: VpnClientDoc): Promise<void> {
await client.save();
}
}

1
ts/vpn/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './classes.vpn-manager.js';

View File

@@ -259,7 +259,7 @@ Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`)
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

@@ -1,4 +1,5 @@
export * from './auth.js'; export * from './auth.js';
export * from './stats.js'; export * from './stats.js';
export * from './remoteingress.js'; export * from './remoteingress.js';
export * from './route-management.js'; export * from './route-management.js';
export * from './vpn.js';

View File

@@ -51,11 +51,26 @@ export interface IRouteRemoteIngress {
edgeFilter?: string[]; edgeFilter?: string[];
} }
/**
* Route-level VPN access configuration.
* When attached to a route, controls VPN client access.
*/
export interface IRouteVpn {
/** Enable VPN client access for this route */
enabled: boolean;
/** When true (default), ONLY VPN clients can access this route (replaces ipAllowList).
* When false, VPN client IPs are added alongside the existing allowlist. */
mandatory?: boolean;
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
allowedServerDefinedClientTags?: string[];
}
/** /**
* Extended route config used within dcrouter. * Extended route config used within dcrouter.
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig. * Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
* SmartProxy ignores unknown properties at runtime. * SmartProxy ignores unknown properties at runtime.
*/ */
export type IDcRouterRouteConfig = IRouteConfig & { export type IDcRouterRouteConfig = IRouteConfig & {
remoteIngress?: IRouteRemoteIngress; remoteIngress?: IRouteRemoteIngress;
vpn?: IRouteVpn;
}; };

View File

@@ -1,10 +1,77 @@
import type { IRouteConfig } from '@push.rocks/smartproxy'; import type { IRouteConfig } from '@push.rocks/smartproxy';
// Derive IRouteSecurity from IRouteConfig since it's not directly exported
export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
// ============================================================================ // ============================================================================
// Route Management Data Types // Route Management Data Types
// ============================================================================ // ============================================================================
export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage'; export type TApiTokenScope =
| 'routes:read' | 'routes:write'
| 'config:read'
| 'tokens:read' | 'tokens:manage'
| 'profiles:read' | 'profiles:write'
| 'targets:read' | 'targets:write';
// ============================================================================
// Security Profile Types
// ============================================================================
/**
* A reusable, named security profile that can be referenced by routes.
* Stores the full IRouteSecurity shape from SmartProxy.
*/
export interface ISecurityProfile {
id: string;
name: string;
description?: string;
/** The security configuration — mirrors SmartProxy's IRouteSecurity. */
security: IRouteSecurity;
/** IDs of profiles this one extends (resolved top-down, later overrides earlier). */
extendsProfiles?: string[];
createdAt: number;
updatedAt: number;
createdBy: string;
}
// ============================================================================
// Network Target Types
// ============================================================================
/**
* A reusable, named network target (host + port) that can be referenced by routes.
*/
export interface INetworkTarget {
id: string;
name: string;
description?: string;
host: string | string[];
port: number;
createdAt: number;
updatedAt: number;
createdBy: string;
}
// ============================================================================
// Route Metadata Types
// ============================================================================
/**
* Metadata on a stored route tracking where its resolved values came from.
*/
export interface IRouteMetadata {
/** ID of the SecurityProfileDoc used to resolve this route's security. */
securityProfileRef?: 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;
/** Snapshot of the target name at resolution time, for display. */
networkTargetName?: string;
/** Timestamp of last reference resolution. */
lastResolvedAt?: number;
}
/** /**
* A merged route combining hardcoded and programmatic sources. * A merged route combining hardcoded and programmatic sources.
@@ -17,6 +84,7 @@ export interface IMergedRoute {
storedRouteId?: string; storedRouteId?: string;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
metadata?: IRouteMetadata;
} }
/** /**
@@ -55,6 +123,7 @@ export interface IStoredRoute {
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
createdBy: string; createdBy: string;
metadata?: IRouteMetadata;
} }
/** /**

64
ts_interfaces/data/vpn.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* A registered VPN client (secrets excluded from API responses).
*/
export interface IVpnClient {
clientId: string;
enabled: boolean;
serverDefinedClientTags?: string[];
description?: string;
assignedIp?: string;
createdAt: number;
updatedAt: number;
expiresAt?: string;
forceDestinationSmartproxy: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
}
/**
* VPN server status.
*/
export interface IVpnServerStatus {
running: boolean;
subnet: string;
wgListenPort: number;
serverPublicKeys: {
noisePublicKey: string;
wgPublicKey: string;
} | null;
registeredClients: number;
connectedClients: number;
}
/**
* A currently connected VPN client (runtime info from the daemon).
*/
export interface IVpnConnectedClient {
clientId: string;
assignedIp: string;
connectedSince: string;
bytesSent: number;
bytesReceived: number;
transport: string;
}
/**
* VPN client telemetry data.
*/
export interface IVpnClientTelemetry {
clientId: string;
assignedIp: string;
bytesSent: number;
bytesReceived: number;
packetsDropped: number;
bytesDropped: number;
lastKeepaliveAt?: string;
keepalivesReceived: number;
rateLimitBytesPerSec?: number;
burstBytes?: number;
}

View File

@@ -96,7 +96,15 @@ interface IIdentity {
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags | | `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat | | `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter | | `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` property | | `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
| `IRouteVpn` | Route-level VPN config: `enabled`/`mandatory` flags and optional `allowedServerDefinedClientTags` |
#### VPN Interfaces
| Interface | Description |
|-----------|-------------|
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
### Request Interfaces (`requests`) ### Request Interfaces (`requests`)
@@ -205,6 +213,19 @@ interface ICertificateInfo {
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges | | `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token | | `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
#### 🔐 VPN
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
#### 📡 RADIUS #### 📡 RADIUS
| Interface | Method | Description | | Interface | Method | Description |
|-----------|--------|-------------| |-----------|--------|-------------|
@@ -280,7 +301,7 @@ console.log('Connection token:', tokenResponse.token);
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

@@ -8,4 +8,7 @@ export * from './email-ops.js';
export * from './certificate.js'; export * from './certificate.js';
export * from './remoteingress.js'; export * from './remoteingress.js';
export * from './route-management.js'; export * from './route-management.js';
export * from './api-tokens.js'; export * from './api-tokens.js';
export * from './vpn.js';
export * from './security-profiles.js';
export * from './network-targets.js';

View File

@@ -0,0 +1,127 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { INetworkTarget } from '../data/route-management.js';
// ============================================================================
// Network Target Endpoints
// ============================================================================
/**
* Get all network targets.
*/
export interface IReq_GetNetworkTargets extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetNetworkTargets
> {
method: 'getNetworkTargets';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
targets: INetworkTarget[];
};
}
/**
* Get a single network target by ID.
*/
export interface IReq_GetNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetNetworkTarget
> {
method: 'getNetworkTarget';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
target: INetworkTarget | null;
};
}
/**
* Create a new network target.
*/
export interface IReq_CreateNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateNetworkTarget
> {
method: 'createNetworkTarget';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
name: string;
description?: string;
host: string | string[];
port: number;
};
response: {
success: boolean;
id?: string;
message?: string;
};
}
/**
* Update a network target.
*/
export interface IReq_UpdateNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateNetworkTarget
> {
method: 'updateNetworkTarget';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
name?: string;
description?: string;
host?: string | string[];
port?: number;
};
response: {
success: boolean;
affectedRouteCount?: number;
message?: string;
};
}
/**
* Delete a network target.
*/
export interface IReq_DeleteNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteNetworkTarget
> {
method: 'deleteNetworkTarget';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
force?: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Get which routes reference a network target.
*/
export interface IReq_GetNetworkTargetUsage extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetNetworkTargetUsage
> {
method: 'getNetworkTargetUsage';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
routes: Array<{ id: string; name: string }>;
};
}

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js'; import type * as authInterfaces from '../data/auth.js';
import type { IMergedRoute, IRouteWarning } from '../data/route-management.js'; import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy'; import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================ // ============================================================================
@@ -38,6 +38,7 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
apiToken?: string; apiToken?: string;
route: IRouteConfig; route: IRouteConfig;
enabled?: boolean; enabled?: boolean;
metadata?: IRouteMetadata;
}; };
response: { response: {
success: boolean; success: boolean;
@@ -60,6 +61,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
id: string; id: string;
route?: Partial<IRouteConfig>; route?: Partial<IRouteConfig>;
enabled?: boolean; enabled?: boolean;
metadata?: Partial<IRouteMetadata>;
}; };
response: { response: {
success: boolean; success: boolean;

View File

@@ -0,0 +1,127 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { ISecurityProfile, IRouteSecurity } from '../data/route-management.js';
// ============================================================================
// Security Profile Endpoints
// ============================================================================
/**
* Get all security profiles.
*/
export interface IReq_GetSecurityProfiles extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecurityProfiles
> {
method: 'getSecurityProfiles';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
profiles: ISecurityProfile[];
};
}
/**
* Get a single security profile by ID.
*/
export interface IReq_GetSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecurityProfile
> {
method: 'getSecurityProfile';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
profile: ISecurityProfile | null;
};
}
/**
* Create a new security profile.
*/
export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateSecurityProfile
> {
method: 'createSecurityProfile';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
name: string;
description?: string;
security: IRouteSecurity;
extendsProfiles?: string[];
};
response: {
success: boolean;
id?: string;
message?: string;
};
}
/**
* Update a security profile.
*/
export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateSecurityProfile
> {
method: 'updateSecurityProfile';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
name?: string;
description?: string;
security?: IRouteSecurity;
extendsProfiles?: string[];
};
response: {
success: boolean;
affectedRouteCount?: number;
message?: string;
};
}
/**
* Delete a security profile.
*/
export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteSecurityProfile
> {
method: 'deleteSecurityProfile';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
force?: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Get which routes reference a security profile.
*/
export interface IReq_GetSecurityProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecurityProfileUsage
> {
method: 'getSecurityProfileUsage';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
routes: Array<{ id: string; name: string }>;
};
}

View File

@@ -0,0 +1,227 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry, IVpnConnectedClient } from '../data/vpn.js';
// ============================================================================
// VPN Client Management
// ============================================================================
/**
* Get all registered VPN clients.
*/
export interface IReq_GetVpnClients extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetVpnClients
> {
method: 'getVpnClients';
request: {
identity: authInterfaces.IIdentity;
};
response: {
clients: IVpnClient[];
};
}
/**
* Get VPN server status.
*/
export interface IReq_GetVpnStatus extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetVpnStatus
> {
method: 'getVpnStatus';
request: {
identity: authInterfaces.IIdentity;
};
response: {
status: IVpnServerStatus;
};
}
/**
* Create a new VPN client. Returns the config bundle (secrets only shown once).
*/
export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateVpnClient
> {
method: 'createVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
};
response: {
success: boolean;
client?: IVpnClient;
/** WireGuard .conf file content (only returned at creation) */
wireguardConfig?: string;
message?: string;
};
}
/**
* Update a VPN client's metadata (description, tags) without rotating keys.
*/
export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateVpnClient
> {
method: 'updateVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
description?: string;
serverDefinedClientTags?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Get currently connected VPN clients.
*/
export interface IReq_GetVpnConnectedClients extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetVpnConnectedClients
> {
method: 'getVpnConnectedClients';
request: {
identity: authInterfaces.IIdentity;
};
response: {
connectedClients: IVpnConnectedClient[];
};
}
/**
* Delete a VPN client.
*/
export interface IReq_DeleteVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteVpnClient
> {
method: 'deleteVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Enable a VPN client.
*/
export interface IReq_EnableVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_EnableVpnClient
> {
method: 'enableVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Disable a VPN client.
*/
export interface IReq_DisableVpnClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DisableVpnClient
> {
method: 'disableVpnClient';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Rotate a VPN client's keys. Returns the new config bundle.
*/
export interface IReq_RotateVpnClientKey extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RotateVpnClientKey
> {
method: 'rotateVpnClientKey';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
/** WireGuard .conf file content with new keys */
wireguardConfig?: string;
message?: string;
};
}
/**
* Export a VPN client config.
*/
export interface IReq_ExportVpnClientConfig extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ExportVpnClientConfig
> {
method: 'exportVpnClientConfig';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
format: 'smartvpn' | 'wireguard';
};
response: {
success: boolean;
config?: string;
message?: string;
};
}
/**
* Get telemetry for a specific VPN client.
*/
export interface IReq_GetVpnClientTelemetry extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetVpnClientTelemetry
> {
method: 'getVpnClientTelemetry';
request: {
identity: authInterfaces.IIdentity;
clientId: string;
};
response: {
success: boolean;
telemetry?: IVpnClientTelemetry;
message?: string;
};
}

View File

@@ -87,14 +87,36 @@ export function getOciContainerConfig(): IDcRouterOptions {
} as IDcRouterOptions['emailConfig']; } as IDcRouterOptions['emailConfig'];
} }
// Cache config // DB config
const cacheEnabled = process.env.DCROUTER_CACHE_ENABLED; const cacheEnabled = process.env.DCROUTER_CACHE_ENABLED;
if (cacheEnabled !== undefined) { if (cacheEnabled !== undefined) {
options.cacheConfig = { options.dbConfig = {
...options.cacheConfig, ...options.dbConfig,
enabled: cacheEnabled === 'true', enabled: cacheEnabled === 'true',
}; };
} }
// Connection capacity config
const maxConnections = process.env.DCROUTER_MAX_CONNECTIONS;
const maxConnectionsPerIP = process.env.DCROUTER_MAX_CONNECTIONS_PER_IP;
const connectionRateLimit = process.env.DCROUTER_CONNECTION_RATE_LIMIT;
if (maxConnections || maxConnectionsPerIP || connectionRateLimit) {
options.smartProxyConfig = {
...options.smartProxyConfig,
routes: options.smartProxyConfig?.routes || [],
...(maxConnectionsPerIP ? { maxConnectionsPerIP: parseInt(maxConnectionsPerIP, 10) } : {}),
...(connectionRateLimit ? { connectionRateLimitPerMinute: parseInt(connectionRateLimit, 10) } : {}),
...(maxConnections ? {
defaults: {
...options.smartProxyConfig?.defaults,
security: {
...options.smartProxyConfig?.defaults?.security,
maxConnections: parseInt(maxConnections, 10),
},
},
} : {}),
};
}
return options; return options;
} }

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.10.0', version: '12.2.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

File diff suppressed because it is too large Load Diff

View File

@@ -9,4 +9,7 @@ export * from './ops-view-apitokens.js';
export * from './ops-view-security.js'; export * from './ops-view-security.js';
export * from './ops-view-certificates.js'; export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js'; export * from './ops-view-remoteingress.js';
export * from './ops-view-vpn.js';
export * from './ops-view-securityprofiles.js';
export * from './ops-view-networktargets.js';
export * from './shared/index.js'; export * from './shared/index.js';

View File

@@ -24,6 +24,9 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js';
import { OpsViewSecurity } from './ops-view-security.js'; import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js'; import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js'; import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
import { OpsViewVpn } from './ops-view-vpn.js';
import { OpsViewSecurityProfiles } from './ops-view-securityprofiles.js';
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
@customElement('ops-dashboard') @customElement('ops-dashboard')
export class OpsDashboard extends DeesElement { export class OpsDashboard extends DeesElement {
@@ -72,6 +75,16 @@ export class OpsDashboard extends DeesElement {
iconName: 'lucide:route', iconName: 'lucide:route',
element: OpsViewRoutes, element: OpsViewRoutes,
}, },
{
name: 'SecurityProfiles',
iconName: 'lucide:shieldCheck',
element: OpsViewSecurityProfiles,
},
{
name: 'NetworkTargets',
iconName: 'lucide:server',
element: OpsViewNetworkTargets,
},
{ {
name: 'ApiTokens', name: 'ApiTokens',
iconName: 'lucide:key', iconName: 'lucide:key',
@@ -92,6 +105,11 @@ export class OpsDashboard extends DeesElement {
iconName: 'lucide:globe', iconName: 'lucide:globe',
element: OpsViewRemoteIngress, element: OpsViewRemoteIngress,
}, },
{
name: 'VPN',
iconName: 'lucide:shield',
element: OpsViewVpn,
},
]; ];
/** /**
@@ -195,17 +213,18 @@ export class OpsDashboard extends DeesElement {
} }
public async firstUpdated() { public async firstUpdated() {
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login'); const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
simpleLogin.addEventListener('login', (e: CustomEvent) => { simpleLogin.addEventListener('login', (e: Event) => {
// Handle logout event // Handle logout event
this.login(e.detail.data.username, e.detail.data.password); const detail = (e as CustomEvent).detail;
this.login(detail.data.username, detail.data.password);
}); });
// Handle view changes // Handle view changes
const appDash = this.shadowRoot.querySelector('dees-simple-appdash'); const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
if (appDash) { if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => { appDash.addEventListener('view-select', (e: Event) => {
const viewName = e.detail.view.name.toLowerCase(); const viewName = (e as CustomEvent).detail.view.name.toLowerCase();
// Use router for navigation instead of direct state update // Use router for navigation instead of direct state update
appRouter.navigateToView(viewName); appRouter.navigateToView(viewName);
}); });
@@ -217,7 +236,7 @@ export class OpsDashboard extends DeesElement {
} }
// Handle initial state - check if we have a stored session that's still valid // Handle initial state - check if we have a stored session that's still valid
const loginState = appstate.loginStatePart.getState(); const loginState = appstate.loginStatePart.getState()!;
if (loginState.identity?.jwt) { if (loginState.identity?.jwt) {
if (loginState.identity.expiresAt > Date.now()) { if (loginState.identity.expiresAt > Date.now()) {
// Client-side expiry looks valid — verify with server (keypair may have changed) // Client-side expiry looks valid — verify with server (keypair may have changed)
@@ -229,7 +248,7 @@ export class OpsDashboard extends DeesElement {
if (response.valid) { if (response.valid) {
// JWT confirmed valid by server // JWT confirmed valid by server
this.loginState = loginState; this.loginState = loginState;
await simpleLogin.switchToSlottedContent(); await (simpleLogin as any).switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null); await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else { } else {
@@ -250,8 +269,8 @@ export class OpsDashboard extends DeesElement {
private async login(username: string, password: string) { private async login(username: string, password: string) {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
console.log(`Attempting to login...`); console.log(`Attempting to login...`);
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login'); const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
const form = simpleLogin.shadowRoot.querySelector('dees-form'); const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
form.setStatus('pending', 'Logging in...'); form.setStatus('pending', 'Logging in...');
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, { const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
@@ -262,14 +281,14 @@ export class OpsDashboard extends DeesElement {
if (state.identity) { if (state.identity) {
console.log('Login successful'); console.log('Login successful');
this.loginState = state; this.loginState = state;
form.setStatus('success', 'Logged in!'); form!.setStatus('success', 'Logged in!');
await simpleLogin.switchToSlottedContent(); await simpleLogin!.switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null); await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
} else { } else {
form.setStatus('error', 'Login failed!'); form!.setStatus('error', 'Login failed!');
await domtools.convenience.smartdelay.delayFor(2000); await domtools.convenience.smartdelay.delayFor(2000);
form.reset(); form!.reset();
} }
} }
} }

View File

@@ -21,11 +21,11 @@ declare global {
@customElement('ops-view-certificates') @customElement('ops-view-certificates')
export class OpsViewCertificates extends DeesElement { export class OpsViewCertificates extends DeesElement {
@state() @state()
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState(); accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
constructor() { constructor() {
super(); super();
const sub = appstate.certificateStatePart.state.subscribe((newState) => { const sub = appstate.certificateStatePart.select().subscribe((newState) => {
this.certState = newState; this.certState = newState;
}); });
this.rxSubscriptions.push(sub); this.rxSubscriptions.push(sub);
@@ -264,10 +264,10 @@ export class OpsViewCertificates extends DeesElement {
{ {
name: 'Import', name: 'Import',
iconName: 'lucide:upload', iconName: 'lucide:upload',
action: async (modal) => { action: async (modal: any) => {
const { DeesToast } = await import('@design.estate/dees-catalog'); const { DeesToast } = await import('@design.estate/dees-catalog');
try { try {
const form = modal.shadowRoot.querySelector('dees-form') as any; const form = modal.shadowRoot!.querySelector('dees-form') as any;
const formData = await form.collectFormData(); const formData = await form.collectFormData();
const files = formData.certJsonFile; const files = formData.certJsonFile;
if (!files || files.length === 0) { if (!files || files.length === 0) {
@@ -287,8 +287,8 @@ export class OpsViewCertificates extends DeesElement {
); );
DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 }); DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 });
modal.destroy(); modal.destroy();
} catch (err) { } catch (err: unknown) {
DeesToast.show({ message: `Import failed: ${err.message}`, type: 'error', duration: 4000 }); DeesToast.show({ message: `Import failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
} }
}, },
}, },
@@ -339,8 +339,8 @@ export class OpsViewCertificates extends DeesElement {
} else { } else {
DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 }); DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 });
} }
} catch (err) { } catch (err: unknown) {
DeesToast.show({ message: `Export failed: ${err.message}`, type: 'error', duration: 4000 }); DeesToast.show({ message: `Export failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
} }
}, },
}, },
@@ -363,7 +363,7 @@ export class OpsViewCertificates extends DeesElement {
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2', iconName: 'lucide:trash-2',
action: async (modal) => { action: async (modal: any) => {
try { try {
await appstate.certificateStatePart.dispatchAction( await appstate.certificateStatePart.dispatchAction(
appstate.deleteCertificateAction, appstate.deleteCertificateAction,
@@ -371,8 +371,8 @@ export class OpsViewCertificates extends DeesElement {
); );
DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 }); DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 });
modal.destroy(); modal.destroy();
} catch (err) { } catch (err: unknown) {
DeesToast.show({ message: `Delete failed: ${err.message}`, type: 'error', duration: 4000 }); DeesToast.show({ message: `Delete failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
} }
}, },
}, },

View File

@@ -102,7 +102,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult { private renderSystemSection(sys: NonNullable<appstate.IConfigState['config']>['system']): TemplateResult {
// Annotate proxy IPs with source hint when Remote Ingress is active // Annotate proxy IPs with source hint when Remote Ingress is active
const ri = this.configState.config?.remoteIngress; const ri = this.configState.config?.remoteIngress;
let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null; let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null;
@@ -133,7 +133,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult { private renderSmartProxySection(proxy: NonNullable<appstate.IConfigState['config']>['smartProxy']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Route Count', value: proxy.routeCount }, { key: 'Route Count', value: proxy.routeCount },
]; ];
@@ -164,7 +164,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult { private renderEmailSection(email: NonNullable<appstate.IConfigState['config']>['email']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' }, { key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' },
{ key: 'Hostname', value: email.hostname }, { key: 'Hostname', value: email.hostname },
@@ -196,7 +196,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderDnsSection(dns: appstate.IConfigState['config']['dns']): TemplateResult { private renderDnsSection(dns: NonNullable<appstate.IConfigState['config']>['dns']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Port', value: dns.port }, { key: 'Port', value: dns.port },
{ key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' }, { key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' },
@@ -216,7 +216,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult { private renderTlsSection(tls: NonNullable<appstate.IConfigState['config']>['tls']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Contact Email', value: tls.contactEmail }, { key: 'Contact Email', value: tls.contactEmail },
{ key: 'Domain', value: tls.domain }, { key: 'Domain', value: tls.domain },
@@ -242,7 +242,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult { private renderCacheSection(cache: NonNullable<appstate.IConfigState['config']>['cache']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Storage Path', value: cache.storagePath }, { key: 'Storage Path', value: cache.storagePath },
{ key: 'DB Name', value: cache.dbName }, { key: 'DB Name', value: cache.dbName },
@@ -267,7 +267,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult { private renderRadiusSection(radius: NonNullable<appstate.IConfigState['config']>['radius']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Auth Port', value: radius.authPort }, { key: 'Auth Port', value: radius.authPort },
{ key: 'Accounting Port', value: radius.acctPort }, { key: 'Accounting Port', value: radius.acctPort },
@@ -296,7 +296,7 @@ export class OpsViewConfig extends DeesElement {
`; `;
} }
private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult { private renderRemoteIngressSection(ri: NonNullable<appstate.IConfigState['config']>['remoteIngress']): TemplateResult {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Tunnel Port', value: ri.tunnelPort }, { key: 'Tunnel Port', value: ri.tunnelPort },
{ key: 'Hub Domain', value: ri.hubDomain }, { key: 'Hub Domain', value: ri.hubDomain },

View File

@@ -28,7 +28,7 @@ export class OpsViewEmails extends DeesElement {
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => { this.stateSubscription = appstate.emailOpsStatePart.select().subscribe((state) => {
this.emails = state.emails; this.emails = state.emails;
this.isLoading = state.isLoading; this.isLoading = state.isLoading;
}); });
@@ -83,13 +83,13 @@ export class OpsViewEmails extends DeesElement {
private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) { private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
const emailSummary = e.detail; const emailSummary = e.detail;
try { try {
const context = appstate.loginStatePart.getState(); const context = appstate.loginStatePart.getState()!;
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetEmailDetail interfaces.requests.IReq_GetEmailDetail
>('/typedrequest', 'getEmailDetail'); >('/typedrequest', 'getEmailDetail');
const response = await request.fire({ const response = await request.fire({
identity: context.identity, identity: context.identity!,
emailId: emailSummary.id, emailId: emailSummary.id,
}); });

Some files were not shown because too many files have changed in this diff Show More