Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 802bcf1c3d | |||
| bad0bd9053 | |||
| ca990781b0 | |||
| 6807aefce8 | |||
| 450ec4816e | |||
| ab4310b775 | |||
| 6efd986406 | |||
| 7370d7f0e7 | |||
| e733067c25 | |||
| bc2ed808f9 | |||
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 | |||
| d53cff6a94 | |||
| eb211348d2 | |||
| 43618abeba | |||
| dd9769b814 | |||
| 99b40fea3f | |||
| 6f72e4fdbc |
88
changelog.md
88
changelog.md
@@ -1,5 +1,93 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-27 - 11.12.4 - fix(acme)
|
||||||
use X509 certificate expiry when reporting ACME certificate validity
|
use X509 certificate expiry when reporting ACME certificate validity
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "11.12.4",
|
"version": "11.21.1",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -59,12 +59,15 @@
|
|||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.3.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
|
"@push.rocks/smartvpn": "1.16.1",
|
||||||
"@push.rocks/taskbuffer": "^8.0.2",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/catalog": "^2.9.0",
|
"@serve.zone/catalog": "^2.9.0",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^4.15.3",
|
"@serve.zone/remoteingress": "^4.15.3",
|
||||||
"@tsclass/tsclass": "^9.5.0",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"lru-cache": "^11.2.7",
|
"lru-cache": "^11.2.7",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
123
pnpm-lock.yaml
generated
123
pnpm-lock.yaml
generated
@@ -95,6 +95,9 @@ importers:
|
|||||||
'@push.rocks/smartunique':
|
'@push.rocks/smartunique':
|
||||||
specifier: ^3.0.9
|
specifier: ^3.0.9
|
||||||
version: 3.0.9
|
version: 3.0.9
|
||||||
|
'@push.rocks/smartvpn':
|
||||||
|
specifier: 1.16.1
|
||||||
|
version: 1.16.1
|
||||||
'@push.rocks/taskbuffer':
|
'@push.rocks/taskbuffer':
|
||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
@@ -110,9 +113,15 @@ importers:
|
|||||||
'@tsclass/tsclass':
|
'@tsclass/tsclass':
|
||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.5.0
|
version: 9.5.0
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
lru-cache:
|
lru-cache:
|
||||||
specifier: ^11.2.7
|
specifier: ^11.2.7
|
||||||
version: 11.2.7
|
version: 11.2.7
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.0.0
|
version: 13.0.0
|
||||||
@@ -1243,6 +1252,9 @@ packages:
|
|||||||
'@push.rocks/smartnftables@1.0.1':
|
'@push.rocks/smartnftables@1.0.1':
|
||||||
resolution: {integrity: sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==}
|
resolution: {integrity: sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==}
|
||||||
|
|
||||||
|
'@push.rocks/smartnftables@1.1.0':
|
||||||
|
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||||
|
|
||||||
'@push.rocks/smartnpm@2.0.6':
|
'@push.rocks/smartnpm@2.0.6':
|
||||||
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
||||||
|
|
||||||
@@ -1327,6 +1339,9 @@ packages:
|
|||||||
'@push.rocks/smartversion@3.0.5':
|
'@push.rocks/smartversion@3.0.5':
|
||||||
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
||||||
|
|
||||||
|
'@push.rocks/smartvpn@1.16.1':
|
||||||
|
resolution: {integrity: sha512-LQzt3ajMKIs3anYki/3drt7XcCuekoKvApCltLEjsoGEEX5JkXGSZFB+UFvqEhG8NcEuHw574rU3tB2orHzKTQ==}
|
||||||
|
|
||||||
'@push.rocks/smartwatch@6.4.0':
|
'@push.rocks/smartwatch@6.4.0':
|
||||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2038,6 +2053,9 @@ packages:
|
|||||||
'@types/node@25.5.0':
|
'@types/node@25.5.0':
|
||||||
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/randomatic@3.1.5':
|
'@types/randomatic@3.1.5':
|
||||||
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
|
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
|
||||||
|
|
||||||
@@ -2292,6 +2310,10 @@ packages:
|
|||||||
camel-case@3.0.0:
|
camel-case@3.0.0:
|
||||||
resolution: {integrity: sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=}
|
resolution: {integrity: sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
camelcase@6.3.0:
|
camelcase@6.3.0:
|
||||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2332,6 +2354,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2408,6 +2433,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decode-named-character-reference@1.3.0:
|
decode-named-character-reference@1.3.0:
|
||||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||||
|
|
||||||
@@ -2461,6 +2490,9 @@ packages:
|
|||||||
devtools-protocol@0.0.1581282:
|
devtools-protocol@0.0.1581282:
|
||||||
resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==}
|
resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
@@ -3580,6 +3612,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
pngjs@6.0.0:
|
pngjs@6.0.0:
|
||||||
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
|
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
|
||||||
engines: {node: '>=12.13.0'}
|
engines: {node: '>=12.13.0'}
|
||||||
@@ -3701,6 +3737,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
qs@6.15.0:
|
qs@6.15.0:
|
||||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -3771,6 +3812,9 @@ packages:
|
|||||||
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
|
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resolve-alpn@1.2.1:
|
resolve-alpn@1.2.1:
|
||||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||||
|
|
||||||
@@ -3826,6 +3870,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4158,6 +4205,9 @@ packages:
|
|||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=}
|
resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -4213,6 +4263,9 @@ packages:
|
|||||||
xterm@5.3.0:
|
xterm@5.3.0:
|
||||||
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
|
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4222,6 +4275,10 @@ packages:
|
|||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4230,6 +4287,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
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:
|
yargs@17.7.2:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -6325,6 +6386,11 @@ snapshots:
|
|||||||
'@push.rocks/smartlog': 3.2.1
|
'@push.rocks/smartlog': 3.2.1
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@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':
|
'@push.rocks/smartnpm@2.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/consolecolor': 2.0.3
|
'@push.rocks/consolecolor': 2.0.3
|
||||||
@@ -6556,6 +6622,12 @@ snapshots:
|
|||||||
'@types/semver': 7.7.1
|
'@types/semver': 7.7.1
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
|
|
||||||
|
'@push.rocks/smartvpn@1.16.1':
|
||||||
|
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':
|
'@push.rocks/smartwatch@6.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/lik': 6.4.0
|
'@push.rocks/lik': 6.4.0
|
||||||
@@ -7424,6 +7496,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.5.0
|
||||||
|
|
||||||
'@types/randomatic@3.1.5': {}
|
'@types/randomatic@3.1.5': {}
|
||||||
|
|
||||||
'@types/relateurl@0.2.33': {}
|
'@types/relateurl@0.2.33': {}
|
||||||
@@ -7668,6 +7744,8 @@ snapshots:
|
|||||||
no-case: 2.3.2
|
no-case: 2.3.2
|
||||||
upper-case: 1.1.3
|
upper-case: 1.1.3
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
camelcase@6.3.0: {}
|
camelcase@6.3.0: {}
|
||||||
|
|
||||||
ccount@2.0.1: {}
|
ccount@2.0.1: {}
|
||||||
@@ -7698,6 +7776,12 @@ snapshots:
|
|||||||
|
|
||||||
cli-width@4.1.0: {}
|
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:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
@@ -7772,6 +7856,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decode-named-character-reference@1.3.0:
|
decode-named-character-reference@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities: 2.0.2
|
character-entities: 2.0.2
|
||||||
@@ -7818,6 +7904,8 @@ snapshots:
|
|||||||
|
|
||||||
devtools-protocol@0.0.1581282: {}
|
devtools-protocol@0.0.1581282: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -9196,6 +9284,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
find-up: 4.1.0
|
find-up: 4.1.0
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
pngjs@6.0.0: {}
|
pngjs@6.0.0: {}
|
||||||
|
|
||||||
pngjs@7.0.0: {}
|
pngjs@7.0.0: {}
|
||||||
@@ -9381,6 +9471,12 @@ snapshots:
|
|||||||
|
|
||||||
pvutils@1.1.5: {}
|
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:
|
qs@6.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
@@ -9479,6 +9575,8 @@ snapshots:
|
|||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve-alpn@1.2.1: {}
|
resolve-alpn@1.2.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -9536,6 +9634,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.4: {}
|
semver@7.7.4: {}
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -9927,6 +10027,8 @@ snapshots:
|
|||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -9968,14 +10070,35 @@ snapshots:
|
|||||||
|
|
||||||
xterm@5.3.0: {}
|
xterm@5.3.0: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yaml@2.8.3: {}
|
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@21.1.1: {}
|
||||||
|
|
||||||
yargs-parser@22.0.0: {}
|
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:
|
yargs@17.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
|
|||||||
194
readme.md
194
readme.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
||||||
|
|
||||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
|
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- [DNS Server](#dns-server)
|
- [DNS Server](#dns-server)
|
||||||
- [RADIUS Server](#radius-server)
|
- [RADIUS Server](#radius-server)
|
||||||
- [Remote Ingress](#remote-ingress)
|
- [Remote Ingress](#remote-ingress)
|
||||||
|
- [VPN Access Control](#vpn-access-control)
|
||||||
- [Certificate Management](#certificate-management)
|
- [Certificate Management](#certificate-management)
|
||||||
- [Storage & Caching](#storage--caching)
|
- [Storage & Caching](#storage--caching)
|
||||||
- [Security Features](#security-features)
|
- [Security Features](#security-features)
|
||||||
@@ -73,6 +74,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
||||||
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
||||||
|
|
||||||
|
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
|
||||||
|
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
|
||||||
|
- **Route-level VPN gating** — mark any route with `vpn: { required: true }` to restrict access to VPN clients only
|
||||||
|
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
|
||||||
|
- **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup
|
||||||
|
- **Rootless operation** — uses userspace NAT (smoltcp) with no root required
|
||||||
|
- **Destination policy** — configurable `forceTarget`, `block`, or `allow` with allowList/blockList for granular traffic control
|
||||||
|
- **Client management** — create, enable, disable, rotate keys, export WireGuard/SmartVPN configs via OpsServer API and dashboard
|
||||||
|
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
|
||||||
|
- **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
||||||
|
|
||||||
### ⚡ High Performance
|
### ⚡ High Performance
|
||||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||||
@@ -89,7 +101,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
### 🖥️ OpsServer Dashboard
|
### 🖥️ OpsServer Dashboard
|
||||||
- **Web-based management interface** with real-time monitoring
|
- **Web-based management interface** with real-time monitoring
|
||||||
- **JWT authentication** with session persistence
|
- **JWT authentication** with session persistence
|
||||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
|
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
||||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||||
- **Remote ingress management** with connection token generation and one-click copy
|
- **Remote ingress management** with connection token generation and one-click copy
|
||||||
- **Read-only configuration display** — DcRouter is configured through code
|
- **Read-only configuration display** — DcRouter is configured through code
|
||||||
@@ -248,6 +260,15 @@ const router = new DcRouter({
|
|||||||
hubDomain: 'hub.example.com',
|
hubDomain: 'hub.example.com',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// VPN — restrict sensitive routes to VPN clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.example.com',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// Persistent storage
|
// Persistent storage
|
||||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
storage: { fsPath: '/var/lib/dcrouter/data' },
|
||||||
|
|
||||||
@@ -276,6 +297,7 @@ graph TB
|
|||||||
DNS[DNS Queries]
|
DNS[DNS Queries]
|
||||||
RAD[RADIUS Clients]
|
RAD[RADIUS Clients]
|
||||||
EDGE[Edge Nodes]
|
EDGE[Edge Nodes]
|
||||||
|
VPN[VPN Clients]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "DcRouter Core"
|
subgraph "DcRouter Core"
|
||||||
@@ -285,6 +307,7 @@ graph TB
|
|||||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||||
RS[SmartRadius Server]
|
RS[SmartRadius Server]
|
||||||
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
||||||
|
VS[SmartVPN Server<br/><i>Rust data plane</i>]
|
||||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||||
OS[OpsServer Dashboard]
|
OS[OpsServer Dashboard]
|
||||||
MM[Metrics Manager]
|
MM[Metrics Manager]
|
||||||
@@ -305,12 +328,14 @@ graph TB
|
|||||||
DNS --> DS
|
DNS --> DS
|
||||||
RAD --> RS
|
RAD --> RS
|
||||||
EDGE --> RI
|
EDGE --> RI
|
||||||
|
VPN --> VS
|
||||||
|
|
||||||
DC --> SP
|
DC --> SP
|
||||||
DC --> ES
|
DC --> ES
|
||||||
DC --> DS
|
DC --> DS
|
||||||
DC --> RS
|
DC --> RS
|
||||||
DC --> RI
|
DC --> RI
|
||||||
|
DC --> VS
|
||||||
DC --> CM
|
DC --> CM
|
||||||
DC --> OS
|
DC --> OS
|
||||||
DC --> MM
|
DC --> MM
|
||||||
@@ -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:
|
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
||||||
|
|
||||||
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
|
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and SmartVPN based on which configs are provided. Services start in dependency order via `ServiceManager`.
|
||||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartVPN runs a Rust data plane for WireGuard and custom transports. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
||||||
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
||||||
|
|
||||||
### Rust-Powered Architecture
|
### Rust-Powered Architecture
|
||||||
@@ -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 |
|
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
|
||||||
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
||||||
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
||||||
|
| **SmartVPN** | `smartvpn_daemon` | WireGuard (boringtun), Noise IK handshake, QUIC/WS transports, userspace NAT (smoltcp) |
|
||||||
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
||||||
|
|
||||||
## Configuration Reference
|
## Configuration Reference
|
||||||
@@ -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 (QUIC) ────────────────────────────────────────────
|
||||||
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
|
||||||
http3?: {
|
http3?: {
|
||||||
@@ -975,6 +1022,128 @@ The OpsServer Remote Ingress view provides:
|
|||||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||||
| **Delete** | Remove the edge registration |
|
| **Delete** | Remove the edge registration |
|
||||||
|
|
||||||
|
## VPN Access Control
|
||||||
|
|
||||||
|
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
|
||||||
|
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
|
||||||
|
3. **Split tunnel** by default — generated WireGuard configs only route VPN subnet traffic through the tunnel (`AllowedIPs = 10.8.0.0/24`), so regular internet traffic stays direct
|
||||||
|
4. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected
|
||||||
|
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: { required: 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: { required: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||||
|
// → alice + bob can access, carol cannot
|
||||||
|
},
|
||||||
|
// 🌐 Public: no VPN required
|
||||||
|
{
|
||||||
|
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
|
||||||
|
- **Enable / Disable** — toggle client access without deleting
|
||||||
|
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
||||||
|
- **Export config** — download in WireGuard (`.conf`) or SmartVPN (`.json`) format
|
||||||
|
- **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 — no custom VPN software needed.
|
||||||
|
|
||||||
## Certificate Management
|
## Certificate Management
|
||||||
|
|
||||||
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
||||||
@@ -1149,8 +1318,12 @@ The OpsServer provides a web-based management interface served on port 3000 by d
|
|||||||
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
||||||
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
||||||
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
||||||
|
| 🛣️ **Routes** | Merged route list (hardcoded + programmatic), create/edit/toggle/override routes |
|
||||||
|
| 🔑 **API Tokens** | Token management with scopes, create/revoke/roll/toggle |
|
||||||
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
||||||
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
||||||
|
| 🔐 **VPN** | VPN client management, server status, create/toggle/export/rotate/delete clients |
|
||||||
|
| 📡 **RADIUS** | NAS client management, VLAN mappings, session monitoring, accounting |
|
||||||
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
||||||
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
||||||
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
||||||
@@ -1215,6 +1388,17 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
|||||||
'getRecentLogs' // Retrieve system logs with filtering
|
'getRecentLogs' // Retrieve system logs with filtering
|
||||||
'getLogStream' // Stream live logs
|
'getLogStream' // Stream live logs
|
||||||
|
|
||||||
|
// VPN
|
||||||
|
'getVpnClients' // List all registered VPN clients
|
||||||
|
'getVpnStatus' // VPN server status (running, subnet, port, keys)
|
||||||
|
'createVpnClient' // Create client → returns WireGuard config (shown once)
|
||||||
|
'deleteVpnClient' // Remove a VPN client
|
||||||
|
'enableVpnClient' // Enable a disabled client
|
||||||
|
'disableVpnClient' // Disable a client
|
||||||
|
'rotateVpnClientKey' // Generate new keys (invalidates old ones)
|
||||||
|
'exportVpnClientConfig' // Export WireGuard (.conf) or SmartVPN (.json) config
|
||||||
|
'getVpnClientTelemetry' // Per-client bytes sent/received, keepalives
|
||||||
|
|
||||||
// RADIUS
|
// RADIUS
|
||||||
'getRadiusSessions' // Active RADIUS sessions
|
'getRadiusSessions' // Active RADIUS sessions
|
||||||
'getRadiusClients' // List NAS clients
|
'getRadiusClients' // List NAS clients
|
||||||
@@ -1332,6 +1516,7 @@ const router = new DcRouter(options: IDcRouterOptions);
|
|||||||
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
||||||
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
||||||
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
||||||
|
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
|
||||||
| `storageManager` | `StorageManager` | Storage backend |
|
| `storageManager` | `StorageManager` | Storage backend |
|
||||||
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
||||||
| `metricsManager` | `MetricsManager` | Metrics collector |
|
| `metricsManager` | `MetricsManager` | Metrics collector |
|
||||||
@@ -1458,6 +1643,7 @@ The container exposes all service ports:
|
|||||||
| 1812, 1813 | UDP | RADIUS auth/acct |
|
| 1812, 1813 | UDP | RADIUS auth/acct |
|
||||||
| 3000 | TCP | OpsServer dashboard |
|
| 3000 | TCP | OpsServer dashboard |
|
||||||
| 8443 | TCP | Remote ingress tunnels |
|
| 8443 | TCP | Remote ingress tunnels |
|
||||||
|
| 51820 | UDP | WireGuard VPN |
|
||||||
| 29000–30000 | TCP | Dynamic port range |
|
| 29000–30000 | TCP | Dynamic port range |
|
||||||
|
|
||||||
### Building the Image
|
### Building the Image
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DcRouter } from '../ts/index.js';
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
const devRouter = new DcRouter({
|
const devRouter = new DcRouter({
|
||||||
|
// Server public IP (used for VPN AllowedIPs)
|
||||||
|
publicIp: '203.0.113.1',
|
||||||
// SmartProxy routes for development/demo
|
// SmartProxy routes for development/demo
|
||||||
smartProxyConfig: {
|
smartProxyConfig: {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -23,6 +25,28 @@ const devRouter = new DcRouter({
|
|||||||
tls: { mode: 'passthrough' },
|
tls: { mode: 'passthrough' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-internal-app',
|
||||||
|
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||||
|
vpn: { required: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-eng-dashboard',
|
||||||
|
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||||
|
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||||
|
},
|
||||||
|
] as any[],
|
||||||
|
},
|
||||||
|
// VPN with pre-defined clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.dev.local',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' },
|
||||||
|
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' },
|
||||||
|
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Disable cache/mongo for dev
|
// Disable cache/mongo for dev
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.12.4',
|
version: '11.21.1',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
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 { MetricsManager } from './monitoring/index.js';
|
||||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||||
|
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||||
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
||||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
@@ -188,6 +189,39 @@ export interface IDcRouterOptions {
|
|||||||
keyPath?: string;
|
keyPath?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VPN server configuration.
|
||||||
|
* Enables VPN-based access control: routes with vpn.required 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 remoteIngressManager?: RemoteIngressManager;
|
||||||
public tunnelManager?: TunnelManager;
|
public tunnelManager?: TunnelManager;
|
||||||
|
|
||||||
|
// VPN
|
||||||
|
public vpnManager?: VpnManager;
|
||||||
|
|
||||||
// Programmatic config API
|
// Programmatic config API
|
||||||
public routeConfigManager?: RouteConfigManager;
|
public routeConfigManager?: RouteConfigManager;
|
||||||
public apiTokenManager?: ApiTokenManager;
|
public apiTokenManager?: ApiTokenManager;
|
||||||
@@ -429,6 +466,14 @@ export class DcRouter {
|
|||||||
() => this.getConstructorRoutes(),
|
() => this.getConstructorRoutes(),
|
||||||
() => this.smartProxy,
|
() => this.smartProxy,
|
||||||
() => this.options.http3,
|
() => 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);
|
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
||||||
await this.apiTokenManager.initialize();
|
await this.apiTokenManager.initialize();
|
||||||
@@ -533,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
|
// Wire up aggregated events for logging
|
||||||
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
|
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
|
||||||
const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info';
|
const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info';
|
||||||
@@ -616,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'}`);
|
logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VPN summary
|
||||||
|
if (this.vpnManager && this.options.vpnConfig?.enabled) {
|
||||||
|
const subnet = this.vpnManager.getSubnet();
|
||||||
|
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
|
||||||
|
const clientCount = this.vpnManager.listClients().length;
|
||||||
|
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Remote Ingress summary
|
// Remote Ingress summary
|
||||||
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
||||||
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
||||||
@@ -741,6 +813,11 @@ export class DcRouter {
|
|||||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VPN route security injection: restrict vpn.required routes to VPN subnet
|
||||||
|
if (this.options.vpnConfig?.enabled) {
|
||||||
|
routes = this.injectVpnSecurity(routes);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache constructor routes for RouteConfigManager
|
// Cache constructor routes for RouteConfigManager
|
||||||
this.constructorRoutes = [...routes];
|
this.constructorRoutes = [...routes];
|
||||||
|
|
||||||
@@ -892,6 +969,17 @@ export class DcRouter {
|
|||||||
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||||
|
if (this.options.vpnConfig?.enabled) {
|
||||||
|
smartProxyConfig.acceptProxyProtocol = true;
|
||||||
|
if (!smartProxyConfig.proxyIPs) {
|
||||||
|
smartProxyConfig.proxyIPs = [];
|
||||||
|
}
|
||||||
|
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||||
|
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create SmartProxy instance
|
// Create SmartProxy instance
|
||||||
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
|
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
|
||||||
|
|
||||||
@@ -1996,6 +2084,131 @@ export class DcRouter {
|
|||||||
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
|
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up VPN server for VPN-based route access control.
|
||||||
|
*/
|
||||||
|
private async setupVpnServer(): Promise<void> {
|
||||||
|
if (!this.options.vpnConfig?.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', 'Setting up VPN server...');
|
||||||
|
|
||||||
|
this.vpnManager = new VpnManager(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?.required) 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject VPN security into routes that have vpn.required === true.
|
||||||
|
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
|
||||||
|
*/
|
||||||
|
private injectVpnSecurity(routes: plugins.smartproxy.IRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
|
||||||
|
const vpnSubnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||||
|
let injectedCount = 0;
|
||||||
|
|
||||||
|
const result = routes.map((route) => {
|
||||||
|
const dcrouterRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
|
||||||
|
if (dcrouterRoute.vpn?.required) {
|
||||||
|
injectedCount++;
|
||||||
|
const existing = route.security?.ipAllowList || [];
|
||||||
|
|
||||||
|
let vpnAllowList: string[];
|
||||||
|
if (dcrouterRoute.vpn.allowedServerDefinedClientTags?.length && this.vpnManager) {
|
||||||
|
// Tag-based: only specific client IPs
|
||||||
|
vpnAllowList = this.vpnManager.getClientIpsForServerDefinedTags(
|
||||||
|
dcrouterRoute.vpn.allowedServerDefinedClientTags,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No tags specified: entire VPN subnet
|
||||||
|
vpnAllowList = [vpnSubnet];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: [...existing, ...vpnAllowList],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return route;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (injectedCount > 0) {
|
||||||
|
logger.log('info', `VPN: Injected ipAllowList into ${injectedCount} VPN-protected route(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up RADIUS server for network authentication
|
* Set up RADIUS server for network authentication
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
IMergedRoute,
|
IMergedRoute,
|
||||||
IRouteWarning,
|
IRouteWarning,
|
||||||
} from '../../ts_interfaces/data/route-management.js';
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||||
|
|
||||||
const ROUTES_PREFIX = '/config-api/routes/';
|
const ROUTES_PREFIX = '/config-api/routes/';
|
||||||
@@ -22,6 +23,7 @@ export class RouteConfigManager {
|
|||||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||||
|
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,7 +246,7 @@ export class RouteConfigManager {
|
|||||||
// Private: apply merged routes to SmartProxy
|
// Private: apply merged routes to SmartProxy
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async applyRoutes(): Promise<void> {
|
public async applyRoutes(): Promise<void> {
|
||||||
const smartProxy = this.getSmartProxy();
|
const smartProxy = this.getSmartProxy();
|
||||||
if (!smartProxy) return;
|
if (!smartProxy) return;
|
||||||
|
|
||||||
@@ -260,15 +262,31 @@ export class RouteConfigManager {
|
|||||||
enabledRoutes.push(route);
|
enabledRoutes.push(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
|
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||||
const http3Config = this.getHttp3Config?.();
|
const http3Config = this.getHttp3Config?.();
|
||||||
|
const vpnAllowList = this.getVpnAllowList;
|
||||||
for (const stored of this.storedRoutes.values()) {
|
for (const stored of this.storedRoutes.values()) {
|
||||||
if (stored.enabled) {
|
if (stored.enabled) {
|
||||||
|
let route = stored.route;
|
||||||
if (http3Config && http3Config.enabled !== false) {
|
if (http3Config && http3Config.enabled !== false) {
|
||||||
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
|
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||||
} else {
|
|
||||||
enabledRoutes.push(stored.route);
|
|
||||||
}
|
}
|
||||||
|
// Inject VPN security for programmatic routes with vpn.required
|
||||||
|
if (vpnAllowList) {
|
||||||
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
|
if (dcRoute.vpn?.required) {
|
||||||
|
const existing = route.security?.ipAllowList || [];
|
||||||
|
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
|
||||||
|
route = {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: [...existing, ...allowList],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enabledRoutes.push(route);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class OpsServer {
|
|||||||
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||||
|
private vpnHandler!: handlers.VpnHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -86,6 +87,7 @@ export class OpsServer {
|
|||||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||||
|
this.vpnHandler = new handlers.VpnHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from './certificate.handler.js';
|
|||||||
export * from './remoteingress.handler.js';
|
export * from './remoteingress.handler.js';
|
||||||
export * from './route-management.handler.js';
|
export * from './route-management.handler.js';
|
||||||
export * from './api-token.handler.js';
|
export * from './api-token.handler.js';
|
||||||
|
export * from './vpn.handler.js';
|
||||||
255
ts/opsserver/handlers/vpn.handler.ts
Normal file
255
ts/opsserver/handlers/vpn.handler.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartproxy from '@push.rocks/smartproxy';
|
import * as smartproxy from '@push.rocks/smartproxy';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
import * as smartvpn from '@push.rocks/smartvpn';
|
||||||
import * as smartradius from '@push.rocks/smartradius';
|
import * as smartradius from '@push.rocks/smartradius';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
|
||||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, 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
|
// Define SmartLog types for use in error handling
|
||||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||||
|
|||||||
445
ts/vpn/classes.vpn-manager.ts
Normal file
445
ts/vpn/classes.vpn-manager.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
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?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
@@ -2,3 +2,4 @@ export * from './auth.js';
|
|||||||
export * from './stats.js';
|
export * from './stats.js';
|
||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
export * from './route-management.js';
|
export * from './route-management.js';
|
||||||
|
export * from './vpn.js';
|
||||||
@@ -51,11 +51,23 @@ export interface IRouteRemoteIngress {
|
|||||||
edgeFilter?: string[];
|
edgeFilter?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-level VPN access configuration.
|
||||||
|
* When attached to a route, restricts access to VPN clients only.
|
||||||
|
*/
|
||||||
|
export interface IRouteVpn {
|
||||||
|
/** Whether this route requires VPN access */
|
||||||
|
required: boolean;
|
||||||
|
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
|
||||||
|
allowedServerDefinedClientTags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended route config used within dcrouter.
|
* Extended route config used within dcrouter.
|
||||||
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
|
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
|
||||||
* SmartProxy ignores unknown properties at runtime.
|
* SmartProxy ignores unknown properties at runtime.
|
||||||
*/
|
*/
|
||||||
export type IDcRouterRouteConfig = IRouteConfig & {
|
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||||
remoteIngress?: IRouteRemoteIngress;
|
remoteIngress?: IRouteRemoteIngress;
|
||||||
|
vpn?: IRouteVpn;
|
||||||
};
|
};
|
||||||
|
|||||||
44
ts_interfaces/data/vpn.ts
Normal file
44
ts_interfaces/data/vpn.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 |
|
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
||||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
||||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` property |
|
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
||||||
|
| `IRouteVpn` | Route-level VPN config: `required` flag and optional `allowedServerDefinedClientTags` |
|
||||||
|
|
||||||
|
#### VPN Interfaces
|
||||||
|
| Interface | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
|
||||||
|
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
|
||||||
|
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
||||||
|
|
||||||
### Request Interfaces (`requests`)
|
### Request Interfaces (`requests`)
|
||||||
|
|
||||||
@@ -205,6 +213,19 @@ interface ICertificateInfo {
|
|||||||
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
||||||
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
|
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
|
||||||
|
|
||||||
|
#### 🔐 VPN
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
|
||||||
|
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
|
||||||
|
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
|
||||||
|
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
|
||||||
|
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
|
||||||
|
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
|
||||||
|
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
|
||||||
|
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
|
||||||
|
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
|
||||||
|
|
||||||
#### 📡 RADIUS
|
#### 📡 RADIUS
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
|-----------|--------|-------------|
|
|-----------|--------|-------------|
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from './certificate.js';
|
|||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
export * from './route-management.js';
|
export * from './route-management.js';
|
||||||
export * from './api-tokens.js';
|
export * from './api-tokens.js';
|
||||||
|
export * from './vpn.js';
|
||||||
175
ts_interfaces/requests/vpn.ts
Normal file
175
ts_interfaces/requests/vpn.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry } 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.12.4',
|
version: '11.21.1',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -905,6 +905,161 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VPN State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IVpnState {
|
||||||
|
clients: interfaces.data.IVpnClient[];
|
||||||
|
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: [],
|
||||||
|
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 [clientsResponse, statusResponse] = await Promise.all([
|
||||||
|
clientsRequest.fire({ identity: context.identity }),
|
||||||
|
statusRequest.fire({ identity: context.identity }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
clients: clientsResponse.clients,
|
||||||
|
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 clearNewClientConfigAction = vpnStatePart.createAction(
|
||||||
|
async (statePartArg): Promise<IVpnState> => {
|
||||||
|
return { ...statePartArg.getState()!, newClientConfig: null };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Management Actions
|
// Route Management Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1372,6 +1527,15 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
console.error('Remote ingress refresh failed:', error);
|
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) {
|
} catch (error) {
|
||||||
console.error('Combined refresh failed:', error);
|
console.error('Combined refresh failed:', error);
|
||||||
// If the error looks like an auth failure (invalid JWT), force re-login
|
// 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-security.js';
|
||||||
export * from './ops-view-certificates.js';
|
export * from './ops-view-certificates.js';
|
||||||
export * from './ops-view-remoteingress.js';
|
export * from './ops-view-remoteingress.js';
|
||||||
|
export * from './ops-view-vpn.js';
|
||||||
export * from './shared/index.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 { OpsViewSecurity } from './ops-view-security.js';
|
||||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||||
|
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||||
|
|
||||||
@customElement('ops-dashboard')
|
@customElement('ops-dashboard')
|
||||||
export class OpsDashboard extends DeesElement {
|
export class OpsDashboard extends DeesElement {
|
||||||
@@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement {
|
|||||||
iconName: 'lucide:globe',
|
iconName: 'lucide:globe',
|
||||||
element: OpsViewRemoteIngress,
|
element: OpsViewRemoteIngress,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'VPN',
|
||||||
|
iconName: 'lucide:shield',
|
||||||
|
element: OpsViewVpn,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
521
ts_web/elements/ops-view-vpn.ts
Normal file
521
ts_web/elements/ops-view-vpn.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
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')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
const status = this.vpnState.status;
|
||||||
|
const clients = this.vpnState.clients;
|
||||||
|
const connectedCount = status?.connectedClients ?? 0;
|
||||||
|
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) => ({
|
||||||
|
'Client ID': client.clientId,
|
||||||
|
'Status': client.enabled
|
||||||
|
? html`<span class="statusBadge enabled">enabled</span>`
|
||||||
|
: html`<span class="statusBadge disabled">disabled</span>`,
|
||||||
|
'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: 'Toggle',
|
||||||
|
iconName: 'lucide:power',
|
||||||
|
type: ['contextmenu', 'inRow'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
|
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
||||||
|
clientId: client.clientId,
|
||||||
|
enabled: !client.enabled,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: '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
|
// TypedSocket for real-time push communication
|
||||||
import * as typedsocket from '@api.global/typedsocket';
|
import * as typedsocket from '@api.global/typedsocket';
|
||||||
|
|
||||||
|
// QR code generation for WireGuard configs
|
||||||
|
import * as qrcode from 'qrcode';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
deesElement,
|
deesElement,
|
||||||
deesCatalog,
|
deesCatalog,
|
||||||
szCatalog,
|
szCatalog,
|
||||||
typedsocket,
|
typedsocket,
|
||||||
|
qrcode,
|
||||||
}
|
}
|
||||||
|
|
||||||
// domtools gives us TypedRequest and other utilities
|
// domtools gives us TypedRequest and other utilities
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
|
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
|
||||||
- Enable/disable, edit, secret regeneration, and delete actions
|
- 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 and clipboard copy on client creation
|
||||||
|
- Per-client telemetry (bytes sent/received, keepalives)
|
||||||
|
- Server public key display for manual client configuration
|
||||||
|
|
||||||
### 📜 Log Viewer
|
### 📜 Log Viewer
|
||||||
- Real-time log streaming
|
- Real-time log streaming
|
||||||
- Filter by log level (error, warning, info, debug)
|
- Filter by log level (error, warning, info, debug)
|
||||||
@@ -100,6 +107,7 @@ ts_web/
|
|||||||
├── ops-view-emails.ts # Email queue management
|
├── ops-view-emails.ts # Email queue management
|
||||||
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
||||||
├── ops-view-remoteingress.ts # Remote ingress edge management
|
├── ops-view-remoteingress.ts # Remote ingress edge management
|
||||||
|
├── ops-view-vpn.ts # VPN client management
|
||||||
├── ops-view-logs.ts # Log viewer
|
├── ops-view-logs.ts # Log viewer
|
||||||
├── ops-view-routes.ts # Route & API token management
|
├── ops-view-routes.ts # Route & API token management
|
||||||
├── ops-view-config.ts # Configuration display
|
├── ops-view-config.ts # Configuration display
|
||||||
@@ -124,6 +132,7 @@ The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled
|
|||||||
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
|
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
|
||||||
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
||||||
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
||||||
|
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
|
||||||
|
|
||||||
### Tab Visibility Optimization
|
### Tab Visibility Optimization
|
||||||
|
|
||||||
@@ -173,6 +182,13 @@ regenerateRemoteIngressSecretAction(id) // New secret
|
|||||||
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
||||||
clearNewEdgeSecretAction() // Dismiss secret banner
|
clearNewEdgeSecretAction() // Dismiss secret banner
|
||||||
fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
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
|
### Client-Side Routing
|
||||||
@@ -187,6 +203,7 @@ fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
|||||||
/emails/security → Security incidents
|
/emails/security → Security incidents
|
||||||
/certificates → Certificate management
|
/certificates → Certificate management
|
||||||
/remoteingress → Remote ingress edge management
|
/remoteingress → Remote ingress edge management
|
||||||
|
/vpn → VPN client management
|
||||||
/routes → Route & API token management
|
/routes → Route & API token management
|
||||||
/logs → Log viewer
|
/logs → Log viewer
|
||||||
/configuration → System configuration
|
/configuration → System configuration
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
|
|||||||
|
|
||||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
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];
|
export type TValidView = typeof validViews[number];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user