Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c77accf8 | |||
| b8fba52cb3 | |||
| f247c77807 | |||
| e88938cf95 | |||
| 4f705a591e | |||
| 29687670e8 | |||
| 95daee1d8f | |||
| 11ca64a1cd | |||
| cfb727b86d | |||
| 1e4b9997f4 | |||
| bb32f23d77 | |||
| 1aa6451dba | |||
| eb0408c036 | |||
| 098a2567fa | |||
| c6534df362 | |||
| 2e4b375ad5 | |||
| 802bcf1c3d | |||
| bad0bd9053 | |||
| ca990781b0 | |||
| 6807aefce8 | |||
| 450ec4816e | |||
| ab4310b775 | |||
| 6efd986406 | |||
| 7370d7f0e7 | |||
| e733067c25 | |||
| bc2ed808f9 | |||
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 | |||
| d53cff6a94 | |||
| eb211348d2 | |||
| 43618abeba | |||
| dd9769b814 | |||
| 99b40fea3f | |||
| 6f72e4fdbc | |||
| fbe845cd8e | |||
| 31413d28be | |||
| cd286cede6 | |||
| 36a3060cce | |||
| d2b108317e | |||
| dcd75f5e47 | |||
| 3d443fa147 | |||
| 2efdd2f16b |
160
changelog.md
160
changelog.md
@@ -1,5 +1,165 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "11.12.0",
|
||||
"version": "11.23.2",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -40,7 +40,7 @@
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.3.0",
|
||||
"@push.rocks/smartacme": "^9.3.1",
|
||||
"@push.rocks/smartdata": "^7.1.3",
|
||||
"@push.rocks/smartdb": "^2.0.0",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
@@ -53,18 +53,21 @@
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.0.0",
|
||||
"@push.rocks/smartproxy": "^27.1.0",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.16.5",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.9.0",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
187
pnpm-lock.yaml
generated
187
pnpm-lock.yaml
generated
@@ -39,8 +39,8 @@ importers:
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
'@push.rocks/smartacme':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(socks@2.8.7)
|
||||
specifier: ^9.3.1
|
||||
version: 9.3.1(socks@2.8.7)
|
||||
'@push.rocks/smartdata':
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3(socks@2.8.7)
|
||||
@@ -78,8 +78,8 @@ importers:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.0.0
|
||||
version: 27.0.0
|
||||
specifier: ^27.1.0
|
||||
version: 27.1.0
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -95,6 +95,9 @@ importers:
|
||||
'@push.rocks/smartunique':
|
||||
specifier: ^3.0.9
|
||||
version: 3.0.9
|
||||
'@push.rocks/smartvpn':
|
||||
specifier: 1.16.5
|
||||
version: 1.16.5
|
||||
'@push.rocks/taskbuffer':
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
@@ -110,9 +113,15 @@ importers:
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.0
|
||||
version: 9.5.0
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
lru-cache:
|
||||
specifier: ^11.2.7
|
||||
version: 11.2.7
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
@@ -1048,6 +1057,10 @@ packages:
|
||||
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@peculiar/x509@2.0.0':
|
||||
resolution: {integrity: sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@pnpm/config.env-replace@1.1.0':
|
||||
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@@ -1092,8 +1105,8 @@ packages:
|
||||
'@push.rocks/qenv@6.1.3':
|
||||
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
|
||||
|
||||
'@push.rocks/smartacme@9.3.0':
|
||||
resolution: {integrity: sha512-R6+fBNqlIy3fP2ECmOjBB65tl35w2+2vmSierO6oC9/5DW+khwjvFsT0+5WnfyjejEtWzdAprEseYWmBbyTGtA==}
|
||||
'@push.rocks/smartacme@9.3.1':
|
||||
resolution: {integrity: sha512-Cl1DVQ+rfpaYkk6VVm/KYVeUYzWfXzSfTXybHfCZ5SuiACuTVHZ6jK8TouELaV1RgrdYnIp0MrbiY2Kqi8ayAw==}
|
||||
|
||||
'@push.rocks/smartarchive@4.2.4':
|
||||
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
|
||||
@@ -1239,6 +1252,9 @@ packages:
|
||||
'@push.rocks/smartnftables@1.0.1':
|
||||
resolution: {integrity: sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==}
|
||||
|
||||
'@push.rocks/smartnftables@1.1.0':
|
||||
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
||||
|
||||
@@ -1260,8 +1276,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.3':
|
||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||
|
||||
'@push.rocks/smartproxy@27.0.0':
|
||||
resolution: {integrity: sha512-1scXCoXUM0Ify81une5LldTfbKaBFN8aa5xTiFg2PAS6R4QoGsYuj/aCmErVwBDzCF4G+je4Lh0wxLkMKy7QBA==}
|
||||
'@push.rocks/smartproxy@27.1.0':
|
||||
resolution: {integrity: sha512-uMtmbT6/9Y+lOnSi4w6SRICWJr9q9bHsYAq6xMLmym3zvnEzEwJWF6sw4Jb/uEFEjI2/e4irNSQ9Ba74DhFRlg==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5':
|
||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||
@@ -1323,6 +1339,9 @@ packages:
|
||||
'@push.rocks/smartversion@3.0.5':
|
||||
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
||||
|
||||
'@push.rocks/smartvpn@1.16.5':
|
||||
resolution: {integrity: sha512-wUau/Ad18p36AeIF5R33S45WEM78R7Y4SZSkWdxMdvKNIqSfn1mhf4Zd57iAtxvq+2iO246xfifBrATZWfjPeQ==}
|
||||
|
||||
'@push.rocks/smartwatch@6.4.0':
|
||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1339,9 +1358,6 @@ packages:
|
||||
'@push.rocks/taskbuffer@3.5.0':
|
||||
resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==}
|
||||
|
||||
'@push.rocks/taskbuffer@6.1.2':
|
||||
resolution: {integrity: sha512-sdqKd8N/GidztQ1k3r8A86rLvD8Afyir5FjYCNJXDD9837JLoqzHaOKGltUSBsCGh2gjsZn6GydsY6HhXQgvZQ==}
|
||||
|
||||
'@push.rocks/taskbuffer@8.0.2':
|
||||
resolution: {integrity: sha512-SRCAzrSHysW5XEjwZ494V60ybdpOo/s96jDD3sn7SkYolzg2Pboh+SW5Q7SVNcdkP4b9wCEizOYe9CB3vj3W6w==}
|
||||
|
||||
@@ -2037,6 +2053,9 @@ packages:
|
||||
'@types/node@25.5.0':
|
||||
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/randomatic@3.1.5':
|
||||
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
|
||||
|
||||
@@ -2291,6 +2310,10 @@ packages:
|
||||
camel-case@3.0.0:
|
||||
resolution: {integrity: sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase@6.3.0:
|
||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2331,6 +2354,9 @@ packages:
|
||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2407,6 +2433,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||
|
||||
@@ -2460,6 +2490,9 @@ packages:
|
||||
devtools-protocol@0.0.1581282:
|
||||
resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
@@ -3579,6 +3612,10 @@ packages:
|
||||
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
pngjs@6.0.0:
|
||||
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
|
||||
engines: {node: '>=12.13.0'}
|
||||
@@ -3700,6 +3737,11 @@ packages:
|
||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
qs@6.15.0:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -3770,6 +3812,9 @@ packages:
|
||||
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resolve-alpn@1.2.1:
|
||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||
|
||||
@@ -3825,6 +3870,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4157,6 +4205,9 @@ packages:
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -4212,6 +4263,9 @@ packages:
|
||||
xterm@5.3.0:
|
||||
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4221,6 +4275,10 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4229,6 +4287,10 @@ packages:
|
||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5746,6 +5808,19 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@peculiar/x509@2.0.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.1
|
||||
'@peculiar/asn1-csr': 2.6.1
|
||||
'@peculiar/asn1-ecc': 2.6.1
|
||||
'@peculiar/asn1-pkcs9': 2.6.1
|
||||
'@peculiar/asn1-rsa': 2.6.1
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@pnpm/config.env-replace@1.1.0': {}
|
||||
|
||||
'@pnpm/network.ca-file@1.0.2':
|
||||
@@ -5853,10 +5928,10 @@ snapshots:
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
|
||||
'@push.rocks/smartacme@9.3.0(socks@2.8.7)':
|
||||
'@push.rocks/smartacme@9.3.1(socks@2.8.7)':
|
||||
dependencies:
|
||||
'@apiclient.xyz/cloudflare': 7.1.0
|
||||
'@peculiar/x509': 1.14.3
|
||||
'@peculiar/x509': 2.0.0
|
||||
'@push.rocks/lik': 6.4.0
|
||||
'@push.rocks/smartdata': 7.1.3(socks@2.8.7)
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -5866,17 +5941,21 @@ snapshots:
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@push.rocks/smarttime': 4.2.3
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
'@push.rocks/taskbuffer': 6.1.2
|
||||
'@push.rocks/taskbuffer': 8.0.2
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
reflect-metadata: 0.2.2
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- encoding
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- react
|
||||
- react-native-b4a
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
@@ -6307,6 +6386,11 @@ snapshots:
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smartnftables@1.1.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
dependencies:
|
||||
'@push.rocks/consolecolor': 2.0.3
|
||||
@@ -6382,7 +6466,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.3': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.0.0':
|
||||
'@push.rocks/smartproxy@27.1.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
@@ -6538,6 +6622,12 @@ snapshots:
|
||||
'@types/semver': 7.7.1
|
||||
semver: 7.7.4
|
||||
|
||||
'@push.rocks/smartvpn@1.16.5':
|
||||
dependencies:
|
||||
'@push.rocks/smartnftables': 1.1.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
|
||||
'@push.rocks/smartwatch@6.4.0':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.4.0
|
||||
@@ -6577,22 +6667,6 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/taskbuffer@6.1.2':
|
||||
dependencies:
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@push.rocks/lik': 6.4.0
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smarttime': 4.2.3
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/taskbuffer@8.0.2':
|
||||
dependencies:
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
@@ -7422,6 +7496,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
|
||||
'@types/randomatic@3.1.5': {}
|
||||
|
||||
'@types/relateurl@0.2.33': {}
|
||||
@@ -7666,6 +7744,8 @@ snapshots:
|
||||
no-case: 2.3.2
|
||||
upper-case: 1.1.3
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
camelcase@6.3.0: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
@@ -7696,6 +7776,12 @@ snapshots:
|
||||
|
||||
cli-width@4.1.0: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -7770,6 +7856,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -7816,6 +7904,8 @@ snapshots:
|
||||
|
||||
devtools-protocol@0.0.1581282: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
@@ -9194,6 +9284,8 @@ snapshots:
|
||||
dependencies:
|
||||
find-up: 4.1.0
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
pngjs@6.0.0: {}
|
||||
|
||||
pngjs@7.0.0: {}
|
||||
@@ -9379,6 +9471,12 @@ snapshots:
|
||||
|
||||
pvutils@1.1.5: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
qs@6.15.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@@ -9477,6 +9575,8 @@ snapshots:
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resolve-alpn@1.2.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
@@ -9534,6 +9634,8 @@ snapshots:
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -9925,6 +10027,8 @@ snapshots:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -9966,14 +10070,35 @@ snapshots:
|
||||
|
||||
xterm@5.3.0: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs-parser@22.0.0: {}
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
|
||||
195
readme.md
195
readme.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**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
|
||||
|
||||
@@ -23,6 +23,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- [DNS Server](#dns-server)
|
||||
- [RADIUS Server](#radius-server)
|
||||
- [Remote Ingress](#remote-ingress)
|
||||
- [VPN Access Control](#vpn-access-control)
|
||||
- [Certificate Management](#certificate-management)
|
||||
- [Storage & Caching](#storage--caching)
|
||||
- [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
|
||||
- **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
|
||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||
@@ -89,7 +101,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
### 🖥️ OpsServer Dashboard
|
||||
- **Web-based management interface** with real-time monitoring
|
||||
- **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
|
||||
- **Remote ingress management** with connection token generation and one-click copy
|
||||
- **Read-only configuration display** — DcRouter is configured through code
|
||||
@@ -248,6 +260,15 @@ const router = new DcRouter({
|
||||
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
|
||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
||||
|
||||
@@ -276,6 +297,7 @@ graph TB
|
||||
DNS[DNS Queries]
|
||||
RAD[RADIUS Clients]
|
||||
EDGE[Edge Nodes]
|
||||
VPN[VPN Clients]
|
||||
end
|
||||
|
||||
subgraph "DcRouter Core"
|
||||
@@ -285,6 +307,7 @@ graph TB
|
||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||
RS[SmartRadius Server]
|
||||
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>]
|
||||
OS[OpsServer Dashboard]
|
||||
MM[Metrics Manager]
|
||||
@@ -305,12 +328,14 @@ graph TB
|
||||
DNS --> DS
|
||||
RAD --> RS
|
||||
EDGE --> RI
|
||||
VPN --> VS
|
||||
|
||||
DC --> SP
|
||||
DC --> ES
|
||||
DC --> DS
|
||||
DC --> RS
|
||||
DC --> RI
|
||||
DC --> VS
|
||||
DC --> CM
|
||||
DC --> OS
|
||||
DC --> MM
|
||||
@@ -347,8 +372,8 @@ graph TB
|
||||
|
||||
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.
|
||||
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.
|
||||
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. 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.
|
||||
|
||||
### Rust-Powered Architecture
|
||||
@@ -361,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 |
|
||||
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
||||
| **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) |
|
||||
|
||||
## Configuration Reference
|
||||
@@ -428,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 config — enabled by default on qualifying HTTPS routes */
|
||||
http3?: {
|
||||
@@ -975,6 +1022,129 @@ The OpsServer Remote Ingress view provides:
|
||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||
| **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
|
||||
|
||||
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:
|
||||
@@ -1149,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 |
|
||||
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
||||
| 📧 **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 |
|
||||
| 🌍 **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 |
|
||||
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
||||
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
||||
@@ -1215,6 +1389,17 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
||||
'getRecentLogs' // Retrieve system logs with filtering
|
||||
'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
|
||||
'getRadiusSessions' // Active RADIUS sessions
|
||||
'getRadiusClients' // List NAS clients
|
||||
@@ -1332,6 +1517,7 @@ const router = new DcRouter(options: IDcRouterOptions);
|
||||
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
||||
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
||||
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
||||
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
|
||||
| `storageManager` | `StorageManager` | Storage backend |
|
||||
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
||||
| `metricsManager` | `MetricsManager` | Metrics collector |
|
||||
@@ -1458,6 +1644,7 @@ The container exposes all service ports:
|
||||
| 1812, 1813 | UDP | RADIUS auth/acct |
|
||||
| 3000 | TCP | OpsServer dashboard |
|
||||
| 8443 | TCP | Remote ingress tunnels |
|
||||
| 51820 | UDP | WireGuard VPN |
|
||||
| 29000–30000 | TCP | Dynamic port range |
|
||||
|
||||
### Building the Image
|
||||
|
||||
120
readme.storage.md
Normal file
120
readme.storage.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# DCRouter Storage Overview
|
||||
|
||||
DCRouter uses two complementary storage systems: **StorageManager** for configuration and state, and **CacheDb** for time-limited cached data.
|
||||
|
||||
## StorageManager (Key-Value Store)
|
||||
|
||||
A lightweight, pluggable key-value store for configuration, credentials, and runtime state. Data is persisted as flat JSON files on disk by default.
|
||||
|
||||
### Default Path
|
||||
|
||||
```
|
||||
~/.serve.zone/dcrouter/storage/
|
||||
```
|
||||
|
||||
Configurable via `options.storage.fsPath` or `options.baseDir`.
|
||||
|
||||
### Backends
|
||||
|
||||
```typescript
|
||||
// Filesystem (default)
|
||||
storage: { fsPath: '/var/lib/dcrouter/data' }
|
||||
|
||||
// Custom (Redis, S3, etc.)
|
||||
storage: {
|
||||
readFunction: async (key) => await redis.get(key),
|
||||
writeFunction: async (key, value) => await redis.set(key, value),
|
||||
}
|
||||
|
||||
// In-memory (omit storage config — data lost on restart)
|
||||
```
|
||||
|
||||
### What's Stored
|
||||
|
||||
| Prefix | Contents | Managed By |
|
||||
|--------|----------|------------|
|
||||
| `/vpn/server-keys` | VPN server Noise + WireGuard keypairs | `VpnManager` |
|
||||
| `/vpn/clients/{clientId}` | VPN client registrations (keys, tags, description, assigned IP) | `VpnManager` |
|
||||
| `/config-api/routes/{uuid}.json` | Programmatic routes (created via OpsServer API) | `RouteConfigManager` |
|
||||
| `/config-api/tokens/{uuid}.json` | API tokens (hashed secrets, scopes, expiry) | `ApiTokenManager` |
|
||||
| `/config-api/overrides/{routeName}.json` | Hardcoded route overrides (enable/disable) | `RouteConfigManager` |
|
||||
| `/email/bounces/suppression-list.json` | Email bounce suppression list | `smartmta` |
|
||||
| `/certs/*` | TLS certificates and ACME state | `SmartAcme` (via `StorageBackedCertManager`) |
|
||||
|
||||
### API
|
||||
|
||||
```typescript
|
||||
// Read/write JSON
|
||||
await storageManager.getJSON<T>(key);
|
||||
await storageManager.setJSON(key, value);
|
||||
|
||||
// Raw string read/write
|
||||
await storageManager.get(key);
|
||||
await storageManager.set(key, value);
|
||||
|
||||
// List keys by prefix
|
||||
await storageManager.list('/vpn/clients/');
|
||||
|
||||
// Delete
|
||||
await storageManager.delete(key);
|
||||
```
|
||||
|
||||
## CacheDb (Embedded MongoDB)
|
||||
|
||||
An embedded MongoDB-compatible database (via `@push.rocks/smartdb` + `@push.rocks/smartdata`) for cached data with automatic TTL-based cleanup.
|
||||
|
||||
### Default Path
|
||||
|
||||
```
|
||||
~/.serve.zone/dcrouter/tsmdb/
|
||||
```
|
||||
|
||||
Configurable via `options.cacheConfig.storagePath`.
|
||||
|
||||
### What's Cached
|
||||
|
||||
| Document Type | Default TTL | Purpose |
|
||||
|--------------|-------------|---------|
|
||||
| `CachedEmail` | 30 days | Email metadata cache for dashboard display |
|
||||
| `CachedIPReputation` | 1 day | IP reputation lookup results (DNSBL checks) |
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
cacheConfig: {
|
||||
enabled: true, // default: true
|
||||
storagePath: '~/.serve.zone/dcrouter/tsmdb', // default
|
||||
dbName: 'dcrouter', // default
|
||||
cleanupIntervalHours: 1, // how often to purge expired records
|
||||
ttlConfig: {
|
||||
emails: 30, // days
|
||||
ipReputation: 1, // days
|
||||
bounces: 30, // days (reserved)
|
||||
dkimKeys: 90, // days (reserved)
|
||||
suppression: 30, // days (reserved)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. `CacheDb` starts a `LocalSmartDb` instance (embedded MongoDB process)
|
||||
2. `smartdata` connects to it via Unix socket
|
||||
3. Document classes (`CachedEmail`, `CachedIPReputation`) are decorated with `@Collection` and use `smartdata` ORM
|
||||
4. `CacheCleaner` runs on a timer, purging records older than their configured TTL
|
||||
|
||||
### Disabling
|
||||
|
||||
For development or lightweight deployments, disable the cache to avoid starting a MongoDB process:
|
||||
|
||||
```typescript
|
||||
cacheConfig: { enabled: false }
|
||||
```
|
||||
|
||||
## When to Use Which
|
||||
|
||||
| Use Case | System | Why |
|
||||
|----------|--------|-----|
|
||||
| VPN keys, API tokens, routes, certs | **StorageManager** | Small JSON blobs, key-value access, no queries needed |
|
||||
| Email metadata, IP reputation | **CacheDb** | Time-series data, TTL expiry, potential for queries/aggregation |
|
||||
| Runtime state (connected clients, metrics) | **Neither** | In-memory only, rebuilt on startup |
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Server public IP (used for VPN AllowedIPs)
|
||||
publicIp: '203.0.113.1',
|
||||
// SmartProxy routes for development/demo
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
@@ -23,6 +25,28 @@ const devRouter = new DcRouter({
|
||||
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
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.12.0',
|
||||
version: '11.23.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js';
|
||||
import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
@@ -188,6 +189,39 @@ export interface IDcRouterOptions {
|
||||
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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +260,9 @@ export class DcRouter {
|
||||
public remoteIngressManager?: RemoteIngressManager;
|
||||
public tunnelManager?: TunnelManager;
|
||||
|
||||
// VPN
|
||||
public vpnManager?: VpnManager;
|
||||
|
||||
// Programmatic config API
|
||||
public routeConfigManager?: RouteConfigManager;
|
||||
public apiTokenManager?: ApiTokenManager;
|
||||
@@ -388,6 +425,23 @@ export class DcRouter {
|
||||
await this.smartAcme.start();
|
||||
this.smartAcmeReady = true;
|
||||
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 () => {
|
||||
@@ -412,6 +466,14 @@ export class DcRouter {
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.options.vpnConfig?.enabled
|
||||
? (tags?: string[]) => {
|
||||
if (tags?.length && this.vpnManager) {
|
||||
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
|
||||
}
|
||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
||||
await this.apiTokenManager.initialize();
|
||||
@@ -516,6 +578,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
|
||||
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
|
||||
const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info';
|
||||
@@ -599,6 +680,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'}`);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
||||
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
||||
@@ -724,7 +813,8 @@ export class DcRouter {
|
||||
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];
|
||||
|
||||
// If we have routes or need a basic SmartProxy instance, create it
|
||||
@@ -835,14 +925,22 @@ export class DcRouter {
|
||||
const cert = await this.smartAcme!.getCertificateForDomain(domain, {
|
||||
includeWildcard: !isWildcardDomain,
|
||||
});
|
||||
if (cert.validUntil) {
|
||||
eventComms.setExpiryDate(new Date(cert.validUntil));
|
||||
// Parse real X509 expiry from PEM (defense-in-depth over SmartAcme's estimate)
|
||||
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 = {
|
||||
id: cert.id,
|
||||
domainName: cert.domainName,
|
||||
created: cert.created,
|
||||
validUntil: cert.validUntil,
|
||||
validUntil: realValidUntil,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
csr: cert.csr,
|
||||
@@ -867,6 +965,17 @@ export class DcRouter {
|
||||
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
|
||||
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
|
||||
|
||||
@@ -1130,23 +1239,6 @@ export class DcRouter {
|
||||
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
|
||||
*/
|
||||
@@ -1988,6 +2080,96 @@ export class DcRouter {
|
||||
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(this.storageManager, {
|
||||
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,
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
|
||||
const ROUTES_PREFIX = '/config-api/routes/';
|
||||
@@ -22,6 +23,7 @@ export class RouteConfigManager {
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -244,31 +246,51 @@ export class RouteConfigManager {
|
||||
// Private: apply merged routes to SmartProxy
|
||||
// =========================================================================
|
||||
|
||||
private async applyRoutes(): Promise<void> {
|
||||
public async applyRoutes(): Promise<void> {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
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 !== false; // defaults to true
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: mandatory
|
||||
? allowList
|
||||
: [...(route.security?.ipAllowList || []), ...allowList],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
if (override && !override.enabled) {
|
||||
continue; // Skip disabled hardcoded route
|
||||
}
|
||||
enabledRoutes.push(route);
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config && http3Config.enabled !== false) {
|
||||
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
|
||||
} else {
|
||||
enabledRoutes.push(stored.route);
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export class OpsServer {
|
||||
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||
private vpnHandler!: handlers.VpnHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -86,6 +87,7 @@ export class OpsServer {
|
||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||
this.vpnHandler = new handlers.VpnHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export * from './email-ops.handler.js';
|
||||
export * from './certificate.handler.js';
|
||||
export * from './remoteingress.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';
|
||||
303
ts/opsserver/handlers/vpn.handler.ts
Normal file
303
ts/opsserver/handlers/vpn.handler.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
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,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,13 +58,14 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartproxy from '@push.rocks/smartproxy';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartvpn from '@push.rocks/smartvpn';
|
||||
import * as smartradius from '@push.rocks/smartradius';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, 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
|
||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||
|
||||
461
ts/vpn/classes.vpn-manager.ts
Normal file
461
ts/vpn/classes.vpn-manager.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
||||
|
||||
const STORAGE_PREFIX_KEYS = '/vpn/server-keys';
|
||||
const STORAGE_PREFIX_CLIENTS = '/vpn/clients/';
|
||||
|
||||
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[]>;
|
||||
}
|
||||
|
||||
interface IPersistedServerKeys {
|
||||
noisePrivateKey: string;
|
||||
noisePublicKey: string;
|
||||
wgPrivateKey: string;
|
||||
wgPublicKey: string;
|
||||
}
|
||||
|
||||
interface IPersistedClient {
|
||||
clientId: string;
|
||||
enabled: boolean;
|
||||
serverDefinedClientTags?: string[];
|
||||
description?: string;
|
||||
assignedIp?: string;
|
||||
noisePublicKey: string;
|
||||
wgPublicKey: string;
|
||||
/** WireGuard private key — stored so exports and QR codes produce valid configs */
|
||||
wgPrivateKey?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt?: string;
|
||||
/** @deprecated Legacy field — migrated to serverDefinedClientTags on load */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the SmartVPN server lifecycle and VPN client CRUD.
|
||||
* Persists server keys and client registrations via StorageManager.
|
||||
*/
|
||||
export class VpnManager {
|
||||
private storageManager: StorageManager;
|
||||
private config: IVpnManagerConfig;
|
||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||
private clients: Map<string, IPersistedClient> = new Map();
|
||||
private serverKeys?: IPersistedServerKeys;
|
||||
|
||||
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
|
||||
this.storageManager = storageManager;
|
||||
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[] = [];
|
||||
for (const client of this.clients.values()) {
|
||||
clientEntries.push({
|
||||
clientId: client.clientId,
|
||||
publicKey: client.noisePublicKey,
|
||||
wgPublicKey: client.wgPublicKey,
|
||||
enabled: client.enabled,
|
||||
serverDefinedClientTags: client.serverDefinedClientTags,
|
||||
description: client.description,
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
const subnet = this.getSubnet();
|
||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||
|
||||
// Create and start VpnServer
|
||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||
transport: { transport: 'stdio' },
|
||||
});
|
||||
|
||||
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: 'socket',
|
||||
transportMode: 'all',
|
||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: true,
|
||||
destinationPolicy: this.config.destinationPolicy
|
||||
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
clientAllowedIPs: [subnet],
|
||||
};
|
||||
|
||||
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;
|
||||
}): 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 persisted: IPersistedClient = {
|
||||
clientId: bundle.entry.clientId,
|
||||
enabled: bundle.entry.enabled ?? true,
|
||||
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||
description: bundle.entry.description,
|
||||
assignedIp: bundle.entry.assignedIp,
|
||||
noisePublicKey: bundle.entry.publicKey,
|
||||
wgPublicKey: bundle.entry.wgPublicKey || '',
|
||||
wgPrivateKey: bundle.secrets?.wgPrivateKey
|
||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: bundle.entry.expiresAt,
|
||||
};
|
||||
this.clients.set(persisted.clientId, persisted);
|
||||
await this.persistClient(persisted);
|
||||
|
||||
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);
|
||||
this.clients.delete(clientId);
|
||||
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
|
||||
this.config.onClientChanged?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered clients (without secrets).
|
||||
*/
|
||||
public listClients(): IPersistedClient[] {
|
||||
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[];
|
||||
}): 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;
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async loadOrGenerateServerKeys(): Promise<IPersistedServerKeys> {
|
||||
const stored = await this.storageManager.getJSON<IPersistedServerKeys>(STORAGE_PREFIX_KEYS);
|
||||
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 keys: IPersistedServerKeys = {
|
||||
noisePrivateKey: noiseKeys.privateKey,
|
||||
noisePublicKey: noiseKeys.publicKey,
|
||||
wgPrivateKey: wgKeys.privateKey,
|
||||
wgPublicKey: wgKeys.publicKey,
|
||||
};
|
||||
|
||||
await this.storageManager.setJSON(STORAGE_PREFIX_KEYS, keys);
|
||||
logger.log('info', 'Generated and persisted new VPN server keys');
|
||||
return keys;
|
||||
}
|
||||
|
||||
private async loadPersistedClients(): Promise<void> {
|
||||
const keys = await this.storageManager.list(STORAGE_PREFIX_CLIENTS);
|
||||
for (const key of keys) {
|
||||
const client = await this.storageManager.getJSON<IPersistedClient>(key);
|
||||
if (client) {
|
||||
// Migrate legacy `tags` → `serverDefinedClientTags`
|
||||
if (!client.serverDefinedClientTags && client.tags) {
|
||||
client.serverDefinedClientTags = client.tags;
|
||||
delete client.tags;
|
||||
await this.persistClient(client);
|
||||
}
|
||||
this.clients.set(client.clientId, client);
|
||||
}
|
||||
}
|
||||
if (this.clients.size > 0) {
|
||||
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistClient(client: IPersistedClient): Promise<void> {
|
||||
await this.storageManager.setJSON(`${STORAGE_PREFIX_CLIENTS}${client.clientId}`, client);
|
||||
}
|
||||
}
|
||||
1
ts/vpn/index.ts
Normal file
1
ts/vpn/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.vpn-manager.js';
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './auth.js';
|
||||
export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './route-management.js';
|
||||
export * from './vpn.js';
|
||||
@@ -51,11 +51,26 @@ export interface IRouteRemoteIngress {
|
||||
edgeFilter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level VPN access configuration.
|
||||
* When attached to a route, controls VPN client access.
|
||||
*/
|
||||
export interface IRouteVpn {
|
||||
/** Enable VPN client access for this route */
|
||||
enabled: boolean;
|
||||
/** When true (default), ONLY VPN clients can access this route (replaces ipAllowList).
|
||||
* When false, VPN client IPs are added alongside the existing allowlist. */
|
||||
mandatory?: boolean;
|
||||
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
|
||||
allowedServerDefinedClientTags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended route config used within dcrouter.
|
||||
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
|
||||
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
|
||||
* SmartProxy ignores unknown properties at runtime.
|
||||
*/
|
||||
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||
remoteIngress?: IRouteRemoteIngress;
|
||||
vpn?: IRouteVpn;
|
||||
};
|
||||
|
||||
56
ts_interfaces/data/vpn.ts
Normal file
56
ts_interfaces/data/vpn.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -96,7 +96,15 @@ interface IIdentity {
|
||||
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||
| `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`)
|
||||
|
||||
@@ -205,6 +213,19 @@ interface ICertificateInfo {
|
||||
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
||||
| `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
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
|
||||
@@ -8,4 +8,5 @@ export * from './email-ops.js';
|
||||
export * from './certificate.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './api-tokens.js';
|
||||
export * from './api-tokens.js';
|
||||
export * from './vpn.js';
|
||||
211
ts_interfaces/requests/vpn.ts
Normal file
211
ts_interfaces/requests/vpn.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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;
|
||||
};
|
||||
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[];
|
||||
};
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.12.0',
|
||||
version: '11.23.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -905,6 +905,202 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// VPN State
|
||||
// ============================================================================
|
||||
|
||||
export interface IVpnState {
|
||||
clients: interfaces.data.IVpnClient[];
|
||||
connectedClients: interfaces.data.IVpnConnectedClient[];
|
||||
status: interfaces.data.IVpnServerStatus | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
/** WireGuard config shown after create/rotate (only shown once) */
|
||||
newClientConfig: string | null;
|
||||
}
|
||||
|
||||
export const vpnStatePart = await appState.getStatePart<IVpnState>(
|
||||
'vpn',
|
||||
{
|
||||
clients: [],
|
||||
connectedClients: [],
|
||||
status: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
newClientConfig: null,
|
||||
},
|
||||
'soft'
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// VPN Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const clientsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetVpnClients
|
||||
>('/typedrequest', 'getVpnClients');
|
||||
|
||||
const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetVpnStatus
|
||||
>('/typedrequest', 'getVpnStatus');
|
||||
|
||||
const connectedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetVpnConnectedClients
|
||||
>('/typedrequest', 'getVpnConnectedClients');
|
||||
|
||||
const [clientsResponse, statusResponse, connectedResponse] = await Promise.all([
|
||||
clientsRequest.fire({ identity: context.identity }),
|
||||
statusRequest.fire({ identity: context.identity }),
|
||||
connectedRequest.fire({ identity: context.identity }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
clients: clientsResponse.clients,
|
||||
connectedClients: connectedResponse.connectedClients,
|
||||
status: statusResponse.status,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch VPN data',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const createVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
description?: string;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateVpnClient
|
||||
>('/typedrequest', 'createVpnClient');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
clientId: dataArg.clientId,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
description: dataArg.description,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
return { ...currentState, error: response.message || 'Failed to create client' };
|
||||
}
|
||||
|
||||
const refreshed = await actionContext!.dispatch(fetchVpnAction, null);
|
||||
return {
|
||||
...refreshed,
|
||||
newClientConfig: response.wireguardConfig || null,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to create VPN client',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteVpnClientAction = vpnStatePart.createAction<string>(
|
||||
async (statePartArg, clientId, actionContext): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteVpnClient
|
||||
>('/typedrequest', 'deleteVpnClient');
|
||||
|
||||
await request.fire({ identity: context.identity!, clientId });
|
||||
return await actionContext!.dispatch(fetchVpnAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete VPN client',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const toggleVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
enabled: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
try {
|
||||
const method = dataArg.enabled ? 'enableVpnClient' : 'disableVpnClient';
|
||||
type TReq = interfaces.requests.IReq_EnableVpnClient | interfaces.requests.IReq_DisableVpnClient;
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<TReq>(
|
||||
'/typedrequest', method,
|
||||
);
|
||||
|
||||
await request.fire({ identity: context.identity!, clientId: dataArg.clientId });
|
||||
return await actionContext!.dispatch(fetchVpnAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to toggle VPN client',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const updateVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
description?: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateVpnClient
|
||||
>('/typedrequest', 'updateVpnClient');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
clientId: dataArg.clientId,
|
||||
description: dataArg.description,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
return { ...currentState, error: response.message || 'Failed to update client' };
|
||||
}
|
||||
|
||||
return await actionContext!.dispatch(fetchVpnAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to update VPN client',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const clearNewClientConfigAction = vpnStatePart.createAction(
|
||||
async (statePartArg): Promise<IVpnState> => {
|
||||
return { ...statePartArg.getState()!, newClientConfig: null };
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Actions
|
||||
// ============================================================================
|
||||
@@ -1372,6 +1568,15 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
console.error('Remote ingress refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh VPN data if on vpn view
|
||||
if (currentView === 'vpn') {
|
||||
try {
|
||||
await vpnStatePart.dispatchAction(fetchVpnAction, null);
|
||||
} catch (error) {
|
||||
console.error('VPN refresh failed:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Combined refresh failed:', error);
|
||||
// If the error looks like an auth failure (invalid JWT), force re-login
|
||||
|
||||
@@ -9,4 +9,5 @@ export * from './ops-view-apitokens.js';
|
||||
export * from './ops-view-security.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './ops-view-vpn.js';
|
||||
export * from './shared/index.js';
|
||||
@@ -24,6 +24,7 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js';
|
||||
import { OpsViewSecurity } from './ops-view-security.js';
|
||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||
|
||||
@customElement('ops-dashboard')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement {
|
||||
iconName: 'lucide:globe',
|
||||
element: OpsViewRemoteIngress,
|
||||
},
|
||||
{
|
||||
name: 'VPN',
|
||||
iconName: 'lucide:shield',
|
||||
element: OpsViewVpn,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
657
ts_web/elements/ops-view-vpn.ts
Normal file
657
ts_web/elements/ops-view-vpn.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
DeesElement,
|
||||
html,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
css,
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-vpn': OpsViewVpn;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-vpn')
|
||||
export class OpsViewVpn extends DeesElement {
|
||||
@state()
|
||||
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
|
||||
this.vpnState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.vpnContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statusBadge.enabled {
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.statusBadge.disabled {
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.configDialog {
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
|
||||
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.configDialog pre {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('#1f2937', '#111827')};
|
||||
color: #10b981;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 8px 0;
|
||||
user-select: all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.configDialog .warning {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tagBadge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.serverInfo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')};
|
||||
}
|
||||
|
||||
.serverInfo .infoItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.serverInfo .infoLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
.serverInfo .infoValue {
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/** Look up connected client info by clientId or assignedIp */
|
||||
private getConnectedInfo(client: interfaces.data.IVpnClient): interfaces.data.IVpnConnectedClient | undefined {
|
||||
return this.vpnState.connectedClients?.find(
|
||||
c => c.clientId === client.clientId || (client.assignedIp && c.assignedIp === client.assignedIp)
|
||||
);
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
const status = this.vpnState.status;
|
||||
const clients = this.vpnState.clients;
|
||||
const connectedClients = this.vpnState.connectedClients || [];
|
||||
const connectedCount = connectedClients.length;
|
||||
const totalClients = clients.length;
|
||||
const enabledClients = clients.filter(c => c.enabled).length;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalClients',
|
||||
title: 'Total Clients',
|
||||
type: 'number',
|
||||
value: totalClients,
|
||||
icon: 'lucide:users',
|
||||
description: 'Registered VPN clients',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'connectedClients',
|
||||
title: 'Connected',
|
||||
type: 'number',
|
||||
value: connectedCount,
|
||||
icon: 'lucide:link',
|
||||
description: 'Currently connected',
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'enabledClients',
|
||||
title: 'Enabled',
|
||||
type: 'number',
|
||||
value: enabledClients,
|
||||
icon: 'lucide:shieldCheck',
|
||||
description: 'Active client registrations',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'serverStatus',
|
||||
title: 'Server',
|
||||
type: 'text',
|
||||
value: status?.running ? 'Running' : 'Stopped',
|
||||
icon: 'lucide:server',
|
||||
description: status?.running ? 'Active' : 'VPN server not running',
|
||||
color: status?.running ? '#10b981' : '#ef4444',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>VPN</ops-sectionheading>
|
||||
<div class="vpnContainer">
|
||||
|
||||
${this.vpnState.newClientConfig ? html`
|
||||
<div class="configDialog">
|
||||
<strong>Client created successfully!</strong>
|
||||
<div class="warning">Copy the WireGuard config now. It contains private keys that won't be shown again.</div>
|
||||
<pre>${this.vpnState.newClientConfig}</pre>
|
||||
<dees-button
|
||||
@click=${async () => {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(this.vpnState.newClientConfig!);
|
||||
}
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 });
|
||||
}}
|
||||
>Copy to Clipboard</dees-button>
|
||||
<dees-button
|
||||
@click=${() => {
|
||||
const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'wireguard.conf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>Download .conf</dees-button>
|
||||
<dees-button
|
||||
@click=${async () => {
|
||||
const dataUrl = await plugins.qrcode.toDataURL(
|
||||
this.vpnState.newClientConfig!,
|
||||
{ width: 400, margin: 2 }
|
||||
);
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'WireGuard QR Code',
|
||||
content: html`
|
||||
<div style="text-align: center; padding: 16px;">
|
||||
<img src="${dataUrl}" style="max-width: 100%; image-rendering: pixelated;" />
|
||||
<p style="margin-top: 12px; font-size: 13px; color: #9ca3af;">
|
||||
Scan with the WireGuard app on your phone
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
}}
|
||||
>Show QR Code</dees-button>
|
||||
<dees-button
|
||||
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
|
||||
>Dismiss</dees-button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
|
||||
${status ? html`
|
||||
<div class="serverInfo">
|
||||
<div class="infoItem">
|
||||
<span class="infoLabel">Subnet</span>
|
||||
<span class="infoValue">${status.subnet}</span>
|
||||
</div>
|
||||
<div class="infoItem">
|
||||
<span class="infoLabel">WireGuard Port</span>
|
||||
<span class="infoValue">${status.wgListenPort}</span>
|
||||
</div>
|
||||
${status.serverPublicKeys ? html`
|
||||
<div class="infoItem">
|
||||
<span class="infoLabel">WG Public Key</span>
|
||||
<span class="infoValue" style="font-size: 11px; word-break: break-all;">${status.serverPublicKeys.wgPublicKey}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<dees-table
|
||||
.heading1=${'VPN Clients'}
|
||||
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
|
||||
.data=${clients}
|
||||
.displayFunction=${(client: interfaces.data.IVpnClient) => {
|
||||
const conn = this.getConnectedInfo(client);
|
||||
let statusHtml;
|
||||
if (!client.enabled) {
|
||||
statusHtml = html`<span class="statusBadge disabled">disabled</span>`;
|
||||
} else if (conn) {
|
||||
const since = new Date(conn.connectedSince).toLocaleString();
|
||||
statusHtml = html`<span class="statusBadge enabled" title="Since ${since}">connected</span>`;
|
||||
} else {
|
||||
statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
|
||||
}
|
||||
return {
|
||||
'Client ID': client.clientId,
|
||||
'Status': statusHtml,
|
||||
'VPN IP': client.assignedIp || '-',
|
||||
'Tags': client.serverDefinedClientTags?.length
|
||||
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||
: '-',
|
||||
'Description': client.description || '-',
|
||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||
};
|
||||
}}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Create Client',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Create VPN Client',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:plus',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
if (!data.clientId) return;
|
||||
const serverDefinedClientTags = data.tags
|
||||
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
|
||||
clientId: data.clientId,
|
||||
description: data.description || undefined,
|
||||
serverDefinedClientTags,
|
||||
});
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Detail',
|
||||
iconName: 'lucide:info',
|
||||
type: ['doubleClick'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const conn = this.getConnectedInfo(client);
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
// Fetch telemetry on-demand
|
||||
let telemetryHtml = html`<p style="color: #9ca3af;">Loading telemetry...</p>`;
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetVpnClientTelemetry
|
||||
>('/typedrequest', 'getVpnClientTelemetry');
|
||||
const response = await request.fire({
|
||||
identity: appstate.loginStatePart.getState()!.identity!,
|
||||
clientId: client.clientId,
|
||||
});
|
||||
const t = response.telemetry;
|
||||
if (t) {
|
||||
const formatBytes = (b: number) => b > 1048576 ? `${(b / 1048576).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(1)} KB` : `${b} B`;
|
||||
telemetryHtml = html`
|
||||
<div class="serverInfo" style="margin-top: 12px;">
|
||||
<div class="infoItem"><span class="infoLabel">Bytes Sent</span><span class="infoValue">${formatBytes(t.bytesSent)}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Bytes Received</span><span class="infoValue">${formatBytes(t.bytesReceived)}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Keepalives</span><span class="infoValue">${t.keepalivesReceived}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Last Keepalive</span><span class="infoValue">${t.lastKeepaliveAt ? new Date(t.lastKeepaliveAt).toLocaleString() : '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Packets Dropped</span><span class="infoValue">${t.packetsDropped}</span></div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
telemetryHtml = html`<p style="color: #9ca3af;">No telemetry available (client not connected)</p>`;
|
||||
}
|
||||
} catch {
|
||||
telemetryHtml = html`<p style="color: #9ca3af;">Telemetry unavailable</p>`;
|
||||
}
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: `Client: ${client.clientId}`,
|
||||
content: html`
|
||||
<div class="serverInfo">
|
||||
<div class="infoItem"><span class="infoLabel">Client ID</span><span class="infoValue">${client.clientId}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">VPN IP</span><span class="infoValue">${client.assignedIp || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Status</span><span class="infoValue">${!client.enabled ? 'Disabled' : conn ? 'Connected' : 'Offline'}</span></div>
|
||||
${conn ? html`
|
||||
<div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
||||
` : ''}
|
||||
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.join(', ') || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Created</span><span class="infoValue">${new Date(client.createdAt).toLocaleString()}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Updated</span><span class="infoValue">${new Date(client.updatedAt).toLocaleString()}</span></div>
|
||||
</div>
|
||||
<h3 style="margin: 16px 0 4px; font-size: 14px;">Telemetry</h3>
|
||||
${telemetryHtml}
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Enable',
|
||||
iconName: 'lucide:power',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
||||
clientId: client.clientId,
|
||||
enabled: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Disable',
|
||||
iconName: 'lucide:power',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
||||
clientId: client.clientId,
|
||||
enabled: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Export Config',
|
||||
iconName: 'lucide:download',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const exportConfig = async (format: 'wireguard' | 'smartvpn') => {
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ExportVpnClientConfig
|
||||
>('/typedrequest', 'exportVpnClientConfig');
|
||||
const response = await request.fire({
|
||||
identity: appstate.loginStatePart.getState()!.identity!,
|
||||
clientId: client.clientId,
|
||||
format,
|
||||
});
|
||||
if (response.success && response.config) {
|
||||
const ext = format === 'wireguard' ? 'conf' : 'json';
|
||||
const blob = new Blob([response.config], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${client.clientId}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
DeesToast.createAndShow({ message: `${format} config downloaded`, type: 'success', duration: 3000 });
|
||||
} else {
|
||||
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
|
||||
}
|
||||
} catch (err: any) {
|
||||
DeesToast.createAndShow({ message: err.message || 'Export failed', type: 'error', duration: 5000 });
|
||||
}
|
||||
};
|
||||
|
||||
const showQrCode = async () => {
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ExportVpnClientConfig
|
||||
>('/typedrequest', 'exportVpnClientConfig');
|
||||
const response = await request.fire({
|
||||
identity: appstate.loginStatePart.getState()!.identity!,
|
||||
clientId: client.clientId,
|
||||
format: 'wireguard',
|
||||
});
|
||||
if (response.success && response.config) {
|
||||
const dataUrl = await plugins.qrcode.toDataURL(
|
||||
response.config,
|
||||
{ width: 400, margin: 2 }
|
||||
);
|
||||
DeesModal.createAndShow({
|
||||
heading: `QR Code: ${client.clientId}`,
|
||||
content: html`
|
||||
<div style="text-align: center; padding: 16px;">
|
||||
<img src="${dataUrl}" style="max-width: 100%; image-rendering: pixelated;" />
|
||||
<p style="margin-top: 12px; font-size: 13px; color: #9ca3af;">
|
||||
Scan with the WireGuard app on your phone
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
|
||||
}
|
||||
} catch (err: any) {
|
||||
DeesToast.createAndShow({ message: err.message || 'QR generation failed', type: 'error', duration: 5000 });
|
||||
}
|
||||
};
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: `Export Config: ${client.clientId}`,
|
||||
content: html`<p>Choose a config format to download.</p>`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'WireGuard (.conf)',
|
||||
iconName: 'lucide:shield',
|
||||
action: async (modalArg: any) => {
|
||||
await modalArg.destroy();
|
||||
await exportConfig('wireguard');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'SmartVPN (.json)',
|
||||
iconName: 'lucide:braces',
|
||||
action: async (modalArg: any) => {
|
||||
await modalArg.destroy();
|
||||
await exportConfig('smartvpn');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'QR Code (WireGuard)',
|
||||
iconName: 'lucide:qr-code',
|
||||
action: async (modalArg: any) => {
|
||||
await modalArg.destroy();
|
||||
await showQrCode();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const currentDescription = client.description ?? '';
|
||||
const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
|
||||
DeesModal.createAndShow({
|
||||
heading: `Edit: ${client.clientId}`,
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'} .value=${currentTags}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const serverDefinedClientTags = data.tags
|
||||
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
|
||||
clientId: client.clientId,
|
||||
description: data.description || undefined,
|
||||
serverDefinedClientTags,
|
||||
});
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Rotate Keys',
|
||||
iconName: 'lucide:rotate-cw',
|
||||
type: ['contextmenu'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Rotate Client Keys',
|
||||
content: html`<p>Generate new keys for "${client.clientId}"? The old keys will be invalidated and the client will need the new config to reconnect.</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||
{
|
||||
name: 'Rotate',
|
||||
iconName: 'lucide:rotate-cw',
|
||||
action: async (modalArg: any) => {
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_RotateVpnClientKey
|
||||
>('/typedrequest', 'rotateVpnClientKey');
|
||||
const response = await request.fire({
|
||||
identity: appstate.loginStatePart.getState()!.identity!,
|
||||
clientId: client.clientId,
|
||||
});
|
||||
if (response.success && response.wireguardConfig) {
|
||||
appstate.vpnStatePart.setState({
|
||||
...appstate.vpnStatePart.getState()!,
|
||||
newClientConfig: response.wireguardConfig,
|
||||
});
|
||||
}
|
||||
await modalArg.destroy();
|
||||
} catch (err: any) {
|
||||
DeesToast.createAndShow({ message: err.message || 'Rotate failed', type: 'error', duration: 5000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['contextmenu'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Delete VPN Client',
|
||||
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,15 @@ import * as szCatalog from '@serve.zone/catalog';
|
||||
// TypedSocket for real-time push communication
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
// QR code generation for WireGuard configs
|
||||
import * as qrcode from 'qrcode';
|
||||
|
||||
export {
|
||||
deesElement,
|
||||
deesCatalog,
|
||||
szCatalog,
|
||||
typedsocket,
|
||||
qrcode,
|
||||
}
|
||||
|
||||
// domtools gives us TypedRequest and other utilities
|
||||
|
||||
@@ -50,6 +50,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
|
||||
- Enable/disable, edit, secret regeneration, and delete actions
|
||||
|
||||
### 🔐 VPN Management
|
||||
- VPN server status with forwarding mode, subnet, and WireGuard port
|
||||
- Client registration table with create, enable/disable, and delete actions
|
||||
- WireGuard config download, clipboard copy, and **QR code display** on client creation
|
||||
- QR code export for existing clients — scan with WireGuard mobile app (iOS/Android)
|
||||
- Per-client telemetry (bytes sent/received, keepalives)
|
||||
- Server public key display for manual client configuration
|
||||
|
||||
### 📜 Log Viewer
|
||||
- Real-time log streaming
|
||||
- Filter by log level (error, warning, info, debug)
|
||||
@@ -100,6 +108,7 @@ ts_web/
|
||||
├── ops-view-emails.ts # Email queue management
|
||||
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
||||
├── ops-view-remoteingress.ts # Remote ingress edge management
|
||||
├── ops-view-vpn.ts # VPN client management
|
||||
├── ops-view-logs.ts # Log viewer
|
||||
├── ops-view-routes.ts # Route & API token management
|
||||
├── ops-view-config.ts # Configuration display
|
||||
@@ -124,6 +133,7 @@ The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled
|
||||
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
|
||||
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
||||
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
||||
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
|
||||
|
||||
### Tab Visibility Optimization
|
||||
|
||||
@@ -173,6 +183,13 @@ regenerateRemoteIngressSecretAction(id) // New secret
|
||||
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
||||
clearNewEdgeSecretAction() // Dismiss secret banner
|
||||
fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
||||
|
||||
// VPN
|
||||
fetchVpnAction() // Clients + server status
|
||||
createVpnClientAction(data) // Create new VPN client
|
||||
deleteVpnClientAction(clientId) // Remove VPN client
|
||||
toggleVpnClientAction(id, enabled) // Enable/disable
|
||||
clearNewClientConfigAction() // Dismiss config banner
|
||||
```
|
||||
|
||||
### Client-Side Routing
|
||||
@@ -187,6 +204,7 @@ fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
||||
/emails/security → Security incidents
|
||||
/certificates → Certificate management
|
||||
/remoteingress → Remote ingress edge management
|
||||
/vpn → VPN client management
|
||||
/routes → Route & API token management
|
||||
/logs → Log viewer
|
||||
/configuration → System configuration
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn'] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user