feat(vpn): use authenticated VPN route grants

This commit is contained in:
2026-05-24 05:11:48 +00:00
parent ac118397f9
commit 37adcc9ddc
10 changed files with 138 additions and 334 deletions
+6
View File
@@ -2,6 +2,12 @@
## Pending ## Pending
### Features
- switch VPN route authorization to authenticated SmartVPN metadata (vpn)
- configure SmartVPN to forward real client source IPs plus VPN metadata through trusted PROXY v2 headers
- map target profiles to SmartProxy VPN client grants instead of mutating route source IP allow lists
- keep live VPN client source IP tracking as status/UI data while SmartProxy enforces source policy per connection
## 2026-05-21 - 13.34.0 ## 2026-05-21 - 13.34.0
+5 -5
View File
@@ -22,13 +22,13 @@
"watch": "tswatch" "watch": "tswatch"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.4.1", "@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4", "@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.3.0", "@git.zone/tsdocker": "^2.4.0",
"@git.zone/tsrun": "^2.0.4", "@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6", "@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5", "@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.0" "@types/node": "^25.9.1"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.3.1", "@api.global/typedrequest": "^3.3.1",
@@ -56,13 +56,13 @@
"@push.rocks/smartnetwork": "^4.7.2", "@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4", "@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.10.3", "@push.rocks/smartproxy": "^27.11.0",
"@push.rocks/smartradius": "^1.1.2", "@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartrequest": "^5.0.3", "@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.1", "@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.4", "@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4", "@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0", "@serve.zone/interfaces": "^5.8.0",
+36 -36
View File
@@ -84,8 +84,8 @@ importers:
specifier: ^4.2.4 specifier: ^4.2.4
version: 4.2.4 version: 4.2.4
'@push.rocks/smartproxy': '@push.rocks/smartproxy':
specifier: ^27.10.3 specifier: ^27.11.0
version: 27.10.3 version: 27.11.0
'@push.rocks/smartradius': '@push.rocks/smartradius':
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2 version: 1.1.2
@@ -102,8 +102,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.19.4 specifier: 1.20.0
version: 1.19.4 version: 1.20.0
'@push.rocks/taskbuffer': '@push.rocks/taskbuffer':
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
@@ -133,14 +133,14 @@ importers:
version: 14.0.0 version: 14.0.0
devDependencies: devDependencies:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^4.4.1 specifier: ^4.4.2
version: 4.4.1 version: 4.4.2
'@git.zone/tsbundle': '@git.zone/tsbundle':
specifier: ^2.10.4 specifier: ^2.10.4
version: 2.10.4 version: 2.10.4
'@git.zone/tsdocker': '@git.zone/tsdocker':
specifier: ^2.3.0 specifier: ^2.4.0
version: 2.3.0 version: 2.4.0
'@git.zone/tsrun': '@git.zone/tsrun':
specifier: ^2.0.4 specifier: ^2.0.4
version: 2.0.4 version: 2.0.4
@@ -151,8 +151,8 @@ importers:
specifier: ^3.3.5 specifier: ^3.3.5
version: 3.3.5(@tiptap/pm@2.27.2) version: 3.3.5(@tiptap/pm@2.27.2)
'@types/node': '@types/node':
specifier: ^25.9.0 specifier: ^25.9.1
version: 25.9.0 version: 25.9.1
packages: packages:
@@ -718,16 +718,16 @@ packages:
resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==}
engines: {node: '>=6'} engines: {node: '>=6'}
'@git.zone/tsbuild@4.4.1': '@git.zone/tsbuild@4.4.2':
resolution: {integrity: sha512-usxx8BBQsAypxjFOfd1GEV9pL9EUshRKktXtRWHMDByb6ps83+PdUIb3D7O+nkkBp4C9PXo3cfbsR4Asvo33CA==} resolution: {integrity: sha512-v2m0fFYFt3vJZMvNAlrNChHYjZZNOf4iyO0mNNiHeO+sTR3cddkYb++zO/GL3v2UkG3nDRwfEkwUS4UzuXBEWw==}
hasBin: true hasBin: true
'@git.zone/tsbundle@2.10.4': '@git.zone/tsbundle@2.10.4':
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==} resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
hasBin: true hasBin: true
'@git.zone/tsdocker@2.3.0': '@git.zone/tsdocker@2.4.0':
resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==} resolution: {integrity: sha512-GFE93RxFm8HDrSm5Ulggy4se7heb4GaNQgaWV6Mds6lhkm6GouO91xZYlmXVH9glzBoFJNG63pFXYHW6nrqf5A==}
hasBin: true hasBin: true
'@git.zone/tspublish@1.11.6': '@git.zone/tspublish@1.11.6':
@@ -1422,8 +1422,8 @@ packages:
'@push.rocks/smartpromise@4.2.4': '@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==} resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.10.3': '@push.rocks/smartproxy@27.11.0':
resolution: {integrity: sha512-2TvjgXUHtV0s8WH2RbtCS5+yjnFjbvQQ2ROmtVme1lgt2GUaAbekozUJNTE1ZMLEXc4xcZRdXIOfgBcQ6j/dmQ==} resolution: {integrity: sha512-ruyUMbrk28BTtrhcZpB5fX35FRQyyhJgVd7snPFa3Zttw0N8ahYrwKXpKfuagvOcaIpORMQoyR5WSv0C2ATFVA==}
'@push.rocks/smartpuppeteer@2.0.6': '@push.rocks/smartpuppeteer@2.0.6':
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==} resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
@@ -1482,8 +1482,8 @@ packages:
'@push.rocks/smartversion@3.1.0': '@push.rocks/smartversion@3.1.0':
resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==} resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==}
'@push.rocks/smartvpn@1.19.4': '@push.rocks/smartvpn@1.20.0':
resolution: {integrity: sha512-Cp6yyzRcZlqQMEWAQ/CG2tvUxSR4eSmzMTDQFVJsPtV+CbhXpulbqqz0penU6drVMiRGzXhwoQZtGYynigIXwA==} resolution: {integrity: sha512-k5cdbHGtCUMcZTwJr+7BwXNFxbeXZEe5MZ00y/f2Isi8yLAdfmdBJ5o32vwR0LJvWm2ZFn7ST8S1AkCY/K9L3w==}
'@push.rocks/smartwatch@6.4.0': '@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
@@ -2158,8 +2158,8 @@ packages:
'@types/node@22.19.17': '@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@25.9.0': '@types/node@25.9.1':
resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==} resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@@ -5194,7 +5194,7 @@ snapshots:
dependencies: dependencies:
'@fortawesome/fontawesome-common-types': 7.2.0 '@fortawesome/fontawesome-common-types': 7.2.0
'@git.zone/tsbuild@4.4.1': '@git.zone/tsbuild@4.4.2':
dependencies: dependencies:
'@git.zone/tspublish': 1.11.6 '@git.zone/tspublish': 1.11.6
'@push.rocks/early': 4.0.4 '@push.rocks/early': 4.0.4
@@ -5243,7 +5243,7 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@git.zone/tsdocker@2.3.0': '@git.zone/tsdocker@2.4.0':
dependencies: dependencies:
'@push.rocks/lik': 6.4.1 '@push.rocks/lik': 6.4.1
'@push.rocks/projectinfo': 5.1.0 '@push.rocks/projectinfo': 5.1.0
@@ -5370,7 +5370,7 @@ snapshots:
'@happy-dom/global-registrator@20.9.0': '@happy-dom/global-registrator@20.9.0':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
happy-dom: 20.9.0 happy-dom: 20.9.0
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
@@ -6675,7 +6675,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {} '@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.10.3': '@push.rocks/smartproxy@27.11.0':
dependencies: dependencies:
'@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
@@ -6822,7 +6822,7 @@ snapshots:
'@types/semver': 7.7.1 '@types/semver': 7.7.1
semver: 7.7.4 semver: 7.7.4
'@push.rocks/smartvpn@1.19.4': '@push.rocks/smartvpn@1.20.0':
dependencies: dependencies:
'@push.rocks/smartnftables': 1.2.0 '@push.rocks/smartnftables': 1.2.0
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
@@ -7583,7 +7583,7 @@ snapshots:
'@types/clean-css@4.2.11': '@types/clean-css@4.2.11':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
source-map: 0.6.1 source-map: 0.6.1
'@types/debug@4.1.13': '@types/debug@4.1.13':
@@ -7611,7 +7611,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10': '@types/jsonwebtoken@9.0.10':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/node': 25.9.0 '@types/node': 25.9.1
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
@@ -7632,16 +7632,16 @@ snapshots:
'@types/mute-stream@0.0.4': '@types/mute-stream@0.0.4':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
'@types/node-fetch@2.6.13': '@types/node-fetch@2.6.13':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
form-data: 4.0.5 form-data: 4.0.5
'@types/node-forge@1.3.14': '@types/node-forge@1.3.14':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
'@types/node@16.9.1': {} '@types/node@16.9.1': {}
@@ -7653,13 +7653,13 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/node@25.9.0': '@types/node@25.9.1':
dependencies: dependencies:
undici-types: 7.24.6 undici-types: 7.24.6
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
'@types/randomatic@3.1.5': {} '@types/randomatic@3.1.5': {}
@@ -7671,7 +7671,7 @@ snapshots:
'@types/through2@2.0.41': '@types/through2@2.0.41':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
@@ -7699,11 +7699,11 @@ snapshots:
'@types/ws@8.18.1': '@types/ws@8.18.1':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
optional: true optional: true
'@ungap/structured-clone@1.3.1': {} '@ungap/structured-clone@1.3.1': {}
@@ -8423,7 +8423,7 @@ snapshots:
happy-dom@20.9.0: happy-dom@20.9.0:
dependencies: dependencies:
'@types/node': 25.9.0 '@types/node': 25.9.1
'@types/whatwg-mimetype': 3.0.2 '@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1 '@types/ws': 8.18.1
entities: 7.0.1 entities: 7.0.1
+2 -2
View File
@@ -198,9 +198,9 @@ await router.start();
## VPN Target Profiles ## VPN Target Profiles
Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also reach non-`vpnOnly` routes that would have allowed the client's real connecting IP without the VPN. Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also be granted to routes whose source policy is meant to evaluate the client's real connecting IP.
dcrouter evaluates the live source IP reported by the VPN transport, such as `remoteAddr` or the WireGuard peer endpoint. If the route source policy allows that real IP, dcrouter injects the client's assigned VPN IP into SmartProxy for that route. The source-IP grant is live-only and is removed or updated when the VPN client disconnects or changes peer endpoint. dcrouter maps target profiles to SmartProxy VPN client grants. SmartVPN forwards both the real client source IP and authenticated VPN metadata through trusted PROXY v2 headers, so SmartProxy checks source policy and VPN client authorization separately for each connection. Route `security.ipAllowList` and `security.ipBlockList` stay the source of truth for real source-IP policy; `vpnOnly` adds the requirement for authenticated VPN metadata and a matching VPN client grant.
```typescript ```typescript
const targetProfile = { const targetProfile = {
+27 -30
View File
@@ -77,7 +77,7 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
}, },
} as any; } as any;
(dcRouter as any).routeConfigManager = { (dcRouter as any).routeConfigManager = {
setVpnClientIpsResolver: (resolver: unknown) => { setVpnClientAccessResolver: (resolver: unknown) => {
resolverValues.push(resolver); resolverValues.push(resolver);
}, },
applyRoutes: async () => { applyRoutes: async () => {
@@ -121,15 +121,15 @@ tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN client
const prepared = (manager as any).injectVpnSecurity(route); const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual([]); expect(prepared.security.ipAllowList).toEqual(['*']);
expect(prepared.security.ipBlockList).toContain('*'); expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
}); });
tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', async () => { tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
const manager = new RouteConfigManager( const manager = new RouteConfigManager(
() => undefined, () => undefined,
undefined, undefined,
() => ['10.8.0.2'], () => ['client-1'],
); );
const route = { const route = {
name: 'private-route', name: 'private-route',
@@ -144,15 +144,16 @@ tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', as
const prepared = (manager as any).injectVpnSecurity(route); const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['10.8.0.2']); expect(prepared.security.ipAllowList).toEqual(['*', '203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']); expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
}); });
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => { tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
const manager = new RouteConfigManager( const manager = new RouteConfigManager(
() => undefined, () => undefined,
undefined, undefined,
() => ['10.8.0.2'], () => ['client-1'],
); );
const route = { const route = {
name: 'shared-private-route', name: 'shared-private-route',
@@ -166,8 +167,9 @@ tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly
const prepared = (manager as any).injectVpnSecurity(route); const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10', '10.8.0.2']); expect(prepared.security.ipAllowList).toEqual(['203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']); expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
}); });
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => { tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
@@ -181,17 +183,17 @@ tap.test('TargetProfileManager matches wildcard profiles against string route do
createdBy: 'test', createdBy: 'test',
}); });
const entries = manager.getMatchingClientIps( const entries = manager.getMatchingVpnClients(
{ {
name: 'hagen-app', name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] }, match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any, } as any,
'route-1', 'route-1',
[{ enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
); );
expect(entries).toEqual(['10.8.0.2']); expect(entries).toEqual(['client-1']);
}); });
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => { tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
@@ -238,7 +240,7 @@ tap.test('TargetProfileManager allows source-IP reachable routes for opted-in pr
createdBy: 'test', createdBy: 'test',
}); });
const entries = manager.getMatchingClientIps( const entries = manager.getMatchingVpnClients(
{ {
name: 'restricted-public-route', name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] }, match: { domains: 'app.example.com', ports: [443] },
@@ -248,13 +250,12 @@ tap.test('TargetProfileManager allows source-IP reachable routes for opted-in pr
'route-1', 'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(), new Map(),
new Map([['client-1', '203.0.113.10']]),
); );
expect(entries).toEqual(['10.8.0.2']); expect(entries).toEqual(['client-1']);
}); });
tap.test('TargetProfileManager does not allow non-matching client source IPs', async () => { tap.test('TargetProfileManager leaves real source-IP enforcement to SmartProxy', async () => {
const manager = new TargetProfileManager(); const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', { (manager as any).profiles.set('profile-1', {
id: 'profile-1', id: 'profile-1',
@@ -265,7 +266,7 @@ tap.test('TargetProfileManager does not allow non-matching client source IPs', a
createdBy: 'test', createdBy: 'test',
}); });
const entries = manager.getMatchingClientIps( const entries = manager.getMatchingVpnClients(
{ {
name: 'restricted-public-route', name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] }, match: { domains: 'app.example.com', ports: [443] },
@@ -275,13 +276,12 @@ tap.test('TargetProfileManager does not allow non-matching client source IPs', a
'route-1', 'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(), new Map(),
new Map([['client-1', '198.51.100.10']]),
); );
expect(entries).toEqual([]); expect(entries).toEqual(['client-1']);
}); });
tap.test('TargetProfileManager source-IP matching respects route block lists', async () => { tap.test('TargetProfileManager does not grant routes with wildcard source block', async () => {
const manager = new TargetProfileManager(); const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', { (manager as any).profiles.set('profile-1', {
id: 'profile-1', id: 'profile-1',
@@ -292,20 +292,19 @@ tap.test('TargetProfileManager source-IP matching respects route block lists', a
createdBy: 'test', createdBy: 'test',
}); });
const entries = manager.getMatchingClientIps( const entries = manager.getMatchingVpnClients(
{ {
name: 'blocked-route', name: 'blocked-route',
match: { domains: 'app.example.com', ports: [443] }, match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { security: {
ipAllowList: ['203.0.113.0/24'], ipAllowList: ['203.0.113.0/24'],
ipBlockList: ['203.0.113.10'], ipBlockList: ['*'],
}, },
} as any, } as any,
'route-1', 'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(), new Map(),
new Map([['client-1', '203.0.113.10']]),
); );
expect(entries).toEqual([]); expect(entries).toEqual([]);
@@ -322,7 +321,7 @@ tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP rea
createdBy: 'test', createdBy: 'test',
}); });
const entries = manager.getMatchingClientIps( const entries = manager.getMatchingVpnClients(
{ {
name: 'public-route', name: 'public-route',
match: { domains: 'public.example.com', ports: [443] }, match: { domains: 'public.example.com', ports: [443] },
@@ -331,13 +330,12 @@ tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP rea
'route-1', 'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(), new Map(),
new Map([['client-1', '203.0.113.10']]),
); );
expect(entries).toEqual(['10.8.0.2']); expect(entries).toEqual(['client-1']);
}); });
tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP matching alone', async () => { tap.test('TargetProfileManager grants vpnOnly routes through source-policy profiles', async () => {
const manager = new TargetProfileManager(); const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', { (manager as any).profiles.set('profile-1', {
id: 'profile-1', id: 'profile-1',
@@ -348,7 +346,7 @@ tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP m
createdBy: 'test', createdBy: 'test',
}); });
const entries = manager.getMatchingClientIps( const entries = manager.getMatchingVpnClients(
{ {
name: 'vpn-only-route', name: 'vpn-only-route',
vpnOnly: true, vpnOnly: true,
@@ -359,10 +357,9 @@ tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP m
'route-1', 'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(), new Map(),
new Map([['client-1', '203.0.113.10']]),
); );
expect(entries).toEqual([]); expect(entries).toEqual(['client-1']);
}); });
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => { tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
@@ -393,7 +390,7 @@ tap.test('TargetProfileManager includes source-IP reachable route domains in cli
}], }],
]) as any; ]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes, '203.0.113.10'); const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('app.example.com'); expect(accessSpec.domains).toContain('app.example.com');
}); });
+11 -14
View File
@@ -26,7 +26,7 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import type { TIpAllowEntry } from './config/classes.route-config-manager.js'; import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js'; import { DnsManager } from './dns/manager.dns.js';
@@ -605,7 +605,7 @@ export class DcRouter {
this.routeConfigManager = new RouteConfigManager( this.routeConfigManager = new RouteConfigManager(
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
this.createVpnRouteAllowListResolver(), this.createVpnClientAccessResolver(),
this.referenceResolver, this.referenceResolver,
// Sync routes to RemoteIngressManager whenever routes change, // Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary // then push updated derived ports to the Rust hub binary
@@ -2399,10 +2399,10 @@ export class DcRouter {
/** /**
* Set up VPN server for VPN-based route access control. * Set up VPN server for VPN-based route access control.
*/ */
private createVpnRouteAllowListResolver(): (( private createVpnClientAccessResolver(): ((
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string, routeId?: string,
) => TIpAllowEntry[]) | undefined { ) => TVpnClientAllowEntry[]) | undefined {
if (!this.options.vpnConfig?.enabled) { if (!this.options.vpnConfig?.enabled) {
return undefined; return undefined;
} }
@@ -2416,12 +2416,11 @@ export class DcRouter {
return []; return [];
} }
return this.targetProfileManager.getMatchingClientIps( return this.targetProfileManager.getMatchingVpnClients(
route, route,
routeId, routeId,
this.vpnManager.listClients(), this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(), this.routeConfigManager?.getRoutes() || new Map(),
this.vpnManager.getClientSourceIpMap(),
); );
}; };
} }
@@ -2453,22 +2452,21 @@ export class DcRouter {
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart, bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd, bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
onClientChanged: () => { onClientChanged: () => {
// Re-apply routes so profile-based ipAllowLists get updated // Re-apply routes so profile-based VPN client grants get updated
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget) // (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
this.routeConfigManager?.applyRoutes().catch((err) => { this.routeConfigManager?.applyRoutes().catch((err) => {
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`); logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
}); });
}, },
onClientSourceIpsChanged: () => { onClientSourceIpsChanged: () => {
this.routeConfigManager?.applyRoutes().catch((err) => { // SmartProxy now receives the real source IP per connection via PROXY v2.
logger.log('warn', `Failed to re-apply routes after VPN client source IP change: ${err?.message || err}`); // Source-IP changes are reflected in status/UI only; route config is static.
});
}, },
getClientDirectTargets: (targetProfileIds: string[]) => { getClientDirectTargets: (targetProfileIds: string[]) => {
if (!this.targetProfileManager) return []; if (!this.targetProfileManager) return [];
return this.targetProfileManager.getDirectTargetIps(targetProfileIds); return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
}, },
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, sourceIp?: string) => { getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, _sourceIp?: string) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24'; const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]); const ips = new Set<string>([subnet]);
@@ -2479,7 +2477,6 @@ export class DcRouter {
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec( const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, targetProfileIds,
allRoutes, allRoutes,
sourceIp,
); );
// Add target IPs directly // Add target IPs directly
@@ -2506,7 +2503,7 @@ export class DcRouter {
await this.vpnManager.start(); await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes // Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
// get correct profile-based ipAllowLists // get correct profile-based VPN client grants.
await this.routeConfigManager?.applyRoutes(); await this.routeConfigManager?.applyRoutes();
} }
@@ -2602,7 +2599,7 @@ export class DcRouter {
this.options.vpnConfig = config; this.options.vpnConfig = config;
this.vpnDomainIpCache.clear(); this.vpnDomainIpCache.clear();
this.warnedWildcardVpnDomains.clear(); this.warnedWildcardVpnDomains.clear();
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver()); this.routeConfigManager?.setVpnClientAccessResolver(this.createVpnClientAccessResolver());
if (this.options.vpnConfig?.enabled) { if (this.options.vpnConfig?.enabled) {
await this.setupVpnServer(); await this.setupVpnServer();
+24 -32
View File
@@ -11,8 +11,7 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js'; import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js'; import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */ export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
export type TIpAllowEntry = string | { ip: string; domains: string[] };
export interface IRouteMutationResult { export interface IRouteMutationResult {
success: boolean; success: boolean;
@@ -57,7 +56,7 @@ export class RouteConfigManager {
constructor( constructor(
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined, private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[], private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
private referenceResolver?: ReferenceResolver, private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[], private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
@@ -73,10 +72,10 @@ export class RouteConfigManager {
return this.routes.get(id); return this.routes.get(id);
} }
public setVpnClientIpsResolver( public setVpnClientAccessResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[], resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
): void { ): void {
this.getVpnClientIpsForRoute = resolver; this.getVpnClientAccessForRoute = resolver;
} }
/** /**
@@ -608,49 +607,42 @@ export class RouteConfigManager {
routeId?: string, routeId?: string,
): plugins.smartproxy.IRouteConfig { ): plugins.smartproxy.IRouteConfig {
const dcRoute = route as IDcRouterRouteConfig; const dcRoute = route as IDcRouterRouteConfig;
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || []; const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
if (!dcRoute.vpnOnly) { if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
const existingAllowList = route.security?.ipAllowList;
if (!Array.isArray(existingAllowList) || existingAllowList.length === 0 || vpnEntries.length === 0) {
return route; return route;
} }
return { const existingVpnSecurity = route.security?.vpn || {};
...route, const mergedAllowedClients = this.mergeVpnClientAllowEntries(
security: { existingVpnSecurity.allowedClients || [],
...route.security, vpnEntries,
ipAllowList: this.mergeIpAllowEntries(existingAllowList as TIpAllowEntry[], vpnEntries), );
},
};
}
const existingBlockList = route.security?.ipBlockList || [];
const ipBlockList = vpnEntries.length
? existingBlockList
: [...new Set([...existingBlockList, '*'])];
return { return {
...route, ...route,
security: { security: {
...route.security, ...route.security,
ipAllowList: vpnEntries, vpn: {
ipBlockList, ...existingVpnSecurity,
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
allowedClients: mergedAllowedClients,
},
}, },
}; };
} }
private mergeIpAllowEntries( private mergeVpnClientAllowEntries(
existingEntries: TIpAllowEntry[], existingEntries: TVpnClientAllowEntry[],
vpnEntries: TIpAllowEntry[], vpnEntries: TVpnClientAllowEntry[],
): TIpAllowEntry[] { ): TVpnClientAllowEntry[] {
const merged: TIpAllowEntry[] = []; const merged: TVpnClientAllowEntry[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const entry of [...existingEntries, ...vpnEntries]) { for (const entry of [...existingEntries, ...vpnEntries]) {
const key = typeof entry === 'string' const key = typeof entry === 'string'
? `ip:${entry}` ? `client:${entry}`
: `domain:${entry.ip}:${[...entry.domains].sort().join(',')}`; : `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
merged.push(entry); merged.push(entry);
+21 -211
View File
@@ -5,7 +5,7 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.js'; import type { IRoute } from '../../ts_interfaces/data/route-management.js';
type TIpAllowEntry = string | { ip: string; domains?: string[] }; type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
/** /**
* Manages TargetProfiles (target-side: what can be accessed). * Manages TargetProfiles (target-side: what can be accessed).
@@ -206,37 +206,35 @@ export class TargetProfileManager {
} }
// ========================================================================= // =========================================================================
// Core matching: route → client IPs // Core matching: route → VPN client grants
// ========================================================================= // =========================================================================
/** /**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile * Find all enabled VPN clients whose assigned TargetProfile matches the route.
* matches the route. Returns IP allow entries for injection into ipAllowList. * Returns SmartProxy VPN client allow entries for authenticated metadata checks.
* *
* Entries are domain-scoped when a profile matches via specific domains that are * Entries are domain-scoped when a profile matches via specific domains that are
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches * a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
* or when profile domains exactly equal the route's domains. Profiles can also opt * or when profile domains exactly equal the route's domains. Profiles can also opt
* into source-IP matching against non-vpnOnly route security. * into source-policy routes; SmartProxy evaluates the real source IP per connection.
*/ */
public getMatchingClientIps( public getMatchingVpnClients(
route: IDcRouterRouteConfig, route: IDcRouterRouteConfig,
routeId: string | undefined, routeId: string | undefined,
clients: VpnClientDoc[], clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(), allRoutes: Map<string, IRoute> = new Map(),
clientSourceIps: Map<string, string> = new Map(), ): TVpnClientAllowEntry[] {
): Array<string | { ip: string; domains: string[] }> { const entries: TVpnClientAllowEntry[] = [];
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains = this.getRouteDomains(route); const routeDomains = this.getRouteDomains(route);
const routeNameIndex = this.buildRouteNameIndex(allRoutes); const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) { for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue; if (!client.enabled || !client.clientId) continue;
if (!client.targetProfileIds?.length) continue; if (!client.targetProfileIds?.length) continue;
// Collect scoped domains from all matching profiles for this client // Collect scoped domains from all matching profiles for this client
let fullAccess = false; let fullAccess = false;
const scopedDomains = new Set<string>(); const scopedDomains = new Set<string>();
const clientSourceIp = clientSourceIps.get(client.clientId);
for (const profileId of client.targetProfileIds) { for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId); const profile = this.profiles.get(profileId);
@@ -258,10 +256,8 @@ export class TargetProfileManager {
} }
if ( if (
!route.vpnOnly profile.allowRoutesByClientSourceIp === true
&& profile.allowRoutesByClientSourceIp === true && this.routeHasSourcePolicy(route)
&& clientSourceIp
&& this.routeAllowsSourceIp(route, clientSourceIp, routeDomains)
) { ) {
fullAccess = true; fullAccess = true;
break; break;
@@ -269,9 +265,9 @@ export class TargetProfileManager {
} }
if (fullAccess) { if (fullAccess) {
entries.push(client.assignedIp); entries.push(client.clientId);
} else if (scopedDomains.size > 0) { } else if (scopedDomains.size > 0) {
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] }); entries.push({ clientId: client.clientId, domains: [...scopedDomains] });
} }
} }
@@ -285,7 +281,6 @@ export class TargetProfileManager {
public getClientAccessSpec( public getClientAccessSpec(
targetProfileIds: string[], targetProfileIds: string[],
allRoutes: Map<string, IRoute>, allRoutes: Map<string, IRoute>,
clientSourceIp?: string,
): { domains: string[]; targetIps: string[] } { ): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>(); const domains = new Set<string>();
const targetIps = new Set<string>(); const targetIps = new Set<string>();
@@ -322,9 +317,7 @@ export class TargetProfileManager {
routeNameIndex, routeNameIndex,
); );
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& clientSourceIp && this.routeHasSourcePolicy(dcRoute);
&& !dcRoute.vpnOnly
&& this.routeAllowsSourceIp(dcRoute, clientSourceIp, routeDomains);
if (profileMatchesRoute || sourceIpMatchesRoute) { if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) { for (const d of routeDomains) {
domains.add(d); domains.add(d);
@@ -450,197 +443,14 @@ export class TargetProfileManager {
return false; return false;
} }
private routeAllowsSourceIp( private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
route: IDcRouterRouteConfig,
sourceIp: string,
routeDomains: string[],
): boolean {
const security = (route as any).security; const security = (route as any).security;
const ipAllowList = this.normalizeIpEntries(security?.ipAllowList); const blockEntries = Array.isArray(security?.ipBlockList)
const ipBlockList = this.normalizeIpEntries(security?.ipBlockList); ? security.ipBlockList
: security?.ipBlockList
if (this.ipEntriesMatchSource(ipBlockList, sourceIp, routeDomains)) { ? [security.ipBlockList]
return false; : [];
} return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
if (!ipAllowList.length) {
return true;
}
return this.ipEntriesMatchSource(ipAllowList, sourceIp, routeDomains);
}
private normalizeIpEntries(entries: unknown): TIpAllowEntry[] {
if (!entries) return [];
if (Array.isArray(entries)) return entries as TIpAllowEntry[];
return [entries as TIpAllowEntry];
}
private ipEntriesMatchSource(
entries: TIpAllowEntry[],
sourceIp: string,
routeDomains: string[],
): boolean {
return entries.some((entry) => this.ipEntryMatchesSource(entry, sourceIp, routeDomains));
}
private ipEntryMatchesSource(
entry: TIpAllowEntry,
sourceIp: string,
routeDomains: string[],
): boolean {
const ipPattern = typeof entry === 'string' ? entry : entry.ip;
if (typeof ipPattern !== 'string') return false;
if (!this.ipPatternMatchesSource(ipPattern, sourceIp)) {
return false;
}
if (typeof entry === 'string' || !entry.domains?.length) {
return true;
}
if (!routeDomains.length) {
return false;
}
return routeDomains.some((routeDomain) =>
entry.domains!.some((entryDomain) =>
this.domainMatchesPattern(routeDomain, entryDomain)
|| this.domainMatchesPattern(entryDomain, routeDomain),
),
);
}
private ipPatternMatchesSource(pattern: string, sourceIp: string): boolean {
const trimmedPattern = pattern.trim();
const trimmedSourceIp = sourceIp.trim();
if (!trimmedPattern || !trimmedSourceIp) return false;
if (trimmedPattern === '*') return true;
if (trimmedPattern === trimmedSourceIp) return true;
if (trimmedPattern.includes('/')) {
return this.ipMatchesCidr(trimmedSourceIp, trimmedPattern);
}
if (trimmedPattern.includes('-')) {
return this.ipMatchesRange(trimmedSourceIp, trimmedPattern);
}
if (trimmedPattern.includes('*')) {
return this.ipMatchesWildcard(trimmedSourceIp, trimmedPattern);
}
return false;
}
private ipMatchesCidr(sourceIp: string, cidr: string): boolean {
const [networkIp, prefixString] = cidr.split('/');
if (!networkIp || !prefixString) return false;
const source = this.ipToComparable(sourceIp);
const network = this.ipToComparable(networkIp);
const prefix = Number(prefixString);
if (!source || !network || source.version !== network.version) return false;
const bitCount = source.version === 4 ? 32 : 128;
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bitCount) return false;
if (prefix === 0) return true;
const shift = BigInt(bitCount - prefix);
return (source.value >> shift) === (network.value >> shift);
}
private ipMatchesRange(sourceIp: string, range: string): boolean {
const [startIp, endIp] = range.split('-').map((part) => part.trim());
if (!startIp || !endIp) return false;
const source = this.ipToComparable(sourceIp);
const start = this.ipToComparable(startIp);
const end = this.ipToComparable(endIp);
if (!source || !start || !end) return false;
if (source.version !== start.version || source.version !== end.version) return false;
return source.value >= start.value && source.value <= end.value;
}
private ipMatchesWildcard(sourceIp: string, pattern: string): boolean {
const sourceParts = sourceIp.split('.');
const patternParts = pattern.split('.');
if (sourceParts.length !== 4 || patternParts.length !== 4) return false;
return patternParts.every((patternPart, index) => {
if (patternPart === '*') return true;
return patternPart === sourceParts[index];
});
}
private ipToComparable(ip: string): { version: 4 | 6; value: bigint } | undefined {
const normalizedIp = this.normalizeIpLiteral(ip);
const ipVersion = plugins.net.isIP(normalizedIp);
if (ipVersion === 4) {
const parts = normalizedIp.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return undefined;
}
return {
version: 4,
value: parts.reduce((value, part) => (value << 8n) + BigInt(part), 0n),
};
}
if (ipVersion === 6) {
const parts = this.expandIpv6(normalizedIp);
if (!parts) return undefined;
return {
version: 6,
value: parts.reduce((value, part) => (value << 16n) + BigInt(part), 0n),
};
}
return undefined;
}
private normalizeIpLiteral(ip: string): string {
const trimmed = ip.trim().replace(/^\[|\]$/g, '');
const zoneIndex = trimmed.indexOf('%');
const withoutZone = zoneIndex === -1 ? trimmed : trimmed.slice(0, zoneIndex);
const ipv4MappedPrefix = '::ffff:';
if (withoutZone.toLowerCase().startsWith(ipv4MappedPrefix)) {
const mappedIpv4 = withoutZone.slice(ipv4MappedPrefix.length);
if (plugins.net.isIP(mappedIpv4) === 4) return mappedIpv4;
}
return withoutZone;
}
private expandIpv6(ip: string): number[] | undefined {
let normalizedIp = ip.toLowerCase();
if (normalizedIp.includes('.')) {
const lastColonIndex = normalizedIp.lastIndexOf(':');
const ipv4Part = normalizedIp.slice(lastColonIndex + 1);
const ipv4Comparable = this.ipToComparable(ipv4Part);
if (!ipv4Comparable || ipv4Comparable.version !== 4) return undefined;
const high = Number((ipv4Comparable.value >> 16n) & 0xffffn).toString(16);
const low = Number(ipv4Comparable.value & 0xffffn).toString(16);
normalizedIp = `${normalizedIp.slice(0, lastColonIndex)}:${high}:${low}`;
}
const doubleColonParts = normalizedIp.split('::');
if (doubleColonParts.length > 2) return undefined;
const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : [];
const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : [];
const missingCount = 8 - head.length - tail.length;
if (missingCount < 0 || (doubleColonParts.length === 1 && missingCount !== 0)) return undefined;
const parts = [
...head,
...Array(missingCount).fill('0'),
...tail,
];
if (parts.length !== 8) return undefined;
const numbers = parts.map((part) => Number.parseInt(part || '0', 16));
if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
return undefined;
}
return numbers;
} }
private getRouteDomains(route: IDcRouterRouteConfig): string[] { private getRouteDomains(route: IDcRouterRouteConfig): string[] {
+2
View File
@@ -152,6 +152,8 @@ export class VpnManager {
wgListenPort, wgListenPort,
clients: clientEntries, clients: clientEntries,
socketForwardProxyProtocol: !isBridge, socketForwardProxyProtocol: !isBridge,
socketForwardProxyProtocolSource: 'remoteIp',
socketForwardProxyProtocolVpnMetadata: true,
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy), destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
serverEndpoint, serverEndpoint,
clientAllowedIPs: [subnet], clientAllowedIPs: [subnet],
@@ -97,7 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
'Route Refs': profile.routeRefs?.length 'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}` ? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
: '-', : '-',
'Client Source IP Routes': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No', 'Source-Policy Route Grants': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No',
Created: new Date(profile.createdAt).toLocaleDateString(), Created: new Date(profile.createdAt).toLocaleDateString(),
})} })}
.dataActions=${[ .dataActions=${[
@@ -224,7 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list> <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list> <dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list> <dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow routes by VPN client source IP'} .description=${'Also grant access to non-VPN-only routes that would allow the client\'s real connecting IP'} .value=${false}></dees-input-checkbox> <dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${false}></dees-input-checkbox>
</dees-form> </dees-form>
`, `,
menuOptions: [ menuOptions: [
@@ -287,7 +287,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list> <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list> <dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list> <dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow routes by VPN client source IP'} .description=${'Also grant access to non-VPN-only routes that would allow the client\'s real connecting IP'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox> <dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox>
</dees-form> </dees-form>
`, `,
menuOptions: [ menuOptions: [