Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfb727b86d | |||
| 1e4b9997f4 | |||
| bb32f23d77 | |||
| 1aa6451dba | |||
| eb0408c036 | |||
| 098a2567fa | |||
| c6534df362 | |||
| 2e4b375ad5 | |||
| 802bcf1c3d | |||
| bad0bd9053 | |||
| ca990781b0 | |||
| 6807aefce8 | |||
| 450ec4816e | |||
| ab4310b775 | |||
| 6efd986406 | |||
| 7370d7f0e7 | |||
| e733067c25 | |||
| bc2ed808f9 | |||
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 | |||
| d53cff6a94 | |||
| eb211348d2 |
94
changelog.md
94
changelog.md
@@ -1,5 +1,99 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-30 - 11.14.0 - feat(docs)
|
||||||
document VPN access control and add OpsServer VPN navigation
|
document VPN access control and add OpsServer VPN navigation
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "11.14.0",
|
"version": "11.21.5",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -59,13 +59,15 @@
|
|||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.3.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartvpn": "1.12.0",
|
"@push.rocks/smartvpn": "1.16.4",
|
||||||
"@push.rocks/taskbuffer": "^8.0.2",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/catalog": "^2.9.0",
|
"@serve.zone/catalog": "^2.9.0",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^4.15.3",
|
"@serve.zone/remoteingress": "^4.15.3",
|
||||||
"@tsclass/tsclass": "^9.5.0",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"lru-cache": "^11.2.7",
|
"lru-cache": "^11.2.7",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
122
pnpm-lock.yaml
generated
122
pnpm-lock.yaml
generated
@@ -96,8 +96,8 @@ importers:
|
|||||||
specifier: ^3.0.9
|
specifier: ^3.0.9
|
||||||
version: 3.0.9
|
version: 3.0.9
|
||||||
'@push.rocks/smartvpn':
|
'@push.rocks/smartvpn':
|
||||||
specifier: 1.12.0
|
specifier: 1.16.4
|
||||||
version: 1.12.0
|
version: 1.16.4
|
||||||
'@push.rocks/taskbuffer':
|
'@push.rocks/taskbuffer':
|
||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
@@ -113,9 +113,15 @@ importers:
|
|||||||
'@tsclass/tsclass':
|
'@tsclass/tsclass':
|
||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.5.0
|
version: 9.5.0
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
lru-cache:
|
lru-cache:
|
||||||
specifier: ^11.2.7
|
specifier: ^11.2.7
|
||||||
version: 11.2.7
|
version: 11.2.7
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.0.0
|
version: 13.0.0
|
||||||
@@ -1246,6 +1252,9 @@ packages:
|
|||||||
'@push.rocks/smartnftables@1.0.1':
|
'@push.rocks/smartnftables@1.0.1':
|
||||||
resolution: {integrity: sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==}
|
resolution: {integrity: sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==}
|
||||||
|
|
||||||
|
'@push.rocks/smartnftables@1.1.0':
|
||||||
|
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||||
|
|
||||||
'@push.rocks/smartnpm@2.0.6':
|
'@push.rocks/smartnpm@2.0.6':
|
||||||
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
||||||
|
|
||||||
@@ -1330,8 +1339,8 @@ packages:
|
|||||||
'@push.rocks/smartversion@3.0.5':
|
'@push.rocks/smartversion@3.0.5':
|
||||||
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
||||||
|
|
||||||
'@push.rocks/smartvpn@1.12.0':
|
'@push.rocks/smartvpn@1.16.4':
|
||||||
resolution: {integrity: sha512-lwZCK8fopkms3c6ZSrUghuVNFi7xOXMSkGDSptQM2K3tu2UbajhpdxlAVMODY8n6caQr5ZXp0kHdtwVU9WKi5Q==}
|
resolution: {integrity: sha512-ps7NcdBzaaGQFjHcXUN8JC623xZbLNyIYfICxDLJb2BxzzuZa667fW0KxQQCwLtZaB2txN5sMlaOKFi27tXTBA==}
|
||||||
|
|
||||||
'@push.rocks/smartwatch@6.4.0':
|
'@push.rocks/smartwatch@6.4.0':
|
||||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||||
@@ -2044,6 +2053,9 @@ packages:
|
|||||||
'@types/node@25.5.0':
|
'@types/node@25.5.0':
|
||||||
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/randomatic@3.1.5':
|
'@types/randomatic@3.1.5':
|
||||||
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
|
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
|
||||||
|
|
||||||
@@ -2298,6 +2310,10 @@ packages:
|
|||||||
camel-case@3.0.0:
|
camel-case@3.0.0:
|
||||||
resolution: {integrity: sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=}
|
resolution: {integrity: sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
camelcase@6.3.0:
|
camelcase@6.3.0:
|
||||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2338,6 +2354,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2414,6 +2433,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decode-named-character-reference@1.3.0:
|
decode-named-character-reference@1.3.0:
|
||||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||||
|
|
||||||
@@ -2467,6 +2490,9 @@ packages:
|
|||||||
devtools-protocol@0.0.1581282:
|
devtools-protocol@0.0.1581282:
|
||||||
resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==}
|
resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
@@ -3586,6 +3612,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
pngjs@6.0.0:
|
pngjs@6.0.0:
|
||||||
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
|
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
|
||||||
engines: {node: '>=12.13.0'}
|
engines: {node: '>=12.13.0'}
|
||||||
@@ -3707,6 +3737,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
qs@6.15.0:
|
qs@6.15.0:
|
||||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -3777,6 +3812,9 @@ packages:
|
|||||||
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
|
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resolve-alpn@1.2.1:
|
resolve-alpn@1.2.1:
|
||||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||||
|
|
||||||
@@ -3832,6 +3870,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4164,6 +4205,9 @@ packages:
|
|||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=}
|
resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -4219,6 +4263,9 @@ packages:
|
|||||||
xterm@5.3.0:
|
xterm@5.3.0:
|
||||||
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
|
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4228,6 +4275,10 @@ packages:
|
|||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4236,6 +4287,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -6331,6 +6386,11 @@ snapshots:
|
|||||||
'@push.rocks/smartlog': 3.2.1
|
'@push.rocks/smartlog': 3.2.1
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
|
|
||||||
|
'@push.rocks/smartnftables@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@push.rocks/smartlog': 3.2.1
|
||||||
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
|
|
||||||
'@push.rocks/smartnpm@2.0.6':
|
'@push.rocks/smartnpm@2.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/consolecolor': 2.0.3
|
'@push.rocks/consolecolor': 2.0.3
|
||||||
@@ -6562,8 +6622,9 @@ snapshots:
|
|||||||
'@types/semver': 7.7.1
|
'@types/semver': 7.7.1
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
|
|
||||||
'@push.rocks/smartvpn@1.12.0':
|
'@push.rocks/smartvpn@1.16.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@push.rocks/smartnftables': 1.1.0
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
|
|
||||||
@@ -7435,6 +7496,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.5.0
|
||||||
|
|
||||||
'@types/randomatic@3.1.5': {}
|
'@types/randomatic@3.1.5': {}
|
||||||
|
|
||||||
'@types/relateurl@0.2.33': {}
|
'@types/relateurl@0.2.33': {}
|
||||||
@@ -7679,6 +7744,8 @@ snapshots:
|
|||||||
no-case: 2.3.2
|
no-case: 2.3.2
|
||||||
upper-case: 1.1.3
|
upper-case: 1.1.3
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
camelcase@6.3.0: {}
|
camelcase@6.3.0: {}
|
||||||
|
|
||||||
ccount@2.0.1: {}
|
ccount@2.0.1: {}
|
||||||
@@ -7709,6 +7776,12 @@ snapshots:
|
|||||||
|
|
||||||
cli-width@4.1.0: {}
|
cli-width@4.1.0: {}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
@@ -7783,6 +7856,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decode-named-character-reference@1.3.0:
|
decode-named-character-reference@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities: 2.0.2
|
character-entities: 2.0.2
|
||||||
@@ -7829,6 +7904,8 @@ snapshots:
|
|||||||
|
|
||||||
devtools-protocol@0.0.1581282: {}
|
devtools-protocol@0.0.1581282: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -9207,6 +9284,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
find-up: 4.1.0
|
find-up: 4.1.0
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
pngjs@6.0.0: {}
|
pngjs@6.0.0: {}
|
||||||
|
|
||||||
pngjs@7.0.0: {}
|
pngjs@7.0.0: {}
|
||||||
@@ -9392,6 +9471,12 @@ snapshots:
|
|||||||
|
|
||||||
pvutils@1.1.5: {}
|
pvutils@1.1.5: {}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
dependencies:
|
||||||
|
dijkstrajs: 1.0.3
|
||||||
|
pngjs: 5.0.0
|
||||||
|
yargs: 15.4.1
|
||||||
|
|
||||||
qs@6.15.0:
|
qs@6.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
@@ -9490,6 +9575,8 @@ snapshots:
|
|||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve-alpn@1.2.1: {}
|
resolve-alpn@1.2.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -9547,6 +9634,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.4: {}
|
semver@7.7.4: {}
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -9938,6 +10027,8 @@ snapshots:
|
|||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -9979,14 +10070,35 @@ snapshots:
|
|||||||
|
|
||||||
xterm@5.3.0: {}
|
xterm@5.3.0: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yaml@2.8.3: {}
|
yaml@2.8.3: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
yargs-parser@22.0.0: {}
|
yargs-parser@22.0.0: {}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
dependencies:
|
||||||
|
cliui: 6.0.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
find-up: 4.1.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
require-main-filename: 2.0.0
|
||||||
|
set-blocking: 2.0.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
which-module: 2.0.1
|
||||||
|
y18n: 4.0.3
|
||||||
|
yargs-parser: 18.1.3
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
|
|||||||
140
readme.md
140
readme.md
@@ -77,10 +77,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
|
### 🔐 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
|
- **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
|
- **Route-level VPN gating** — mark any route with `vpn: { required: true }` to restrict access to VPN clients only
|
||||||
- **Rootless operation** — auto-detects privileges: kernel TUN when running as root, userspace NAT (smoltcp) when not
|
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
|
||||||
- **Client management** — create, enable, disable, rotate keys, export WireGuard `.conf` files via OpsServer API
|
- **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
|
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
|
||||||
- **PROXY protocol v2** — in socket mode, the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
- **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
|
||||||
|
|
||||||
### ⚡ High Performance
|
### ⚡ High Performance
|
||||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||||
@@ -261,7 +264,9 @@ const router = new DcRouter({
|
|||||||
vpnConfig: {
|
vpnConfig: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
serverEndpoint: 'vpn.example.com',
|
serverEndpoint: 'vpn.example.com',
|
||||||
wgListenPort: 51820,
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Persistent storage
|
// Persistent storage
|
||||||
@@ -367,8 +372,8 @@ graph TB
|
|||||||
|
|
||||||
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
||||||
|
|
||||||
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
|
1. **On `start()`**: DcRouter initializes OpsServer (default port 3000, configurable via `opsServerPort`), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and SmartVPN based on which configs are provided. Services start in dependency order via `ServiceManager`.
|
||||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartVPN runs a Rust data plane for WireGuard and custom transports. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
||||||
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
||||||
|
|
||||||
### Rust-Powered Architecture
|
### Rust-Powered Architecture
|
||||||
@@ -381,6 +386,7 @@ DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-c
|
|||||||
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
|
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
|
||||||
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
||||||
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
||||||
|
| **SmartVPN** | `smartvpn_daemon` | WireGuard (boringtun), Noise IK handshake, QUIC/WS transports, userspace NAT (smoltcp) |
|
||||||
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
||||||
|
|
||||||
## Configuration Reference
|
## Configuration Reference
|
||||||
@@ -456,7 +462,17 @@ interface IDcRouterOptions {
|
|||||||
wgListenPort?: number; // default: 51820
|
wgListenPort?: number; // default: 51820
|
||||||
dns?: string[]; // DNS servers pushed to VPN clients
|
dns?: string[]; // DNS servers pushed to VPN clients
|
||||||
serverEndpoint?: string; // Hostname in generated client configs
|
serverEndpoint?: string; // Hostname in generated client configs
|
||||||
forwardingMode?: 'tun' | 'socket'; // default: auto-detect (root → tun, else socket)
|
clients?: Array<{ // Pre-defined VPN clients
|
||||||
|
clientId: string;
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
destinationPolicy?: { // Traffic routing policy
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
target?: string; // IP for forceTarget (default: '127.0.0.1')
|
||||||
|
allowList?: string[]; // Pass through directly
|
||||||
|
blockList?: string[]; // Always block (overrides allowList)
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
// ── HTTP/3 (QUIC) ────────────────────────────────────────────
|
||||||
@@ -1014,17 +1030,34 @@ DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks
|
|||||||
|
|
||||||
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
|
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`)
|
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
|
||||||
3. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected with the VPN subnet
|
3. **Split tunnel** by default — generated WireGuard configs only route VPN subnet traffic through the tunnel (`AllowedIPs = 10.8.0.0/24`), so regular internet traffic stays direct
|
||||||
4. SmartProxy enforces the allowlist — only VPN-sourced traffic is accepted on those routes
|
4. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected
|
||||||
|
5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
|
||||||
|
6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
|
||||||
|
7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
|
||||||
|
|
||||||
### Two Operating Modes
|
### Destination Policy
|
||||||
|
|
||||||
| Mode | Root Required? | How It Works |
|
By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
|
||||||
|------|---------------|-------------|
|
|
||||||
| **TUN** (`forwardingMode: 'tun'`) | Yes | Kernel TUN device — VPN traffic enters the network stack with real VPN IPs |
|
|
||||||
| **Socket** (`forwardingMode: 'socket'`) | No | Userspace NAT via smoltcp — outbound connections send PROXY protocol v2 to preserve VPN client IPs |
|
|
||||||
|
|
||||||
DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls back to socket mode. You can override this with the `forwardingMode` option.
|
```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
|
### Configuration
|
||||||
|
|
||||||
@@ -1032,26 +1065,47 @@ DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls
|
|||||||
const router = new DcRouter({
|
const router = new DcRouter({
|
||||||
vpnConfig: {
|
vpnConfig: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
subnet: '10.8.0.0/24', // VPN client IP pool (default)
|
||||||
wgListenPort: 51820, // WireGuard UDP port (default)
|
wgListenPort: 51820, // WireGuard UDP port (default)
|
||||||
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
|
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
|
||||||
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
|
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
|
||||||
// forwardingMode: 'socket', // Override auto-detection
|
|
||||||
|
// 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: {
|
smartProxyConfig: {
|
||||||
routes: [
|
routes: [
|
||||||
// This route is VPN-only — non-VPN clients are blocked
|
// 🔐 VPN-only: any VPN client can access
|
||||||
{
|
{
|
||||||
name: 'admin-panel',
|
name: 'internal-app',
|
||||||
match: { domains: ['admin.example.com'], ports: [443] },
|
match: { domains: ['internal.example.com'], ports: [443] },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
targets: [{ host: '192.168.1.50', port: 8080 }],
|
targets: [{ host: '192.168.1.50', port: 8080 }],
|
||||||
tls: { mode: 'terminate', certificate: 'auto' },
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
},
|
},
|
||||||
vpn: { required: true }, // 🔐 Only VPN clients can access this
|
vpn: { required: true },
|
||||||
},
|
},
|
||||||
// This route is public — anyone can access it
|
// 🔐 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',
|
name: 'public-site',
|
||||||
match: { domains: ['example.com'], ports: [443] },
|
match: { domains: ['example.com'], ports: [443] },
|
||||||
@@ -1066,17 +1120,29 @@ const router = new DcRouter({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Client Management via OpsServer API
|
### Client Tags
|
||||||
|
|
||||||
Once the VPN server is running, you can manage clients through the OpsServer dashboard or API:
|
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
|
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
|
||||||
- **Enable / Disable** — toggle client access without deleting
|
- **Enable / Disable** — toggle client access without deleting
|
||||||
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
- **Rotate keys** — generate fresh keypairs (invalidates old ones)
|
||||||
- **Export config** — re-export in WireGuard or SmartVPN format
|
- **Export config** — download in WireGuard (`.conf`) or SmartVPN (`.json`) format
|
||||||
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
|
- **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 QR code — no custom VPN software needed.
|
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file — no custom VPN software needed.
|
||||||
|
|
||||||
## Certificate Management
|
## Certificate Management
|
||||||
|
|
||||||
@@ -1252,8 +1318,12 @@ The OpsServer provides a web-based management interface served on port 3000 by d
|
|||||||
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
||||||
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
||||||
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
||||||
|
| 🛣️ **Routes** | Merged route list (hardcoded + programmatic), create/edit/toggle/override routes |
|
||||||
|
| 🔑 **API Tokens** | Token management with scopes, create/revoke/roll/toggle |
|
||||||
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
||||||
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
||||||
|
| 🔐 **VPN** | VPN client management, server status, create/toggle/export/rotate/delete clients |
|
||||||
|
| 📡 **RADIUS** | NAS client management, VLAN mappings, session monitoring, accounting |
|
||||||
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
||||||
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
||||||
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
||||||
@@ -1318,6 +1388,17 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
|||||||
'getRecentLogs' // Retrieve system logs with filtering
|
'getRecentLogs' // Retrieve system logs with filtering
|
||||||
'getLogStream' // Stream live logs
|
'getLogStream' // Stream live logs
|
||||||
|
|
||||||
|
// VPN
|
||||||
|
'getVpnClients' // List all registered VPN clients
|
||||||
|
'getVpnStatus' // VPN server status (running, subnet, port, keys)
|
||||||
|
'createVpnClient' // Create client → returns WireGuard config (shown once)
|
||||||
|
'deleteVpnClient' // Remove a VPN client
|
||||||
|
'enableVpnClient' // Enable a disabled client
|
||||||
|
'disableVpnClient' // Disable a client
|
||||||
|
'rotateVpnClientKey' // Generate new keys (invalidates old ones)
|
||||||
|
'exportVpnClientConfig' // Export WireGuard (.conf) or SmartVPN (.json) config
|
||||||
|
'getVpnClientTelemetry' // Per-client bytes sent/received, keepalives
|
||||||
|
|
||||||
// RADIUS
|
// RADIUS
|
||||||
'getRadiusSessions' // Active RADIUS sessions
|
'getRadiusSessions' // Active RADIUS sessions
|
||||||
'getRadiusClients' // List NAS clients
|
'getRadiusClients' // List NAS clients
|
||||||
@@ -1435,6 +1516,7 @@ const router = new DcRouter(options: IDcRouterOptions);
|
|||||||
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
||||||
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
||||||
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
||||||
|
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
|
||||||
| `storageManager` | `StorageManager` | Storage backend |
|
| `storageManager` | `StorageManager` | Storage backend |
|
||||||
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
||||||
| `metricsManager` | `MetricsManager` | Metrics collector |
|
| `metricsManager` | `MetricsManager` | Metrics collector |
|
||||||
@@ -1575,7 +1657,7 @@ The Docker build supports multi-platform (`linux/amd64`, `linux/arm64`) via [tsd
|
|||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DcRouter } from '../ts/index.js';
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
const devRouter = new DcRouter({
|
const devRouter = new DcRouter({
|
||||||
|
// Server public IP (used for VPN AllowedIPs)
|
||||||
|
publicIp: '203.0.113.1',
|
||||||
// SmartProxy routes for development/demo
|
// SmartProxy routes for development/demo
|
||||||
smartProxyConfig: {
|
smartProxyConfig: {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -23,6 +25,28 @@ const devRouter = new DcRouter({
|
|||||||
tls: { mode: 'passthrough' },
|
tls: { mode: 'passthrough' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-internal-app',
|
||||||
|
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||||
|
vpn: { required: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vpn-eng-dashboard',
|
||||||
|
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||||
|
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||||
|
},
|
||||||
|
] as any[],
|
||||||
|
},
|
||||||
|
// VPN with pre-defined clients
|
||||||
|
vpnConfig: {
|
||||||
|
enabled: true,
|
||||||
|
serverEndpoint: 'vpn.dev.local',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' },
|
||||||
|
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' },
|
||||||
|
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Disable cache/mongo for dev
|
// Disable cache/mongo for dev
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.14.0',
|
version: '11.21.5',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,8 +206,21 @@ export interface IDcRouterOptions {
|
|||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||||
serverEndpoint?: string;
|
serverEndpoint?: string;
|
||||||
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
/** Pre-defined VPN clients created on startup */
|
||||||
forwardingMode?: 'tun' | 'socket';
|
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[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +466,14 @@ export class DcRouter {
|
|||||||
() => this.getConstructorRoutes(),
|
() => this.getConstructorRoutes(),
|
||||||
() => this.smartProxy,
|
() => this.smartProxy,
|
||||||
() => this.options.http3,
|
() => this.options.http3,
|
||||||
() => this.options.vpnConfig?.enabled ? (this.options.vpnConfig.subnet || '10.8.0.0/24') : undefined,
|
this.options.vpnConfig?.enabled
|
||||||
|
? (tags?: string[]) => {
|
||||||
|
if (tags?.length && this.vpnManager) {
|
||||||
|
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
|
||||||
|
}
|
||||||
|
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
||||||
await this.apiTokenManager.initialize();
|
await this.apiTokenManager.initialize();
|
||||||
@@ -664,9 +684,8 @@ export class DcRouter {
|
|||||||
if (this.vpnManager && this.options.vpnConfig?.enabled) {
|
if (this.vpnManager && this.options.vpnConfig?.enabled) {
|
||||||
const subnet = this.vpnManager.getSubnet();
|
const subnet = this.vpnManager.getSubnet();
|
||||||
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
|
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
|
||||||
const mode = this.vpnManager.forwardingMode;
|
|
||||||
const clientCount = this.vpnManager.listClients().length;
|
const clientCount = this.vpnManager.listClients().length;
|
||||||
logger.log('info', `VPN Service: mode=${mode}, subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote Ingress summary
|
// Remote Ingress summary
|
||||||
@@ -794,12 +813,8 @@ export class DcRouter {
|
|||||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
// VPN route security injection: restrict vpn.required routes to VPN subnet
|
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
|
||||||
if (this.options.vpnConfig?.enabled) {
|
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
|
||||||
routes = this.injectVpnSecurity(routes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache constructor routes for RouteConfigManager
|
|
||||||
this.constructorRoutes = [...routes];
|
this.constructorRoutes = [...routes];
|
||||||
|
|
||||||
// If we have routes or need a basic SmartProxy instance, create it
|
// If we have routes or need a basic SmartProxy instance, create it
|
||||||
@@ -950,19 +965,14 @@ export class DcRouter {
|
|||||||
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// When VPN is in socket mode, the userspace NAT engine sends PP v2 headers
|
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||||
// on outbound connections to SmartProxy to preserve VPN client tunnel IPs.
|
|
||||||
if (this.options.vpnConfig?.enabled) {
|
if (this.options.vpnConfig?.enabled) {
|
||||||
const vpnForwardingMode = this.options.vpnConfig.forwardingMode
|
smartProxyConfig.acceptProxyProtocol = true;
|
||||||
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
|
if (!smartProxyConfig.proxyIPs) {
|
||||||
if (vpnForwardingMode === 'socket') {
|
smartProxyConfig.proxyIPs = [];
|
||||||
smartProxyConfig.acceptProxyProtocol = true;
|
}
|
||||||
if (!smartProxyConfig.proxyIPs) {
|
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||||
smartProxyConfig.proxyIPs = [];
|
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||||
}
|
|
||||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
|
||||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2085,43 +2095,81 @@ export class DcRouter {
|
|||||||
wgListenPort: this.options.vpnConfig.wgListenPort,
|
wgListenPort: this.options.vpnConfig.wgListenPort,
|
||||||
dns: this.options.vpnConfig.dns,
|
dns: this.options.vpnConfig.dns,
|
||||||
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
||||||
forwardingMode: this.options.vpnConfig.forwardingMode,
|
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();
|
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 }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject VPN security into routes that have vpn.required === true.
|
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
||||||
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
|
|
||||||
*/
|
*/
|
||||||
private injectVpnSecurity(routes: plugins.smartproxy.IRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
|
private async resolveVpnDomainIPs(domain: string): Promise<string[]> {
|
||||||
const vpnSubnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
const cached = this.vpnDomainIpCache.get(domain);
|
||||||
let injectedCount = 0;
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.ips;
|
||||||
const result = routes.map((route) => {
|
}
|
||||||
const dcrouterRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
|
try {
|
||||||
if (dcrouterRoute.vpn?.required) {
|
const { promises: dnsPromises } = await import('dns');
|
||||||
injectedCount++;
|
const ips = await dnsPromises.resolve4(domain);
|
||||||
const existing = route.security?.ipAllowList || [];
|
this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
|
||||||
return {
|
return ips;
|
||||||
...route,
|
} catch (err) {
|
||||||
security: {
|
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
|
||||||
...route.security,
|
return cached?.ips || []; // Return stale cache on failure, or empty
|
||||||
ipAllowList: [...existing, vpnSubnet],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return route;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (injectedCount > 0) {
|
|
||||||
logger.log('info', `VPN: Injected ipAllowList (${vpnSubnet}) into ${injectedCount} VPN-protected route(s)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
||||||
|
// via the getVpnAllowList callback — no longer a separate method here.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up RADIUS server for network authentication
|
* Set up RADIUS server for network authentication
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class RouteConfigManager {
|
|||||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||||
private getVpnSubnet?: () => string | undefined,
|
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -246,46 +246,48 @@ export class RouteConfigManager {
|
|||||||
// Private: apply merged routes to SmartProxy
|
// Private: apply merged routes to SmartProxy
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async applyRoutes(): Promise<void> {
|
public async applyRoutes(): Promise<void> {
|
||||||
const smartProxy = this.getSmartProxy();
|
const smartProxy = this.getSmartProxy();
|
||||||
if (!smartProxy) return;
|
if (!smartProxy) return;
|
||||||
|
|
||||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
// Add enabled hardcoded routes (respecting overrides)
|
const http3Config = this.getHttp3Config?.();
|
||||||
|
const vpnAllowList = this.getVpnAllowList;
|
||||||
|
|
||||||
|
// Helper: inject VPN security into a route if vpn.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()) {
|
for (const route of this.getHardcodedRoutes()) {
|
||||||
const name = route.name || '';
|
const name = route.name || '';
|
||||||
const override = this.overrides.get(name);
|
const override = this.overrides.get(name);
|
||||||
if (override && !override.enabled) {
|
if (override && !override.enabled) {
|
||||||
continue; // Skip disabled hardcoded route
|
continue; // Skip disabled hardcoded route
|
||||||
}
|
}
|
||||||
enabledRoutes.push(route);
|
enabledRoutes.push(injectVpn(route));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
|
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||||
const http3Config = this.getHttp3Config?.();
|
|
||||||
const vpnSubnet = this.getVpnSubnet?.();
|
|
||||||
for (const stored of this.storedRoutes.values()) {
|
for (const stored of this.storedRoutes.values()) {
|
||||||
if (stored.enabled) {
|
if (stored.enabled) {
|
||||||
let route = stored.route;
|
let route = stored.route;
|
||||||
if (http3Config && http3Config.enabled !== false) {
|
if (http3Config && http3Config.enabled !== false) {
|
||||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||||
}
|
}
|
||||||
// Inject VPN security for programmatic routes with vpn.required
|
enabledRoutes.push(injectVpn(route));
|
||||||
if (vpnSubnet) {
|
|
||||||
const dcRoute = route as IDcRouterRouteConfig;
|
|
||||||
if (dcRoute.vpn?.required) {
|
|
||||||
const existing = route.security?.ipAllowList || [];
|
|
||||||
route = {
|
|
||||||
...route,
|
|
||||||
security: {
|
|
||||||
...route.security,
|
|
||||||
ipAllowList: [...existing, vpnSubnet],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
enabledRoutes.push(route);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class VpnHandler {
|
|||||||
const clients = manager.listClients().map((c) => ({
|
const clients = manager.listClients().map((c) => ({
|
||||||
clientId: c.clientId,
|
clientId: c.clientId,
|
||||||
enabled: c.enabled,
|
enabled: c.enabled,
|
||||||
tags: c.tags,
|
serverDefinedClientTags: c.serverDefinedClientTags,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
assignedIp: c.assignedIp,
|
assignedIp: c.assignedIp,
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
@@ -48,7 +48,6 @@ export class VpnHandler {
|
|||||||
return {
|
return {
|
||||||
status: {
|
status: {
|
||||||
running: false,
|
running: false,
|
||||||
forwardingMode: 'socket' as const,
|
|
||||||
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
subnet: vpnConfig?.subnet || '10.8.0.0/24',
|
||||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
serverPublicKeys: null,
|
serverPublicKeys: null,
|
||||||
@@ -62,7 +61,6 @@ export class VpnHandler {
|
|||||||
return {
|
return {
|
||||||
status: {
|
status: {
|
||||||
running: manager.running,
|
running: manager.running,
|
||||||
forwardingMode: manager.forwardingMode,
|
|
||||||
subnet: manager.getSubnet(),
|
subnet: manager.getSubnet(),
|
||||||
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
serverPublicKeys: manager.getServerPublicKeys(),
|
serverPublicKeys: manager.getServerPublicKeys(),
|
||||||
@@ -89,7 +87,7 @@ export class VpnHandler {
|
|||||||
try {
|
try {
|
||||||
const bundle = await manager.createClient({
|
const bundle = await manager.createClient({
|
||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
tags: dataArg.tags,
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
description: dataArg.description,
|
description: dataArg.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +96,7 @@ export class VpnHandler {
|
|||||||
client: {
|
client: {
|
||||||
clientId: bundle.entry.clientId,
|
clientId: bundle.entry.clientId,
|
||||||
enabled: bundle.entry.enabled ?? true,
|
enabled: bundle.entry.enabled ?? true,
|
||||||
tags: bundle.entry.tags,
|
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||||
description: bundle.entry.description,
|
description: bundle.entry.description,
|
||||||
assignedIp: bundle.entry.assignedIp,
|
assignedIp: bundle.entry.assignedIp,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|||||||
@@ -14,8 +14,25 @@ export interface IVpnManagerConfig {
|
|||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
|
||||||
serverEndpoint?: string;
|
serverEndpoint?: string;
|
||||||
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
|
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
||||||
forwardingMode?: 'tun' | 'socket';
|
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 {
|
interface IPersistedServerKeys {
|
||||||
@@ -28,14 +45,18 @@ interface IPersistedServerKeys {
|
|||||||
interface IPersistedClient {
|
interface IPersistedClient {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
assignedIp?: string;
|
assignedIp?: string;
|
||||||
noisePublicKey: string;
|
noisePublicKey: string;
|
||||||
wgPublicKey: string;
|
wgPublicKey: string;
|
||||||
|
/** WireGuard private key — stored so exports and QR codes produce valid configs */
|
||||||
|
wgPrivateKey?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
/** @deprecated Legacy field — migrated to serverDefinedClientTags on load */
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,19 +69,10 @@ export class VpnManager {
|
|||||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||||
private clients: Map<string, IPersistedClient> = new Map();
|
private clients: Map<string, IPersistedClient> = new Map();
|
||||||
private serverKeys?: IPersistedServerKeys;
|
private serverKeys?: IPersistedServerKeys;
|
||||||
private _forwardingMode: 'tun' | 'socket';
|
|
||||||
|
|
||||||
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
|
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
|
||||||
this.storageManager = storageManager;
|
this.storageManager = storageManager;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
// Auto-detect forwarding mode: tun if root, socket otherwise
|
|
||||||
this._forwardingMode = config.forwardingMode
|
|
||||||
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The effective forwarding mode (tun or socket). */
|
|
||||||
public get forwardingMode(): 'tun' | 'socket' {
|
|
||||||
return this._forwardingMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The VPN subnet CIDR. */
|
/** The VPN subnet CIDR. */
|
||||||
@@ -92,7 +104,7 @@ export class VpnManager {
|
|||||||
publicKey: client.noisePublicKey,
|
publicKey: client.noisePublicKey,
|
||||||
wgPublicKey: client.wgPublicKey,
|
wgPublicKey: client.wgPublicKey,
|
||||||
enabled: client.enabled,
|
enabled: client.enabled,
|
||||||
tags: client.tags,
|
serverDefinedClientTags: client.serverDefinedClientTags,
|
||||||
description: client.description,
|
description: client.description,
|
||||||
assignedIp: client.assignedIp,
|
assignedIp: client.assignedIp,
|
||||||
expiresAt: client.expiresAt,
|
expiresAt: client.expiresAt,
|
||||||
@@ -113,16 +125,37 @@ export class VpnManager {
|
|||||||
publicKey: this.serverKeys.noisePublicKey,
|
publicKey: this.serverKeys.noisePublicKey,
|
||||||
subnet,
|
subnet,
|
||||||
dns: this.config.dns,
|
dns: this.config.dns,
|
||||||
forwardingMode: this._forwardingMode,
|
forwardingMode: 'socket',
|
||||||
transportMode: 'all',
|
transportMode: 'all',
|
||||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||||
wgListenPort,
|
wgListenPort,
|
||||||
clients: clientEntries,
|
clients: clientEntries,
|
||||||
socketForwardProxyProtocol: this._forwardingMode === 'socket',
|
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);
|
await this.vpnServer.start(serverConfig);
|
||||||
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
|
||||||
|
// 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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,7 +181,7 @@ export class VpnManager {
|
|||||||
*/
|
*/
|
||||||
public async createClient(opts: {
|
public async createClient(opts: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||||
if (!this.vpnServer) {
|
if (!this.vpnServer) {
|
||||||
@@ -157,28 +190,30 @@ export class VpnManager {
|
|||||||
|
|
||||||
const bundle = await this.vpnServer.createClient({
|
const bundle = await this.vpnServer.createClient({
|
||||||
clientId: opts.clientId,
|
clientId: opts.clientId,
|
||||||
tags: opts.tags,
|
serverDefinedClientTags: opts.serverDefinedClientTags,
|
||||||
description: opts.description,
|
description: opts.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update WireGuard config endpoint if serverEndpoint is configured
|
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||||
if (this.config.serverEndpoint && bundle.wireguardConfig) {
|
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||||
const wgPort = this.config.wgListenPort ?? 51820;
|
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
|
||||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
/Endpoint\s*=\s*.+/,
|
/AllowedIPs\s*=\s*.+/,
|
||||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist client entry (without private keys)
|
// Persist client entry (including WG private key for export/QR)
|
||||||
const persisted: IPersistedClient = {
|
const persisted: IPersistedClient = {
|
||||||
clientId: bundle.entry.clientId,
|
clientId: bundle.entry.clientId,
|
||||||
enabled: bundle.entry.enabled ?? true,
|
enabled: bundle.entry.enabled ?? true,
|
||||||
tags: bundle.entry.tags,
|
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||||
description: bundle.entry.description,
|
description: bundle.entry.description,
|
||||||
assignedIp: bundle.entry.assignedIp,
|
assignedIp: bundle.entry.assignedIp,
|
||||||
noisePublicKey: bundle.entry.publicKey,
|
noisePublicKey: bundle.entry.publicKey,
|
||||||
wgPublicKey: bundle.entry.wgPublicKey || '',
|
wgPublicKey: bundle.entry.wgPublicKey || '',
|
||||||
|
wgPrivateKey: bundle.secrets?.wgPrivateKey
|
||||||
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
expiresAt: bundle.entry.expiresAt,
|
expiresAt: bundle.entry.expiresAt,
|
||||||
@@ -186,6 +221,7 @@ export class VpnManager {
|
|||||||
this.clients.set(persisted.clientId, persisted);
|
this.clients.set(persisted.clientId, persisted);
|
||||||
await this.persistClient(persisted);
|
await this.persistClient(persisted);
|
||||||
|
|
||||||
|
this.config.onClientChanged?.();
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +235,7 @@ export class VpnManager {
|
|||||||
await this.vpnServer.removeClient(clientId);
|
await this.vpnServer.removeClient(clientId);
|
||||||
this.clients.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
|
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,6 +257,7 @@ export class VpnManager {
|
|||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
}
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,6 +272,7 @@ export class VpnManager {
|
|||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
}
|
}
|
||||||
|
this.config.onClientChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,20 +282,13 @@ export class VpnManager {
|
|||||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
||||||
|
|
||||||
// Update endpoint in WireGuard config
|
// Update persisted entry with new keys (including private key for export/QR)
|
||||||
if (this.config.serverEndpoint && bundle.wireguardConfig) {
|
|
||||||
const wgPort = this.config.wgListenPort ?? 51820;
|
|
||||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
|
||||||
/Endpoint\s*=\s*.+/,
|
|
||||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update persisted entry with new public keys
|
|
||||||
const client = this.clients.get(clientId);
|
const client = this.clients.get(clientId);
|
||||||
if (client) {
|
if (client) {
|
||||||
client.noisePublicKey = bundle.entry.publicKey;
|
client.noisePublicKey = bundle.entry.publicKey;
|
||||||
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
|
client.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||||
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
}
|
}
|
||||||
@@ -265,24 +297,53 @@ export class VpnManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export a client config (without secrets).
|
* Export a client config. Injects stored WG private key and per-client AllowedIPs.
|
||||||
*/
|
*/
|
||||||
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
||||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||||
let config = await this.vpnServer.exportClientConfig(clientId, format);
|
let config = await this.vpnServer.exportClientConfig(clientId, format);
|
||||||
|
|
||||||
// Update endpoint in WireGuard config
|
if (format === 'wireguard') {
|
||||||
if (format === 'wireguard' && this.config.serverEndpoint) {
|
const persisted = this.clients.get(clientId);
|
||||||
const wgPort = this.config.wgListenPort ?? 51820;
|
|
||||||
config = config.replace(
|
// Inject stored WG private key so exports produce valid, scannable configs
|
||||||
/Endpoint\s*=\s*.+/,
|
if (persisted?.wgPrivateKey) {
|
||||||
`Endpoint = ${this.config.serverEndpoint}:${wgPort}`,
|
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;
|
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 ───────────────────────────────────────────────
|
// ── Status and telemetry ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -364,6 +425,12 @@ export class VpnManager {
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const client = await this.storageManager.getJSON<IPersistedClient>(key);
|
const client = await this.storageManager.getJSON<IPersistedClient>(key);
|
||||||
if (client) {
|
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);
|
this.clients.set(client.clientId, client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface IRouteRemoteIngress {
|
|||||||
export interface IRouteVpn {
|
export interface IRouteVpn {
|
||||||
/** Whether this route requires VPN access */
|
/** Whether this route requires VPN access */
|
||||||
required: boolean;
|
required: boolean;
|
||||||
|
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
|
||||||
|
allowedServerDefinedClientTags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
export interface IVpnClient {
|
export interface IVpnClient {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
assignedIp?: string;
|
assignedIp?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -17,7 +17,6 @@ export interface IVpnClient {
|
|||||||
*/
|
*/
|
||||||
export interface IVpnServerStatus {
|
export interface IVpnServerStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
forwardingMode: 'tun' | 'socket';
|
|
||||||
subnet: string;
|
subnet: string;
|
||||||
wgListenPort: number;
|
wgListenPort: number;
|
||||||
serverPublicKeys: {
|
serverPublicKeys: {
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ interface IIdentity {
|
|||||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
||||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
||||||
| `IRouteVpn` | Route-level VPN config: `required` flag to restrict access to VPN clients |
|
| `IRouteVpn` | Route-level VPN config: `required` flag and optional `allowedServerDefinedClientTags` |
|
||||||
|
|
||||||
#### VPN Interfaces
|
#### VPN Interfaces
|
||||||
| Interface | Description |
|
| Interface | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `IVpnClient` | Client registration: clientId, enabled, tags, description, assignedIp, timestamps |
|
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
|
||||||
| `IVpnServerStatus` | Server status: running, forwardingMode, subnet, wgListenPort, publicKeys, client counts |
|
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
|
||||||
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
||||||
|
|
||||||
### Request Interfaces (`requests`)
|
### Request Interfaces (`requests`)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
|||||||
request: {
|
request: {
|
||||||
identity: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.14.0',
|
version: '11.21.5',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -974,7 +974,7 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
|
|||||||
|
|
||||||
export const createVpnClientAction = vpnStatePart.createAction<{
|
export const createVpnClientAction = vpnStatePart.createAction<{
|
||||||
clientId: string;
|
clientId: string;
|
||||||
tags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
@@ -988,7 +988,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
|
|||||||
const response = await request.fire({
|
const response = await request.fire({
|
||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
tags: dataArg.tags,
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
description: dataArg.description,
|
description: dataArg.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from './shared/css.js';
|
||||||
@@ -181,13 +182,14 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
value: status?.running ? 'Running' : 'Stopped',
|
value: status?.running ? 'Running' : 'Stopped',
|
||||||
icon: 'lucide:server',
|
icon: 'lucide:server',
|
||||||
description: status?.running ? `${status.forwardingMode} mode` : 'VPN server not running',
|
description: status?.running ? 'Active' : 'VPN server not running',
|
||||||
color: status?.running ? '#10b981' : '#ef4444',
|
color: status?.running ? '#10b981' : '#ef4444',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>VPN</ops-sectionheading>
|
<ops-sectionheading>VPN</ops-sectionheading>
|
||||||
|
<div class="vpnContainer">
|
||||||
|
|
||||||
${this.vpnState.newClientConfig ? html`
|
${this.vpnState.newClientConfig ? html`
|
||||||
<div class="configDialog">
|
<div class="configDialog">
|
||||||
@@ -214,13 +216,36 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}}
|
}}
|
||||||
>Download .conf</dees-button>
|
>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
|
<dees-button
|
||||||
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
|
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
|
||||||
>Dismiss</dees-button>
|
>Dismiss</dees-button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
|
|
||||||
${status ? html`
|
${status ? html`
|
||||||
<div class="serverInfo">
|
<div class="serverInfo">
|
||||||
@@ -232,10 +257,6 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
<span class="infoLabel">WireGuard Port</span>
|
<span class="infoLabel">WireGuard Port</span>
|
||||||
<span class="infoValue">${status.wgListenPort}</span>
|
<span class="infoValue">${status.wgListenPort}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoItem">
|
|
||||||
<span class="infoLabel">Forwarding Mode</span>
|
|
||||||
<span class="infoValue">${status.forwardingMode}</span>
|
|
||||||
</div>
|
|
||||||
${status.serverPublicKeys ? html`
|
${status.serverPublicKeys ? html`
|
||||||
<div class="infoItem">
|
<div class="infoItem">
|
||||||
<span class="infoLabel">WG Public Key</span>
|
<span class="infoLabel">WG Public Key</span>
|
||||||
@@ -255,38 +276,237 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
? html`<span class="statusBadge enabled">enabled</span>`
|
? html`<span class="statusBadge enabled">enabled</span>`
|
||||||
: html`<span class="statusBadge disabled">disabled</span>`,
|
: html`<span class="statusBadge disabled">disabled</span>`,
|
||||||
'VPN IP': client.assignedIp || '-',
|
'VPN IP': client.assignedIp || '-',
|
||||||
'Tags': client.tags?.length
|
'Tags': client.serverDefinedClientTags?.length
|
||||||
? html`${client.tags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||||
: '-',
|
: '-',
|
||||||
'Description': client.description || '-',
|
'Description': client.description || '-',
|
||||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||||
})}
|
})}
|
||||||
.dataActions=${[
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Create Client',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header'],
|
||||||
|
actionFunc: async () => {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Create VPN Client',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
if (!data.clientId) return;
|
||||||
|
const serverDefinedClientTags = data.tags
|
||||||
|
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
|
||||||
|
clientId: data.clientId,
|
||||||
|
description: data.description || undefined,
|
||||||
|
serverDefinedClientTags,
|
||||||
|
});
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Toggle',
|
name: 'Toggle',
|
||||||
iconName: 'lucide:power',
|
iconName: 'lucide:power',
|
||||||
action: async (client: interfaces.data.IVpnClient) => {
|
type: ['contextmenu', 'inRow'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
enabled: !client.enabled,
|
enabled: !client.enabled,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Export Config',
|
||||||
|
iconName: 'lucide:download',
|
||||||
|
type: ['contextmenu', 'inRow'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
const exportConfig = async (format: 'wireguard' | 'smartvpn') => {
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ExportVpnClientConfig
|
||||||
|
>('/typedrequest', 'exportVpnClientConfig');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: appstate.loginStatePart.getState()!.identity!,
|
||||||
|
clientId: client.clientId,
|
||||||
|
format,
|
||||||
|
});
|
||||||
|
if (response.success && response.config) {
|
||||||
|
const ext = format === 'wireguard' ? 'conf' : 'json';
|
||||||
|
const blob = new Blob([response.config], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${client.clientId}.${ext}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
DeesToast.createAndShow({ message: `${format} config downloaded`, type: 'success', duration: 3000 });
|
||||||
|
} else {
|
||||||
|
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
DeesToast.createAndShow({ message: err.message || 'Export failed', type: 'error', duration: 5000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showQrCode = async () => {
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ExportVpnClientConfig
|
||||||
|
>('/typedrequest', 'exportVpnClientConfig');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: appstate.loginStatePart.getState()!.identity!,
|
||||||
|
clientId: client.clientId,
|
||||||
|
format: 'wireguard',
|
||||||
|
});
|
||||||
|
if (response.success && response.config) {
|
||||||
|
const dataUrl = await plugins.qrcode.toDataURL(
|
||||||
|
response.config,
|
||||||
|
{ width: 400, margin: 2 }
|
||||||
|
);
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `QR Code: ${client.clientId}`,
|
||||||
|
content: html`
|
||||||
|
<div style="text-align: center; padding: 16px;">
|
||||||
|
<img src="${dataUrl}" style="max-width: 100%; image-rendering: pixelated;" />
|
||||||
|
<p style="margin-top: 12px; font-size: 13px; color: #9ca3af;">
|
||||||
|
Scan with the WireGuard app on your phone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
DeesToast.createAndShow({ message: err.message || 'QR generation failed', type: 'error', duration: 5000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Export Config: ${client.clientId}`,
|
||||||
|
content: html`<p>Choose a config format to download.</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'WireGuard (.conf)',
|
||||||
|
iconName: 'lucide:shield',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
await exportConfig('wireguard');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SmartVPN (.json)',
|
||||||
|
iconName: 'lucide:braces',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
await exportConfig('smartvpn');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QR Code (WireGuard)',
|
||||||
|
iconName: 'lucide:qr-code',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
await showQrCode();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rotate Keys',
|
||||||
|
iconName: 'lucide:rotate-cw',
|
||||||
|
type: ['contextmenu'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Rotate Client Keys',
|
||||||
|
content: html`<p>Generate new keys for "${client.clientId}"? The old keys will be invalidated and the client will need the new config to reconnect.</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Rotate',
|
||||||
|
iconName: 'lucide:rotate-cw',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_RotateVpnClientKey
|
||||||
|
>('/typedrequest', 'rotateVpnClientKey');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: appstate.loginStatePart.getState()!.identity!,
|
||||||
|
clientId: client.clientId,
|
||||||
|
});
|
||||||
|
if (response.success && response.wireguardConfig) {
|
||||||
|
appstate.vpnStatePart.setState({
|
||||||
|
...appstate.vpnStatePart.getState()!,
|
||||||
|
newClientConfig: response.wireguardConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await modalArg.destroy();
|
||||||
|
} catch (err: any) {
|
||||||
|
DeesToast.createAndShow({ message: err.message || 'Rotate failed', type: 'error', duration: 5000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
action: async (client: interfaces.data.IVpnClient) => {
|
type: ['contextmenu'],
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
DeesModal.createAndShow({
|
DeesModal.createAndShow({
|
||||||
heading: 'Delete VPN Client',
|
heading: 'Delete VPN Client',
|
||||||
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
|
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
action: async (modal: any) => {
|
iconName: 'lucide:trash2',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
||||||
modal.destroy();
|
await modalArg.destroy();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -294,37 +514,8 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
.createNewItem=${async () => {
|
|
||||||
const { DeesModal, DeesForm, DeesInputText } = await import('@design.estate/dees-catalog');
|
|
||||||
DeesModal.createAndShow({
|
|
||||||
heading: 'Create VPN Client',
|
|
||||||
content: html`
|
|
||||||
<dees-form>
|
|
||||||
<dees-input-text id="clientId" .label=${'Client ID'} .key=${'clientId'} required></dees-input-text>
|
|
||||||
<dees-input-text id="description" .label=${'Description'} .key=${'description'}></dees-input-text>
|
|
||||||
<dees-input-text id="tags" .label=${'Tags (comma-separated)'} .key=${'tags'}></dees-input-text>
|
|
||||||
</dees-form>
|
|
||||||
`,
|
|
||||||
menuOptions: [
|
|
||||||
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
|
|
||||||
{
|
|
||||||
name: 'Create',
|
|
||||||
action: async (modal: any) => {
|
|
||||||
const form = modal.shadowRoot!.querySelector('dees-form') as any;
|
|
||||||
const data = await form.collectFormData();
|
|
||||||
const tags = 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,
|
|
||||||
tags,
|
|
||||||
});
|
|
||||||
modal.destroy();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
></dees-table>
|
></dees-table>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import * as szCatalog from '@serve.zone/catalog';
|
|||||||
// TypedSocket for real-time push communication
|
// TypedSocket for real-time push communication
|
||||||
import * as typedsocket from '@api.global/typedsocket';
|
import * as typedsocket from '@api.global/typedsocket';
|
||||||
|
|
||||||
|
// QR code generation for WireGuard configs
|
||||||
|
import * as qrcode from 'qrcode';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
deesElement,
|
deesElement,
|
||||||
deesCatalog,
|
deesCatalog,
|
||||||
szCatalog,
|
szCatalog,
|
||||||
typedsocket,
|
typedsocket,
|
||||||
|
qrcode,
|
||||||
}
|
}
|
||||||
|
|
||||||
// domtools gives us TypedRequest and other utilities
|
// domtools gives us TypedRequest and other utilities
|
||||||
|
|||||||
Reference in New Issue
Block a user