Compare commits

...

34 Commits

Author SHA1 Message Date
95daee1d8f v11.22.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 09:53:37 +00:00
11ca64a1cd feat(vpn): add VPN client editing and connected client visibility in ops server 2026-03-31 09:53:37 +00:00
cfb727b86d v11.21.5
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 04:15:51 +00:00
1e4b9997f4 fix(routing): apply VPN route allowlists dynamically after VPN clients load 2026-03-31 04:15:51 +00:00
bb32f23d77 v11.21.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 03:36:36 +00:00
1aa6451dba fix(deps): bump @push.rocks/smartvpn to 1.16.4 2026-03-31 03:36:36 +00:00
eb0408c036 v11.21.3
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 03:21:41 +00:00
098a2567fa fix(deps): bump @push.rocks/smartvpn to 1.16.3 2026-03-31 03:21:41 +00:00
c6534df362 v11.21.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 02:12:18 +00:00
2e4b375ad5 fix(deps): bump @push.rocks/smartvpn to 1.16.2 2026-03-31 02:12:18 +00:00
802bcf1c3d v11.21.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 01:10:19 +00:00
bad0bd9053 fix(vpn): resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups 2026-03-31 01:10:19 +00:00
ca990781b0 v11.21.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 00:45:46 +00:00
6807aefce8 feat(vpn): add tag-aware WireGuard AllowedIPs for VPN-gated routes 2026-03-31 00:45:46 +00:00
450ec4816e v11.20.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 00:08:54 +00:00
ab4310b775 fix(vpn-manager): persist WireGuard private keys for valid client exports and QR codes 2026-03-31 00:08:54 +00:00
6efd986406 v11.20.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 23:50:51 +00:00
7370d7f0e7 feat(vpn-ui): add QR code export for WireGuard client configurations 2026-03-30 23:50:51 +00:00
e733067c25 v11.19.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 18:14:51 +00:00
bc2ed808f9 fix(vpn): configure SmartVPN client exports with explicit server endpoint and split-tunnel allowed IPs 2026-03-30 18:14:51 +00:00
61d856f371 v11.19.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 17:24:18 +00:00
a8d52a4709 feat(vpn): document tag-based VPN access control, declarative clients, and destination policy options 2026-03-30 17:24:17 +00:00
f685ce9928 v11.18.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 17:08:57 +00:00
699aa8a8e1 feat(vpn-ui): add format selection for VPN client config exports 2026-03-30 17:08:57 +00:00
6fa7206f86 v11.17.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 16:49:58 +00:00
11cce23e21 feat(vpn): expand VPN operations view with client management and config export actions 2026-03-30 16:49:58 +00:00
d109554134 v11.16.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 13:06:14 +00:00
cc3a7cb5b6 feat(vpn): add destination-based VPN routing policy and standardize socket proxy forwarding 2026-03-30 13:06:14 +00:00
d53cff6a94 v11.15.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 12:07:58 +00:00
eb211348d2 feat(vpn): add tag-based VPN route access control and support configured initial VPN clients 2026-03-30 12:07:58 +00:00
43618abeba v11.14.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:59:38 +00:00
dd9769b814 feat(docs): document VPN access control and add OpsServer VPN navigation 2026-03-30 08:59:38 +00:00
99b40fea3f v11.13.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-30 08:15:09 +00:00
6f72e4fdbc feat(vpn): add VPN server management and route-based VPN access control 2026-03-30 08:15:09 +00:00
29 changed files with 2751 additions and 23 deletions

View File

@@ -1,5 +1,123 @@
# Changelog
## 2026-03-31 - 11.22.0 - feat(vpn)
add VPN client editing and connected client visibility in ops server
- Adds API support to list currently connected VPN clients and update client metadata without rotating keys
- Updates the web VPN view to show live connection status, client detail telemetry, and separate enable/disable actions
- Refreshes documentation for smart split tunnel behavior, QR code setup/export, and storage architecture
- Bumps @push.rocks/smartvpn from 1.16.4 to 1.16.5
## 2026-03-31 - 11.21.5 - fix(routing)
apply VPN route allowlists dynamically after VPN clients load
- Moves VPN security injection for hardcoded and programmatic routes into RouteConfigManager.applyRoutes() so allowlists are generated from current VPN client state.
- Re-applies routes after starting the VPN manager to ensure tag-based ipAllowLists are available once VPN clients are loaded.
- Avoids caching constructor routes with stale VPN security baked in while preserving HTTP/3 route augmentation.
## 2026-03-31 - 11.21.4 - fix(deps)
bump @push.rocks/smartvpn to 1.16.4
- Updates the @push.rocks/smartvpn dependency from 1.16.3 to 1.16.4 in package.json.
## 2026-03-31 - 11.21.3 - fix(deps)
bump @push.rocks/smartvpn to 1.16.3
- Updates the @push.rocks/smartvpn dependency from 1.16.2 to 1.16.3.
## 2026-03-31 - 11.21.2 - fix(deps)
bump @push.rocks/smartvpn to 1.16.2
- Updates the @push.rocks/smartvpn dependency from 1.16.1 to 1.16.2 in package.json.
## 2026-03-31 - 11.21.1 - fix(vpn)
resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups
- Derive WireGuard AllowedIPs from DNS A records of matched vpn.required route domains instead of only configured public proxy IPs.
- Cache resolved domain IPs for 5 minutes and fall back to stale results on DNS lookup failures.
- Make per-client AllowedIPs generation asynchronous throughout VPN config export and regeneration flows.
## 2026-03-31 - 11.21.0 - feat(vpn)
add tag-aware WireGuard AllowedIPs for VPN-gated routes
- compute per-client WireGuard AllowedIPs from server-defined client tags and VPN-required proxy routes
- include the server public IP in AllowedIPs when a client can access VPN-gated domains so routed traffic reaches the proxy
- preserve and inject WireGuard private keys in generated and exported client configs for valid exports
## 2026-03-31 - 11.20.1 - fix(vpn-manager)
persist WireGuard private keys for valid client exports and QR codes
- Store each client's WireGuard private key when creating and rotating keys.
- Inject the stored private key into exported WireGuard configs so generated configs are complete and scannable.
## 2026-03-30 - 11.20.0 - feat(vpn-ui)
add QR code export for WireGuard client configurations
- adds a QR code action for newly created WireGuard configs in the VPN operations view
- adds a QR code export option for existing VPN clients alongside file downloads
- introduces qrcode and @types/qrcode dependencies and exposes the plugin for web UI use
## 2026-03-30 - 11.19.1 - fix(vpn)
configure SmartVPN client exports with explicit server endpoint and split-tunnel allowed IPs
- Pass the configured WireGuard server endpoint directly to SmartVPN instead of rewriting generated client configs in dcrouter.
- Set client allowed IPs to the VPN subnet so generated WireGuard configs default to split-tunnel routing.
- Update documentation to reflect SmartVPN startup, dashboard/API coverage, and the new split-tunnel behavior.
- Bump @push.rocks/smartvpn from 1.14.0 to 1.16.1 to support the updated VPN configuration flow.
## 2026-03-30 - 11.19.0 - feat(vpn)
document tag-based VPN access control, declarative clients, and destination policy options
- Adds documentation for restricting VPN-protected routes with allowedServerDefinedClientTags.
- Documents pre-defined VPN clients in configuration via vpnConfig.clients.
- Describes destinationPolicy behavior for forceTarget, allow, and block traffic handling.
- Updates interface docs to reflect serverDefinedClientTags and revised VPN server status fields.
## 2026-03-30 - 11.18.0 - feat(vpn-ui)
add format selection for VPN client config exports
- Show an export modal that lets operators choose between WireGuard (.conf) and SmartVPN (.json) client configs.
- Update VPN client row actions to read the selected item from actionData for toggle, export, rotate keys, and delete handlers.
## 2026-03-30 - 11.17.0 - feat(vpn)
expand VPN operations view with client management and config export actions
- adds predefined VPN clients to the dev server configuration for local testing
- adds table actions to create clients, export WireGuard configs, rotate client keys, toggle access, and delete clients
- updates the VPN view layout and stats grid binding to match the current component API
## 2026-03-30 - 11.16.0 - feat(vpn)
add destination-based VPN routing policy and standardize socket proxy forwarding
- replace configurable VPN forwarding mode with socket-based forwarding and always enable proxy protocol support to SmartProxy from localhost
- add destinationPolicy configuration for controlling default VPN traffic handling, including forceTarget, allow, and block rules
- remove forwarding mode reporting from VPN status APIs, logs, and ops UI to reflect the simplified VPN runtime model
- update @push.rocks/smartvpn to 1.14.0 to support the new VPN routing behavior
## 2026-03-30 - 11.15.0 - feat(vpn)
add tag-based VPN route access control and support configured initial VPN clients
- allow VPN-protected routes to restrict access to clients with matching server-defined tags instead of always permitting the full VPN subnet
- create configured VPN clients automatically on startup and re-apply routes when VPN clients change
- rename VPN client tag fields to serverDefinedClientTags across APIs, interfaces, handlers, and UI with legacy tag migration on load
- upgrade @push.rocks/smartvpn from 1.12.0 to 1.13.0
## 2026-03-30 - 11.14.0 - feat(docs)
document VPN access control and add OpsServer VPN navigation
- Adds comprehensive README documentation for VPN access control, configuration, operating modes, and client management
- Updates TypeScript interface documentation with VPN-related route, client, status, telemetry, and API request types
- Extends web dashboard documentation and router view list to include VPN management
## 2026-03-30 - 11.13.0 - feat(vpn)
add VPN server management and route-based VPN access control
- introduces a VPN manager backed by @push.rocks/smartvpn with persisted server keys and client registrations
- adds ops API handlers and typed request interfaces for VPN client lifecycle, status, config export, and telemetry
- adds ops dashboard VPN view and application state for managing VPN clients from the web UI
- supports vpn.required on routes by injecting VPN subnet allowlists into static and programmatic SmartProxy routes
- configures SmartProxy to accept proxy protocol in VPN socket forwarding mode to preserve client tunnel IPs
## 2026-03-27 - 11.12.4 - fix(acme)
use X509 certificate expiry when reporting ACME certificate validity

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "11.12.4",
"version": "11.22.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -59,12 +59,15 @@
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.16.5",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.2.7",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
},
"keywords": [

123
pnpm-lock.yaml generated
View File

@@ -95,6 +95,9 @@ importers:
'@push.rocks/smartunique':
specifier: ^3.0.9
version: 3.0.9
'@push.rocks/smartvpn':
specifier: 1.16.5
version: 1.16.5
'@push.rocks/taskbuffer':
specifier: ^8.0.2
version: 8.0.2
@@ -110,9 +113,15 @@ importers:
'@tsclass/tsclass':
specifier: ^9.5.0
version: 9.5.0
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
lru-cache:
specifier: ^11.2.7
version: 11.2.7
qrcode:
specifier: ^1.5.4
version: 1.5.4
uuid:
specifier: ^13.0.0
version: 13.0.0
@@ -1243,6 +1252,9 @@ packages:
'@push.rocks/smartnftables@1.0.1':
resolution: {integrity: sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==}
'@push.rocks/smartnftables@1.1.0':
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
'@push.rocks/smartnpm@2.0.6':
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
@@ -1327,6 +1339,9 @@ packages:
'@push.rocks/smartversion@3.0.5':
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
'@push.rocks/smartvpn@1.16.5':
resolution: {integrity: sha512-wUau/Ad18p36AeIF5R33S45WEM78R7Y4SZSkWdxMdvKNIqSfn1mhf4Zd57iAtxvq+2iO246xfifBrATZWfjPeQ==}
'@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
engines: {node: '>=20.0.0'}
@@ -2038,6 +2053,9 @@ packages:
'@types/node@25.5.0':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/randomatic@3.1.5':
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
@@ -2292,6 +2310,10 @@ packages:
camel-case@3.0.0:
resolution: {integrity: sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
@@ -2332,6 +2354,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -2408,6 +2433,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=}
engines: {node: '>=0.10.0'}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -2461,6 +2490,9 @@ packages:
devtools-protocol@0.0.1581282:
resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -3580,6 +3612,10 @@ packages:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@6.0.0:
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
engines: {node: '>=12.13.0'}
@@ -3701,6 +3737,11 @@ packages:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
@@ -3771,6 +3812,9 @@ packages:
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
@@ -3826,6 +3870,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -4158,6 +4205,9 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -4213,6 +4263,9 @@ packages:
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -4222,6 +4275,10 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -4230,6 +4287,10 @@ packages:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -6325,6 +6386,11 @@ snapshots:
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartnftables@1.1.0':
dependencies:
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartnpm@2.0.6':
dependencies:
'@push.rocks/consolecolor': 2.0.3
@@ -6556,6 +6622,12 @@ snapshots:
'@types/semver': 7.7.1
semver: 7.7.4
'@push.rocks/smartvpn@1.16.5':
dependencies:
'@push.rocks/smartnftables': 1.1.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2
'@push.rocks/smartwatch@6.4.0':
dependencies:
'@push.rocks/lik': 6.4.0
@@ -7424,6 +7496,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 25.5.0
'@types/randomatic@3.1.5': {}
'@types/relateurl@0.2.33': {}
@@ -7668,6 +7744,8 @@ snapshots:
no-case: 2.3.2
upper-case: 1.1.3
camelcase@5.3.1: {}
camelcase@6.3.0: {}
ccount@2.0.1: {}
@@ -7698,6 +7776,12 @@ snapshots:
cli-width@4.1.0: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -7772,6 +7856,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -7818,6 +7904,8 @@ snapshots:
devtools-protocol@0.0.1581282: {}
dijkstrajs@1.0.3: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -9196,6 +9284,8 @@ snapshots:
dependencies:
find-up: 4.1.0
pngjs@5.0.0: {}
pngjs@6.0.0: {}
pngjs@7.0.0: {}
@@ -9381,6 +9471,12 @@ snapshots:
pvutils@1.1.5: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.0:
dependencies:
side-channel: 1.1.0
@@ -9479,6 +9575,8 @@ snapshots:
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve-alpn@1.2.1: {}
resolve-from@4.0.0: {}
@@ -9536,6 +9634,8 @@ snapshots:
semver@7.7.4: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -9927,6 +10027,8 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -9968,14 +10070,35 @@ snapshots:
xterm@5.3.0: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yaml@2.8.3: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1

195
readme.md
View File

@@ -4,7 +4,7 @@
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, VPN, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, VPN-based access control, distributed edge networking, and enterprise-grade email infrastructure.
## Issue Reporting and Security
@@ -23,6 +23,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- [DNS Server](#dns-server)
- [RADIUS Server](#radius-server)
- [Remote Ingress](#remote-ingress)
- [VPN Access Control](#vpn-access-control)
- [Certificate Management](#certificate-management)
- [Storage & Caching](#storage--caching)
- [Security Features](#security-features)
@@ -73,6 +74,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
- **Route-level VPN gating** — mark any route with `vpn: { 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
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
@@ -89,7 +101,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 🖥️ OpsServer Dashboard
- **Web-based management interface** with real-time monitoring
- **JWT authentication** with session persistence
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
- **Remote ingress management** with connection token generation and one-click copy
- **Read-only configuration display** — DcRouter is configured through code
@@ -248,6 +260,15 @@ const router = new DcRouter({
hubDomain: 'hub.example.com',
},
// VPN — restrict sensitive routes to VPN clients
vpnConfig: {
enabled: true,
serverEndpoint: 'vpn.example.com',
clients: [
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
],
},
// Persistent storage
storage: { fsPath: '/var/lib/dcrouter/data' },
@@ -276,6 +297,7 @@ graph TB
DNS[DNS Queries]
RAD[RADIUS Clients]
EDGE[Edge Nodes]
VPN[VPN Clients]
end
subgraph "DcRouter Core"
@@ -285,6 +307,7 @@ graph TB
DS[SmartDNS Server<br/><i>Rust-powered</i>]
RS[SmartRadius Server]
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
VS[SmartVPN Server<br/><i>Rust data plane</i>]
CM[Certificate Manager<br/><i>smartacme v9</i>]
OS[OpsServer Dashboard]
MM[Metrics Manager]
@@ -305,12 +328,14 @@ graph TB
DNS --> DS
RAD --> RS
EDGE --> RI
VPN --> VS
DC --> SP
DC --> ES
DC --> DS
DC --> RS
DC --> RI
DC --> VS
DC --> CM
DC --> OS
DC --> MM
@@ -347,8 +372,8 @@ graph TB
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and SmartVPN based on which configs are provided. Services start in dependency order via `ServiceManager`.
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartVPN runs a Rust data plane for WireGuard and custom transports. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
### Rust-Powered Architecture
@@ -361,6 +386,7 @@ DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-c
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
| **SmartVPN** | `smartvpn_daemon` | WireGuard (boringtun), Noise IK handshake, QUIC/WS transports, userspace NAT (smoltcp) |
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
## Configuration Reference
@@ -428,6 +454,27 @@ interface IDcRouterOptions {
};
};
// ── VPN ───────────────────────────────────────────────────────
/** VPN server for route-level access control */
vpnConfig?: {
enabled?: boolean; // default: false
subnet?: string; // default: '10.8.0.0/24'
wgListenPort?: number; // default: 51820
dns?: string[]; // DNS servers pushed to VPN clients
serverEndpoint?: string; // Hostname in generated client configs
clients?: Array<{ // Pre-defined VPN clients
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
destinationPolicy?: { // Traffic routing policy
default: 'forceTarget' | 'block' | 'allow';
target?: string; // IP for forceTarget (default: '127.0.0.1')
allowList?: string[]; // Pass through directly
blockList?: string[]; // Always block (overrides allowList)
};
};
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
/** HTTP/3 config — enabled by default on qualifying HTTPS routes */
http3?: {
@@ -975,6 +1022,129 @@ The OpsServer Remote Ingress view provides:
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
| **Delete** | Remove the edge registration |
## VPN Access Control
DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks/smartvpn) to provide VPN-based route access control. VPN clients connect via standard WireGuard or native WebSocket/QUIC transports, receive an IP from a configurable subnet, and can then access routes that are restricted to VPN-only traffic.
### How It Works
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
3. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.required` are resolved at config generation time, so clients route only the necessary traffic through the tunnel
4. Routes with `vpn: { required: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change)
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
- **QR code** — scan with the WireGuard mobile app (iOS/Android) for instant setup
- **Enable / Disable** — toggle client access without deleting
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
- **Export config** — download in WireGuard (`.conf`), SmartVPN (`.json`), or scan as QR code
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
- **Delete** — remove a client and revoke access
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or by scanning the QR code — no custom VPN software needed.
## Certificate Management
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
@@ -1149,8 +1319,12 @@ The OpsServer provides a web-based management interface served on port 3000 by d
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
| 🛣️ **Routes** | Merged route list (hardcoded + programmatic), create/edit/toggle/override routes |
| 🔑 **API Tokens** | Token management with scopes, create/revoke/roll/toggle |
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
| 🔐 **VPN** | VPN client management, server status, create/toggle/export/rotate/delete clients |
| 📡 **RADIUS** | NAS client management, VLAN mappings, session monitoring, accounting |
| 📜 **Logs** | Real-time log viewer with level filtering and search |
| ⚙️ **Configuration** | Read-only view of current system configuration |
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
@@ -1215,6 +1389,17 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
'getRecentLogs' // Retrieve system logs with filtering
'getLogStream' // Stream live logs
// VPN
'getVpnClients' // List all registered VPN clients
'getVpnStatus' // VPN server status (running, subnet, port, keys)
'createVpnClient' // Create client → returns WireGuard config (shown once)
'deleteVpnClient' // Remove a VPN client
'enableVpnClient' // Enable a disabled client
'disableVpnClient' // Disable a client
'rotateVpnClientKey' // Generate new keys (invalidates old ones)
'exportVpnClientConfig' // Export WireGuard (.conf) or SmartVPN (.json) config
'getVpnClientTelemetry' // Per-client bytes sent/received, keepalives
// RADIUS
'getRadiusSessions' // Active RADIUS sessions
'getRadiusClients' // List NAS clients
@@ -1332,6 +1517,7 @@ const router = new DcRouter(options: IDcRouterOptions);
| `radiusServer` | `RadiusServer` | RADIUS server instance |
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
| `storageManager` | `StorageManager` | Storage backend |
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
| `metricsManager` | `MetricsManager` | Metrics collector |
@@ -1458,6 +1644,7 @@ The container exposes all service ports:
| 1812, 1813 | UDP | RADIUS auth/acct |
| 3000 | TCP | OpsServer dashboard |
| 8443 | TCP | Remote ingress tunnels |
| 51820 | UDP | WireGuard VPN |
| 2900030000 | TCP | Dynamic port range |
### Building the Image

120
readme.storage.md Normal file
View File

@@ -0,0 +1,120 @@
# DCRouter Storage Overview
DCRouter uses two complementary storage systems: **StorageManager** for configuration and state, and **CacheDb** for time-limited cached data.
## StorageManager (Key-Value Store)
A lightweight, pluggable key-value store for configuration, credentials, and runtime state. Data is persisted as flat JSON files on disk by default.
### Default Path
```
~/.serve.zone/dcrouter/storage/
```
Configurable via `options.storage.fsPath` or `options.baseDir`.
### Backends
```typescript
// Filesystem (default)
storage: { fsPath: '/var/lib/dcrouter/data' }
// Custom (Redis, S3, etc.)
storage: {
readFunction: async (key) => await redis.get(key),
writeFunction: async (key, value) => await redis.set(key, value),
}
// In-memory (omit storage config — data lost on restart)
```
### What's Stored
| Prefix | Contents | Managed By |
|--------|----------|------------|
| `/vpn/server-keys` | VPN server Noise + WireGuard keypairs | `VpnManager` |
| `/vpn/clients/{clientId}` | VPN client registrations (keys, tags, description, assigned IP) | `VpnManager` |
| `/config-api/routes/{uuid}.json` | Programmatic routes (created via OpsServer API) | `RouteConfigManager` |
| `/config-api/tokens/{uuid}.json` | API tokens (hashed secrets, scopes, expiry) | `ApiTokenManager` |
| `/config-api/overrides/{routeName}.json` | Hardcoded route overrides (enable/disable) | `RouteConfigManager` |
| `/email/bounces/suppression-list.json` | Email bounce suppression list | `smartmta` |
| `/certs/*` | TLS certificates and ACME state | `SmartAcme` (via `StorageBackedCertManager`) |
### API
```typescript
// Read/write JSON
await storageManager.getJSON<T>(key);
await storageManager.setJSON(key, value);
// Raw string read/write
await storageManager.get(key);
await storageManager.set(key, value);
// List keys by prefix
await storageManager.list('/vpn/clients/');
// Delete
await storageManager.delete(key);
```
## CacheDb (Embedded MongoDB)
An embedded MongoDB-compatible database (via `@push.rocks/smartdb` + `@push.rocks/smartdata`) for cached data with automatic TTL-based cleanup.
### Default Path
```
~/.serve.zone/dcrouter/tsmdb/
```
Configurable via `options.cacheConfig.storagePath`.
### What's Cached
| Document Type | Default TTL | Purpose |
|--------------|-------------|---------|
| `CachedEmail` | 30 days | Email metadata cache for dashboard display |
| `CachedIPReputation` | 1 day | IP reputation lookup results (DNSBL checks) |
### Configuration
```typescript
cacheConfig: {
enabled: true, // default: true
storagePath: '~/.serve.zone/dcrouter/tsmdb', // default
dbName: 'dcrouter', // default
cleanupIntervalHours: 1, // how often to purge expired records
ttlConfig: {
emails: 30, // days
ipReputation: 1, // days
bounces: 30, // days (reserved)
dkimKeys: 90, // days (reserved)
suppression: 30, // days (reserved)
},
}
```
### How It Works
1. `CacheDb` starts a `LocalSmartDb` instance (embedded MongoDB process)
2. `smartdata` connects to it via Unix socket
3. Document classes (`CachedEmail`, `CachedIPReputation`) are decorated with `@Collection` and use `smartdata` ORM
4. `CacheCleaner` runs on a timer, purging records older than their configured TTL
### Disabling
For development or lightweight deployments, disable the cache to avoid starting a MongoDB process:
```typescript
cacheConfig: { enabled: false }
```
## When to Use Which
| Use Case | System | Why |
|----------|--------|-----|
| VPN keys, API tokens, routes, certs | **StorageManager** | Small JSON blobs, key-value access, no queries needed |
| Email metadata, IP reputation | **CacheDb** | Time-series data, TTL expiry, potential for queries/aggregation |
| Runtime state (connected clients, metrics) | **Neither** | In-memory only, rebuilt on startup |

View File

@@ -1,6 +1,8 @@
import { DcRouter } from '../ts/index.js';
const devRouter = new DcRouter({
// Server public IP (used for VPN AllowedIPs)
publicIp: '203.0.113.1',
// SmartProxy routes for development/demo
smartProxyConfig: {
routes: [
@@ -23,6 +25,28 @@ const devRouter = new DcRouter({
tls: { mode: 'passthrough' },
},
},
{
name: 'vpn-internal-app',
match: { ports: [18080], domains: ['internal.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
vpn: { 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

View File

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

View File

@@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -188,6 +189,39 @@ export interface IDcRouterOptions {
keyPath?: string;
};
};
/**
* VPN server configuration.
* Enables VPN-based access control: routes with vpn.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 tunnelManager?: TunnelManager;
// VPN
public vpnManager?: VpnManager;
// Programmatic config API
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
@@ -429,6 +466,14 @@ export class DcRouter {
() => this.getConstructorRoutes(),
() => this.smartProxy,
() => this.options.http3,
this.options.vpnConfig?.enabled
? (tags?: string[]) => {
if (tags?.length && this.vpnManager) {
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
}
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
}
: undefined,
);
this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize();
@@ -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
this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => {
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'}`);
}
// VPN summary
if (this.vpnManager && this.options.vpnConfig?.enabled) {
const subnet = this.vpnManager.getSubnet();
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
const clientCount = this.vpnManager.listClients().length;
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
}
// Remote Ingress summary
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
@@ -741,7 +813,8 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
}
// Cache constructor routes for RouteConfigManager
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
this.constructorRoutes = [...routes];
// If we have routes or need a basic SmartProxy instance, create it
@@ -892,6 +965,17 @@ export class DcRouter {
smartProxyConfig.proxyIPs = ['127.0.0.1'];
}
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
if (this.options.vpnConfig?.enabled) {
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
}
// Create SmartProxy instance
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
@@ -1996,6 +2080,96 @@ export class DcRouter {
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
}
/**
* Set up VPN server for VPN-based route access control.
*/
private async setupVpnServer(): Promise<void> {
if (!this.options.vpnConfig?.enabled) {
return;
}
logger.log('info', 'Setting up VPN server...');
this.vpnManager = new VpnManager(this.storageManager, {
subnet: this.options.vpnConfig.subnet,
wgListenPort: this.options.vpnConfig.wgListenPort,
dns: this.options.vpnConfig.dns,
serverEndpoint: this.options.vpnConfig.serverEndpoint,
initialClients: this.options.vpnConfig.clients,
destinationPolicy: this.options.vpnConfig.destinationPolicy,
onClientChanged: () => {
// Re-apply routes so tag-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();
},
getClientAllowedIPs: async (clientTags: string[]) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]);
// Check routes for VPN-gated tag match and collect domains
const routes = this.options.smartProxyConfig?.routes || [];
const domainsToResolve = new Set<string>();
for (const route of routes) {
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
if (!dcRoute.vpn?.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();
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
// get correct tag-based ipAllowLists (not possible during setupSmartProxy since
// VPN server wasn't ready yet)
this.routeConfigManager?.applyRoutes();
}
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
/**
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
*/
private async resolveVpnDomainIPs(domain: string): Promise<string[]> {
const cached = this.vpnDomainIpCache.get(domain);
if (cached && cached.expiresAt > Date.now()) {
return cached.ips;
}
try {
const { promises: dnsPromises } = await import('dns');
const ips = await dnsPromises.resolve4(domain);
this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
return ips;
} catch (err) {
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
return cached?.ips || []; // Return stale cache on failure, or empty
}
}
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
// via the getVpnAllowList callback — no longer a separate method here.
/**
* Set up RADIUS server for network authentication
*/

View File

@@ -7,6 +7,7 @@ import type {
IMergedRoute,
IRouteWarning,
} from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
const ROUTES_PREFIX = '/config-api/routes/';
@@ -22,6 +23,7 @@ export class RouteConfigManager {
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnAllowList?: (tags?: string[]) => string[],
) {}
/**
@@ -244,31 +246,48 @@ export class RouteConfigManager {
// Private: apply merged routes to SmartProxy
// =========================================================================
private async applyRoutes(): Promise<void> {
public async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Add enabled hardcoded routes (respecting overrides)
const http3Config = this.getHttp3Config?.();
const vpnAllowList = this.getVpnAllowList;
// Helper: inject VPN security into a route if vpn.required is set
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => {
if (!vpnAllowList) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpn?.required) return route;
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
return {
...route,
security: {
...route.security,
ipAllowList: [...(route.security?.ipAllowList || []), ...allowList],
},
};
};
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
if (override && !override.enabled) {
continue; // Skip disabled hardcoded route
}
enabledRoutes.push(route);
enabledRoutes.push(injectVpn(route));
}
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
const http3Config = this.getHttp3Config?.();
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
for (const stored of this.storedRoutes.values()) {
if (stored.enabled) {
let route = stored.route;
if (http3Config && http3Config.enabled !== false) {
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
} else {
enabledRoutes.push(stored.route);
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
}
enabledRoutes.push(injectVpn(route));
}
}

View File

@@ -28,6 +28,7 @@ export class OpsServer {
private remoteIngressHandler!: handlers.RemoteIngressHandler;
private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler!: handlers.ApiTokenHandler;
private vpnHandler!: handlers.VpnHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -86,6 +87,7 @@ export class OpsServer {
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
this.vpnHandler = new handlers.VpnHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

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

View File

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

View File

@@ -58,13 +58,14 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartvpn from '@push.rocks/smartvpn';
import * as smartradius from '@push.rocks/smartradius';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique';
import * as taskbuffer from '@push.rocks/taskbuffer';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer };
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
// Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';

View File

@@ -0,0 +1,461 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/classes.storagemanager.js';
const STORAGE_PREFIX_KEYS = '/vpn/server-keys';
const STORAGE_PREFIX_CLIENTS = '/vpn/clients/';
export interface IVpnManagerConfig {
/** VPN subnet CIDR (default: '10.8.0.0/24') */
subnet?: string;
/** WireGuard UDP listen port (default: 51820) */
wgListenPort?: number;
/** DNS servers pushed to VPN clients */
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
initialClients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
target?: string;
allowList?: string[];
blockList?: string[];
};
/** Compute per-client AllowedIPs based on the client's server-defined tags.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
}
interface IPersistedServerKeys {
noisePrivateKey: string;
noisePublicKey: string;
wgPrivateKey: string;
wgPublicKey: string;
}
interface IPersistedClient {
clientId: string;
enabled: boolean;
serverDefinedClientTags?: string[];
description?: string;
assignedIp?: string;
noisePublicKey: string;
wgPublicKey: string;
/** WireGuard private key — stored so exports and QR codes produce valid configs */
wgPrivateKey?: string;
createdAt: number;
updatedAt: number;
expiresAt?: string;
/** @deprecated Legacy field — migrated to serverDefinedClientTags on load */
tags?: string[];
}
/**
* Manages the SmartVPN server lifecycle and VPN client CRUD.
* Persists server keys and client registrations via StorageManager.
*/
export class VpnManager {
private storageManager: StorageManager;
private config: IVpnManagerConfig;
private vpnServer?: plugins.smartvpn.VpnServer;
private clients: Map<string, IPersistedClient> = new Map();
private serverKeys?: IPersistedServerKeys;
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
this.storageManager = storageManager;
this.config = config;
}
/** The VPN subnet CIDR. */
public getSubnet(): string {
return this.config.subnet || '10.8.0.0/24';
}
/** Whether the VPN server is running. */
public get running(): boolean {
return this.vpnServer?.running ?? false;
}
/**
* Start the VPN server.
* Loads or generates server keys, loads persisted clients, starts VpnServer.
*/
public async start(): Promise<void> {
// Load or generate server keys
this.serverKeys = await this.loadOrGenerateServerKeys();
// Load persisted clients
await this.loadPersistedClients();
// Build client entries for the daemon
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
for (const client of this.clients.values()) {
clientEntries.push({
clientId: client.clientId,
publicKey: client.noisePublicKey,
wgPublicKey: client.wgPublicKey,
enabled: client.enabled,
serverDefinedClientTags: client.serverDefinedClientTags,
description: client.description,
assignedIp: client.assignedIp,
expiresAt: client.expiresAt,
});
}
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
// Create and start VpnServer
this.vpnServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: 'socket',
transportMode: 'all',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: true,
destinationPolicy: this.config.destinationPolicy
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
serverEndpoint: this.config.serverEndpoint
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
clientAllowedIPs: [subnet],
};
await this.vpnServer.start(serverConfig);
// Create initial clients from config (idempotent — skip already-persisted)
if (this.config.initialClients) {
for (const initial of this.config.initialClients) {
if (!this.clients.has(initial.clientId)) {
const bundle = await this.createClient({
clientId: initial.clientId,
serverDefinedClientTags: initial.serverDefinedClientTags,
description: initial.description,
});
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
}
}
}
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
/**
* Stop the VPN server.
*/
public async stop(): Promise<void> {
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
} catch {
// Ignore stop errors
}
this.vpnServer.stop();
this.vpnServer = undefined;
}
logger.log('info', 'VPN server stopped');
}
// ── Client CRUD ────────────────────────────────────────────────────────
/**
* Create a new VPN client. Returns the config bundle (secrets only shown once).
*/
public async createClient(opts: {
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
const bundle = await this.vpnServer.createClient({
clientId: opts.clientId,
serverDefinedClientTags: opts.serverDefinedClientTags,
description: opts.description,
});
// Override AllowedIPs with per-client values based on tag-matched routes
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
// Persist client entry (including WG private key for export/QR)
const persisted: IPersistedClient = {
clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true,
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
description: bundle.entry.description,
assignedIp: bundle.entry.assignedIp,
noisePublicKey: bundle.entry.publicKey,
wgPublicKey: bundle.entry.wgPublicKey || '',
wgPrivateKey: bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(),
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: bundle.entry.expiresAt,
};
this.clients.set(persisted.clientId, persisted);
await this.persistClient(persisted);
this.config.onClientChanged?.();
return bundle;
}
/**
* Remove a VPN client.
*/
public async removeClient(clientId: string): Promise<void> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
}
await this.vpnServer.removeClient(clientId);
this.clients.delete(clientId);
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
this.config.onClientChanged?.();
}
/**
* List all registered clients (without secrets).
*/
public listClients(): IPersistedClient[] {
return [...this.clients.values()];
}
/**
* Enable a client.
*/
public async enableClient(clientId: string): Promise<void> {
if (!this.vpnServer) throw new Error('VPN server not running');
await this.vpnServer.enableClient(clientId);
const client = this.clients.get(clientId);
if (client) {
client.enabled = true;
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.config.onClientChanged?.();
}
/**
* Disable a client.
*/
public async disableClient(clientId: string): Promise<void> {
if (!this.vpnServer) throw new Error('VPN server not running');
await this.vpnServer.disableClient(clientId);
const client = this.clients.get(clientId);
if (client) {
client.enabled = false;
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.config.onClientChanged?.();
}
/**
* Update a client's metadata (description, tags) without rotating keys.
*/
public async updateClient(clientId: string, update: {
description?: string;
serverDefinedClientTags?: string[];
}): Promise<void> {
const client = this.clients.get(clientId);
if (!client) throw new Error(`Client not found: ${clientId}`);
if (update.description !== undefined) client.description = update.description;
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
client.updatedAt = Date.now();
await this.persistClient(client);
this.config.onClientChanged?.();
}
/**
* Rotate a client's keys. Returns the new config bundle.
*/
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) throw new Error('VPN server not running');
const bundle = await this.vpnServer.rotateClientKey(clientId);
// Update persisted entry with new keys (including private key for export/QR)
const client = this.clients.get(clientId);
if (client) {
client.noisePublicKey = bundle.entry.publicKey;
client.wgPublicKey = bundle.entry.wgPublicKey || '';
client.wgPrivateKey = bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
client.updatedAt = Date.now();
await this.persistClient(client);
}
return bundle;
}
/**
* Export a client config. Injects stored WG private key and per-client AllowedIPs.
*/
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
if (!this.vpnServer) throw new Error('VPN server not running');
let config = await this.vpnServer.exportClientConfig(clientId, format);
if (format === 'wireguard') {
const persisted = this.clients.get(clientId);
// Inject stored WG private key so exports produce valid, scannable configs
if (persisted?.wgPrivateKey) {
config = config.replace(
'[Interface]\n',
`[Interface]\nPrivateKey = ${persisted.wgPrivateKey}\n`,
);
}
// Override AllowedIPs with per-client values based on tag-matched routes
if (this.config.getClientAllowedIPs) {
const clientTags = persisted?.serverDefinedClientTags || [];
const allowedIPs = await this.config.getClientAllowedIPs(clientTags);
config = config.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
}
return config;
}
// ── Tag-based access control ───────────────────────────────────────────
/**
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
*/
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
const ips: string[] = [];
for (const client of this.clients.values()) {
if (!client.enabled || !client.assignedIp) continue;
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
ips.push(client.assignedIp);
}
}
return ips;
}
// ── Status and telemetry ───────────────────────────────────────────────
/**
* Get server status.
*/
public async getStatus(): Promise<plugins.smartvpn.IVpnStatus | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getStatus();
}
/**
* Get server statistics.
*/
public async getStatistics(): Promise<plugins.smartvpn.IVpnServerStatistics | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getStatistics();
}
/**
* List currently connected clients.
*/
public async getConnectedClients(): Promise<plugins.smartvpn.IVpnClientInfo[]> {
if (!this.vpnServer) return [];
return this.vpnServer.listClients();
}
/**
* Get telemetry for a specific client.
*/
public async getClientTelemetry(clientId: string): Promise<plugins.smartvpn.IVpnClientTelemetry | null> {
if (!this.vpnServer) return null;
return this.vpnServer.getClientTelemetry(clientId);
}
/**
* Get server public keys (for display/info).
*/
public getServerPublicKeys(): { noisePublicKey: string; wgPublicKey: string } | null {
if (!this.serverKeys) return null;
return {
noisePublicKey: this.serverKeys.noisePublicKey,
wgPublicKey: this.serverKeys.wgPublicKey,
};
}
// ── Private helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<IPersistedServerKeys> {
const stored = await this.storageManager.getJSON<IPersistedServerKeys>(STORAGE_PREFIX_KEYS);
if (stored?.noisePrivateKey && stored?.wgPrivateKey) {
logger.log('info', 'Loaded VPN server keys from storage');
return stored;
}
// Generate new keys via the daemon
const tempServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
await tempServer.start();
const noiseKeys = await tempServer.generateKeypair();
const wgKeys = await tempServer.generateWgKeypair();
tempServer.stop();
const keys: IPersistedServerKeys = {
noisePrivateKey: noiseKeys.privateKey,
noisePublicKey: noiseKeys.publicKey,
wgPrivateKey: wgKeys.privateKey,
wgPublicKey: wgKeys.publicKey,
};
await this.storageManager.setJSON(STORAGE_PREFIX_KEYS, keys);
logger.log('info', 'Generated and persisted new VPN server keys');
return keys;
}
private async loadPersistedClients(): Promise<void> {
const keys = await this.storageManager.list(STORAGE_PREFIX_CLIENTS);
for (const key of keys) {
const client = await this.storageManager.getJSON<IPersistedClient>(key);
if (client) {
// Migrate legacy `tags` → `serverDefinedClientTags`
if (!client.serverDefinedClientTags && client.tags) {
client.serverDefinedClientTags = client.tags;
delete client.tags;
await this.persistClient(client);
}
this.clients.set(client.clientId, client);
}
}
if (this.clients.size > 0) {
logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`);
}
}
private async persistClient(client: IPersistedClient): Promise<void> {
await this.storageManager.setJSON(`${STORAGE_PREFIX_CLIENTS}${client.clientId}`, client);
}
}

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

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

View File

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

View File

@@ -51,11 +51,23 @@ export interface IRouteRemoteIngress {
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.
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
* SmartProxy ignores unknown properties at runtime.
*/
export type IDcRouterRouteConfig = IRouteConfig & {
remoteIngress?: IRouteRemoteIngress;
vpn?: IRouteVpn;
};

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

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

View File

@@ -96,7 +96,15 @@ interface IIdentity {
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` property |
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
| `IRouteVpn` | Route-level VPN config: `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`)
@@ -205,6 +213,19 @@ interface ICertificateInfo {
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
#### 🔐 VPN
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
#### 📡 RADIUS
| Interface | Method | Description |
|-----------|--------|-------------|

View File

@@ -8,4 +8,5 @@ export * from './email-ops.js';
export * from './certificate.js';
export * from './remoteingress.js';
export * from './route-management.js';
export * from './api-tokens.js';
export * from './api-tokens.js';
export * from './vpn.js';

View File

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

View File

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

View File

@@ -905,6 +905,202 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
}
});
// ============================================================================
// VPN State
// ============================================================================
export interface IVpnState {
clients: interfaces.data.IVpnClient[];
connectedClients: interfaces.data.IVpnConnectedClient[];
status: interfaces.data.IVpnServerStatus | null;
isLoading: boolean;
error: string | null;
lastUpdated: number;
/** WireGuard config shown after create/rotate (only shown once) */
newClientConfig: string | null;
}
export const vpnStatePart = await appState.getStatePart<IVpnState>(
'vpn',
{
clients: [],
connectedClients: [],
status: null,
isLoading: false,
error: null,
lastUpdated: 0,
newClientConfig: null,
},
'soft'
);
// ============================================================================
// VPN Actions
// ============================================================================
export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const clientsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnClients
>('/typedrequest', 'getVpnClients');
const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnStatus
>('/typedrequest', 'getVpnStatus');
const connectedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnConnectedClients
>('/typedrequest', 'getVpnConnectedClients');
const [clientsResponse, statusResponse, connectedResponse] = await Promise.all([
clientsRequest.fire({ identity: context.identity }),
statusRequest.fire({ identity: context.identity }),
connectedRequest.fire({ identity: context.identity }),
]);
return {
...currentState,
clients: clientsResponse.clients,
connectedClients: connectedResponse.connectedClients,
status: statusResponse.status,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch VPN data',
};
}
});
export const createVpnClientAction = vpnStatePart.createAction<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateVpnClient
>('/typedrequest', 'createVpnClient');
const response = await request.fire({
identity: context.identity!,
clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
description: dataArg.description,
});
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to create client' };
}
const refreshed = await actionContext!.dispatch(fetchVpnAction, null);
return {
...refreshed,
newClientConfig: response.wireguardConfig || null,
};
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to create VPN client',
};
}
});
export const deleteVpnClientAction = vpnStatePart.createAction<string>(
async (statePartArg, clientId, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteVpnClient
>('/typedrequest', 'deleteVpnClient');
await request.fire({ identity: context.identity!, clientId });
return await actionContext!.dispatch(fetchVpnAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete VPN client',
};
}
},
);
export const toggleVpnClientAction = vpnStatePart.createAction<{
clientId: string;
enabled: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const method = dataArg.enabled ? 'enableVpnClient' : 'disableVpnClient';
type TReq = interfaces.requests.IReq_EnableVpnClient | interfaces.requests.IReq_DisableVpnClient;
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<TReq>(
'/typedrequest', method,
);
await request.fire({ identity: context.identity!, clientId: dataArg.clientId });
return await actionContext!.dispatch(fetchVpnAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to toggle VPN client',
};
}
});
export const updateVpnClientAction = vpnStatePart.createAction<{
clientId: string;
description?: string;
serverDefinedClientTags?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateVpnClient
>('/typedrequest', 'updateVpnClient');
const response = await request.fire({
identity: context.identity!,
clientId: dataArg.clientId,
description: dataArg.description,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
});
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to update client' };
}
return await actionContext!.dispatch(fetchVpnAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to update VPN client',
};
}
});
export const clearNewClientConfigAction = vpnStatePart.createAction(
async (statePartArg): Promise<IVpnState> => {
return { ...statePartArg.getState()!, newClientConfig: null };
},
);
// ============================================================================
// Route Management Actions
// ============================================================================
@@ -1372,6 +1568,15 @@ async function dispatchCombinedRefreshActionInner() {
console.error('Remote ingress refresh failed:', error);
}
}
// Refresh VPN data if on vpn view
if (currentView === 'vpn') {
try {
await vpnStatePart.dispatchAction(fetchVpnAction, null);
} catch (error) {
console.error('VPN refresh failed:', error);
}
}
} catch (error) {
console.error('Combined refresh failed:', error);
// If the error looks like an auth failure (invalid JWT), force re-login

View File

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

View File

@@ -24,6 +24,7 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js';
import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
import { OpsViewVpn } from './ops-view-vpn.js';
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement {
iconName: 'lucide:globe',
element: OpsViewRemoteIngress,
},
{
name: 'VPN',
iconName: 'lucide:shield',
element: OpsViewVpn,
},
];
/**

View File

@@ -0,0 +1,655 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-vpn': OpsViewVpn;
}
}
@customElement('ops-view-vpn')
export class OpsViewVpn extends DeesElement {
@state()
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
constructor() {
super();
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
this.vpnState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.vpnContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.statusBadge.enabled {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.configDialog {
padding: 16px;
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
border-radius: 8px;
margin-bottom: 16px;
}
.configDialog pre {
display: block;
padding: 12px;
background: ${cssManager.bdTheme('#1f2937', '#111827')};
color: #10b981;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
margin: 8px 0;
user-select: all;
max-height: 300px;
overflow-y: auto;
}
.configDialog .warning {
font-size: 12px;
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
margin-top: 8px;
}
.tagBadge {
display: inline-flex;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
margin-right: 4px;
}
.serverInfo {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
border-radius: 8px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')};
}
.serverInfo .infoItem {
display: flex;
flex-direction: column;
gap: 4px;
}
.serverInfo .infoLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.serverInfo .infoValue {
font-size: 14px;
font-family: monospace;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
`,
];
/** Look up connected client info by clientId */
private getConnectedInfo(clientId: string): interfaces.data.IVpnConnectedClient | undefined {
return this.vpnState.connectedClients?.find(c => c.clientId === clientId);
}
render(): TemplateResult {
const status = this.vpnState.status;
const clients = this.vpnState.clients;
const connectedClients = this.vpnState.connectedClients || [];
const connectedCount = connectedClients.length;
const totalClients = clients.length;
const enabledClients = clients.filter(c => c.enabled).length;
const statsTiles: IStatsTile[] = [
{
id: 'totalClients',
title: 'Total Clients',
type: 'number',
value: totalClients,
icon: 'lucide:users',
description: 'Registered VPN clients',
color: '#3b82f6',
},
{
id: 'connectedClients',
title: 'Connected',
type: 'number',
value: connectedCount,
icon: 'lucide:link',
description: 'Currently connected',
color: '#10b981',
},
{
id: 'enabledClients',
title: 'Enabled',
type: 'number',
value: enabledClients,
icon: 'lucide:shieldCheck',
description: 'Active client registrations',
color: '#8b5cf6',
},
{
id: 'serverStatus',
title: 'Server',
type: 'text',
value: status?.running ? 'Running' : 'Stopped',
icon: 'lucide:server',
description: status?.running ? 'Active' : 'VPN server not running',
color: status?.running ? '#10b981' : '#ef4444',
},
];
return html`
<ops-sectionheading>VPN</ops-sectionheading>
<div class="vpnContainer">
${this.vpnState.newClientConfig ? html`
<div class="configDialog">
<strong>Client created successfully!</strong>
<div class="warning">Copy the WireGuard config now. It contains private keys that won't be shown again.</div>
<pre>${this.vpnState.newClientConfig}</pre>
<dees-button
@click=${async () => {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(this.vpnState.newClientConfig!);
}
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 });
}}
>Copy to Clipboard</dees-button>
<dees-button
@click=${() => {
const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wireguard.conf';
a.click();
URL.revokeObjectURL(url);
}}
>Download .conf</dees-button>
<dees-button
@click=${async () => {
const dataUrl = await plugins.qrcode.toDataURL(
this.vpnState.newClientConfig!,
{ width: 400, margin: 2 }
);
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'WireGuard QR Code',
content: html`
<div style="text-align: center; padding: 16px;">
<img src="${dataUrl}" style="max-width: 100%; image-rendering: pixelated;" />
<p style="margin-top: 12px; font-size: 13px; color: #9ca3af;">
Scan with the WireGuard app on your phone
</p>
</div>
`,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
],
});
}}
>Show QR Code</dees-button>
<dees-button
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
>Dismiss</dees-button>
</div>
` : ''}
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
${status ? html`
<div class="serverInfo">
<div class="infoItem">
<span class="infoLabel">Subnet</span>
<span class="infoValue">${status.subnet}</span>
</div>
<div class="infoItem">
<span class="infoLabel">WireGuard Port</span>
<span class="infoValue">${status.wgListenPort}</span>
</div>
${status.serverPublicKeys ? html`
<div class="infoItem">
<span class="infoLabel">WG Public Key</span>
<span class="infoValue" style="font-size: 11px; word-break: break-all;">${status.serverPublicKeys.wgPublicKey}</span>
</div>
` : ''}
</div>
` : ''}
<dees-table
.heading1=${'VPN Clients'}
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
.data=${clients}
.displayFunction=${(client: interfaces.data.IVpnClient) => {
const conn = this.getConnectedInfo(client.clientId);
let statusHtml;
if (!client.enabled) {
statusHtml = html`<span class="statusBadge disabled">disabled</span>`;
} else if (conn) {
const since = new Date(conn.connectedSince).toLocaleString();
statusHtml = html`<span class="statusBadge enabled" title="Since ${since}">connected</span>`;
} else {
statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
}
return {
'Client ID': client.clientId,
'Status': statusHtml,
'VPN IP': client.assignedIp || '-',
'Tags': client.serverDefinedClientTags?.length
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
: '-',
'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(),
};
}}
.dataActions=${[
{
name: 'Create Client',
iconName: 'lucide:plus',
type: ['header'],
actionFunc: async () => {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Create VPN Client',
content: html`
<dees-form>
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
if (!data.clientId) return;
const serverDefinedClientTags = data.tags
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId,
description: data.description || undefined,
serverDefinedClientTags,
});
await modalArg.destroy();
},
},
],
});
},
},
{
name: 'Detail',
iconName: 'lucide:info',
type: ['doubleClick'],
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const conn = this.getConnectedInfo(client.clientId);
const { DeesModal } = await import('@design.estate/dees-catalog');
// Fetch telemetry on-demand
let telemetryHtml = html`<p style="color: #9ca3af;">Loading telemetry...</p>`;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnClientTelemetry
>('/typedrequest', 'getVpnClientTelemetry');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
});
const t = response.telemetry;
if (t) {
const formatBytes = (b: number) => b > 1048576 ? `${(b / 1048576).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(1)} KB` : `${b} B`;
telemetryHtml = html`
<div class="serverInfo" style="margin-top: 12px;">
<div class="infoItem"><span class="infoLabel">Bytes Sent</span><span class="infoValue">${formatBytes(t.bytesSent)}</span></div>
<div class="infoItem"><span class="infoLabel">Bytes Received</span><span class="infoValue">${formatBytes(t.bytesReceived)}</span></div>
<div class="infoItem"><span class="infoLabel">Keepalives</span><span class="infoValue">${t.keepalivesReceived}</span></div>
<div class="infoItem"><span class="infoLabel">Last Keepalive</span><span class="infoValue">${t.lastKeepaliveAt ? new Date(t.lastKeepaliveAt).toLocaleString() : '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Packets Dropped</span><span class="infoValue">${t.packetsDropped}</span></div>
</div>
`;
} else {
telemetryHtml = html`<p style="color: #9ca3af;">No telemetry available (client not connected)</p>`;
}
} catch {
telemetryHtml = html`<p style="color: #9ca3af;">Telemetry unavailable</p>`;
}
DeesModal.createAndShow({
heading: `Client: ${client.clientId}`,
content: html`
<div class="serverInfo">
<div class="infoItem"><span class="infoLabel">Client ID</span><span class="infoValue">${client.clientId}</span></div>
<div class="infoItem"><span class="infoLabel">VPN IP</span><span class="infoValue">${client.assignedIp || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Status</span><span class="infoValue">${!client.enabled ? 'Disabled' : conn ? 'Connected' : 'Offline'}</span></div>
${conn ? html`
<div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div>
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
` : ''}
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Created</span><span class="infoValue">${new Date(client.createdAt).toLocaleString()}</span></div>
<div class="infoItem"><span class="infoLabel">Updated</span><span class="infoValue">${new Date(client.updatedAt).toLocaleString()}</span></div>
</div>
<h3 style="margin: 16px 0 4px; font-size: 14px;">Telemetry</h3>
${telemetryHtml}
`,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
],
});
},
},
{
name: 'Enable',
iconName: 'lucide:power',
type: ['contextmenu', 'inRow'],
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
clientId: client.clientId,
enabled: true,
});
},
},
{
name: 'Disable',
iconName: 'lucide:power',
type: ['contextmenu', 'inRow'],
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
clientId: client.clientId,
enabled: false,
});
},
},
{
name: 'Export Config',
iconName: 'lucide:download',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const exportConfig = async (format: 'wireguard' | 'smartvpn') => {
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ExportVpnClientConfig
>('/typedrequest', 'exportVpnClientConfig');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
format,
});
if (response.success && response.config) {
const ext = format === 'wireguard' ? 'conf' : 'json';
const blob = new Blob([response.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${client.clientId}.${ext}`;
a.click();
URL.revokeObjectURL(url);
DeesToast.createAndShow({ message: `${format} config downloaded`, type: 'success', duration: 3000 });
} else {
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
}
} catch (err: any) {
DeesToast.createAndShow({ message: err.message || 'Export failed', type: 'error', duration: 5000 });
}
};
const showQrCode = async () => {
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ExportVpnClientConfig
>('/typedrequest', 'exportVpnClientConfig');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
format: 'wireguard',
});
if (response.success && response.config) {
const dataUrl = await plugins.qrcode.toDataURL(
response.config,
{ width: 400, margin: 2 }
);
DeesModal.createAndShow({
heading: `QR Code: ${client.clientId}`,
content: html`
<div style="text-align: center; padding: 16px;">
<img src="${dataUrl}" style="max-width: 100%; image-rendering: pixelated;" />
<p style="margin-top: 12px; font-size: 13px; color: #9ca3af;">
Scan with the WireGuard app on your phone
</p>
</div>
`,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
],
});
} else {
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
}
} catch (err: any) {
DeesToast.createAndShow({ message: err.message || 'QR generation failed', type: 'error', duration: 5000 });
}
};
DeesModal.createAndShow({
heading: `Export Config: ${client.clientId}`,
content: html`<p>Choose a config format to download.</p>`,
menuOptions: [
{
name: 'WireGuard (.conf)',
iconName: 'lucide:shield',
action: async (modalArg: any) => {
await modalArg.destroy();
await exportConfig('wireguard');
},
},
{
name: 'SmartVPN (.json)',
iconName: 'lucide:braces',
action: async (modalArg: any) => {
await modalArg.destroy();
await exportConfig('smartvpn');
},
},
{
name: 'QR Code (WireGuard)',
iconName: 'lucide:qr-code',
action: async (modalArg: any) => {
await modalArg.destroy();
await showQrCode();
},
},
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
],
});
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? '';
const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
DeesModal.createAndShow({
heading: `Edit: ${client.clientId}`,
content: html`
<dees-form>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'} .value=${currentTags}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
{
name: 'Save',
iconName: 'lucide:check',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const serverDefinedClientTags = data.tags
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
clientId: client.clientId,
description: data.description || undefined,
serverDefinedClientTags,
});
await modalArg.destroy();
},
},
],
});
},
},
{
name: 'Rotate Keys',
iconName: 'lucide:rotate-cw',
type: ['contextmenu'],
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Rotate Client Keys',
content: html`<p>Generate new keys for "${client.clientId}"? The old keys will be invalidated and the client will need the new config to reconnect.</p>`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
{
name: 'Rotate',
iconName: 'lucide:rotate-cw',
action: async (modalArg: any) => {
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RotateVpnClientKey
>('/typedrequest', 'rotateVpnClientKey');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
});
if (response.success && response.wireguardConfig) {
appstate.vpnStatePart.setState({
...appstate.vpnStatePart.getState()!,
newClientConfig: response.wireguardConfig,
});
}
await modalArg.destroy();
} catch (err: any) {
DeesToast.createAndShow({ message: err.message || 'Rotate failed', type: 'error', duration: 5000 });
}
},
},
],
});
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['contextmenu'],
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Delete VPN Client',
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async (modalArg: any) => {
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
await modalArg.destroy();
},
},
],
});
},
},
]}
></dees-table>
</div>
`;
}
}

View File

@@ -8,11 +8,15 @@ import * as szCatalog from '@serve.zone/catalog';
// TypedSocket for real-time push communication
import * as typedsocket from '@api.global/typedsocket';
// QR code generation for WireGuard configs
import * as qrcode from 'qrcode';
export {
deesElement,
deesCatalog,
szCatalog,
typedsocket,
qrcode,
}
// domtools gives us TypedRequest and other utilities

View File

@@ -50,6 +50,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
- Enable/disable, edit, secret regeneration, and delete actions
### 🔐 VPN Management
- VPN server status with forwarding mode, subnet, and WireGuard port
- Client registration table with create, enable/disable, and delete actions
- WireGuard config download, clipboard copy, and **QR code display** on client creation
- QR code export for existing clients — scan with WireGuard mobile app (iOS/Android)
- Per-client telemetry (bytes sent/received, keepalives)
- Server public key display for manual client configuration
### 📜 Log Viewer
- Real-time log streaming
- Filter by log level (error, warning, info, debug)
@@ -100,6 +108,7 @@ ts_web/
├── ops-view-emails.ts # Email queue management
├── ops-view-certificates.ts # Certificate overview & reprovisioning
├── ops-view-remoteingress.ts # Remote ingress edge management
├── ops-view-vpn.ts # VPN client management
├── ops-view-logs.ts # Log viewer
├── ops-view-routes.ts # Route & API token management
├── ops-view-config.ts # Configuration display
@@ -124,6 +133,7 @@ The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
### Tab Visibility Optimization
@@ -173,6 +183,13 @@ regenerateRemoteIngressSecretAction(id) // New secret
toggleRemoteIngressAction(id, enabled) // Enable/disable
clearNewEdgeSecretAction() // Dismiss secret banner
fetchConnectionToken(edgeId) // Get connection token (standalone function)
// VPN
fetchVpnAction() // Clients + server status
createVpnClientAction(data) // Create new VPN client
deleteVpnClientAction(clientId) // Remove VPN client
toggleVpnClientAction(id, enabled) // Enable/disable
clearNewClientConfigAction() // Dismiss config banner
```
### Client-Side Routing
@@ -187,6 +204,7 @@ fetchConnectionToken(edgeId) // Get connection token (standalone function)
/emails/security → Security incidents
/certificates → Certificate management
/remoteingress → Remote ingress edge management
/vpn → VPN client management
/routes → Route & API token management
/logs → Log viewer
/configuration → System configuration

View File

@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn'] as const;
export type TValidView = typeof validViews[number];