Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2a10bdc3c | |||
| 42a5f6df7b | |||
| c61d832b43 | |||
| 872a822ed7 | |||
| 34bfd1528b | |||
| be38808795 | |||
| b9ae4ac344 | |||
| 37adcc9ddc |
@@ -3,6 +3,53 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-29 - 13.36.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- preserve parallel ACME DNS-01 TXT challenges and consume case-insensitive DNS matching (dns,certificates)
|
||||
- Keep exact and wildcard SAN challenge TXT records at the same owner name instead of deleting sibling challenge values.
|
||||
- Match local dcrouter-hosted DNS records case-insensitively so DNS 0x20 mixed-case queries keep resolving.
|
||||
- Update @push.rocks/smartdns to 7.9.3 for case-insensitive handler matching in the embedded DNS server.
|
||||
- preserve parallel ACME TXT challenges and mixed-case DNS queries (dns)
|
||||
- Remove only matching ACME DNS-01 TXT challenge values during setup and cleanup so parallel challenges can coexist.
|
||||
- Resolve locally hosted DNS records case-insensitively while preserving the query name casing in responses.
|
||||
- Bump @push.rocks/smartdns to ^7.9.3.
|
||||
|
||||
## 2026-05-28 - 13.36.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- consume RemoteIngress 4.18.0 tunnel performance improvements (remoteingress)
|
||||
- Update @serve.zone/remoteingress to 4.18.0 so DcRouter uses zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix.
|
||||
- bump @serve.zone/remoteingress to ^4.18.0 (remoteingress)
|
||||
- Updates @serve.zone/remoteingress from ^4.17.1 to ^4.18.0.
|
||||
- Consumes zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix from RemoteIngress.
|
||||
|
||||
## 2026-05-28 - 13.36.0
|
||||
|
||||
### Features
|
||||
|
||||
- add top connected ASN activity to Network Activity (network)
|
||||
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
|
||||
- Expose ASN activity through network stats and combined metrics APIs.
|
||||
- Add a Network Activity table with ASN and organization block actions.
|
||||
- Add MetricsManager coverage for ASN aggregation.
|
||||
- add top connected ASN activity to network monitoring (network)
|
||||
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
|
||||
- Expose top ASN activity through network stats and combined metrics API responses.
|
||||
- Add a Network Activity table for top ASNs with ASN and organization block actions.
|
||||
- Add MetricsManager coverage for ASN aggregation.
|
||||
|
||||
## 2026-05-24 - 13.35.0
|
||||
|
||||
### 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
|
||||
|
||||
### Features
|
||||
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.34.0",
|
||||
"version": "13.36.2",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -22,13 +22,13 @@
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.1",
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@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/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@types/node": "^25.9.0"
|
||||
"@types/node": "^25.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.1",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@push.rocks/smartacme": "^9.5.0",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartdb": "^2.10.1",
|
||||
"@push.rocks/smartdns": "^7.9.2",
|
||||
"@push.rocks/smartdns": "^7.9.3",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.2",
|
||||
@@ -56,17 +56,17 @@
|
||||
"@push.rocks/smartnetwork": "^4.7.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@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/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.1",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.4",
|
||||
"@push.rocks/smartvpn": "1.20.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.8.0",
|
||||
"@serve.zone/remoteingress": "^4.17.1",
|
||||
"@serve.zone/remoteingress": "^4.18.0",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.4.0",
|
||||
|
||||
Generated
+49
-49
@@ -51,8 +51,8 @@ importers:
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1(@tiptap/pm@2.27.2)(socks@2.8.8)
|
||||
'@push.rocks/smartdns':
|
||||
specifier: ^7.9.2
|
||||
version: 7.9.2
|
||||
specifier: ^7.9.3
|
||||
version: 7.9.3
|
||||
'@push.rocks/smartfs':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
@@ -84,8 +84,8 @@ importers:
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.10.3
|
||||
version: 27.10.3
|
||||
specifier: ^27.11.0
|
||||
version: 27.11.0
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2
|
||||
@@ -102,8 +102,8 @@ importers:
|
||||
specifier: ^3.0.9
|
||||
version: 3.0.9
|
||||
'@push.rocks/smartvpn':
|
||||
specifier: 1.19.4
|
||||
version: 1.19.4
|
||||
specifier: 1.20.0
|
||||
version: 1.20.0
|
||||
'@push.rocks/taskbuffer':
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
@@ -114,8 +114,8 @@ importers:
|
||||
specifier: ^5.8.0
|
||||
version: 5.8.0
|
||||
'@serve.zone/remoteingress':
|
||||
specifier: ^4.17.1
|
||||
version: 4.17.1
|
||||
specifier: ^4.18.0
|
||||
version: 4.18.0
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
@@ -133,14 +133,14 @@ importers:
|
||||
version: 14.0.0
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
'@git.zone/tsbundle':
|
||||
specifier: ^2.10.4
|
||||
version: 2.10.4
|
||||
'@git.zone/tsdocker':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0
|
||||
'@git.zone/tsrun':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
@@ -151,8 +151,8 @@ importers:
|
||||
specifier: ^3.3.5
|
||||
version: 3.3.5(@tiptap/pm@2.27.2)
|
||||
'@types/node':
|
||||
specifier: ^25.9.0
|
||||
version: 25.9.0
|
||||
specifier: ^25.9.1
|
||||
version: 25.9.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -718,16 +718,16 @@ packages:
|
||||
resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
'@git.zone/tsbuild@4.4.1':
|
||||
resolution: {integrity: sha512-usxx8BBQsAypxjFOfd1GEV9pL9EUshRKktXtRWHMDByb6ps83+PdUIb3D7O+nkkBp4C9PXo3cfbsR4Asvo33CA==}
|
||||
'@git.zone/tsbuild@4.4.2':
|
||||
resolution: {integrity: sha512-v2m0fFYFt3vJZMvNAlrNChHYjZZNOf4iyO0mNNiHeO+sTR3cddkYb++zO/GL3v2UkG3nDRwfEkwUS4UzuXBEWw==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsbundle@2.10.4':
|
||||
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsdocker@2.3.0':
|
||||
resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==}
|
||||
'@git.zone/tsdocker@2.4.0':
|
||||
resolution: {integrity: sha512-GFE93RxFm8HDrSm5Ulggy4se7heb4GaNQgaWV6Mds6lhkm6GouO91xZYlmXVH9glzBoFJNG63pFXYHW6nrqf5A==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tspublish@1.11.6':
|
||||
@@ -1285,8 +1285,8 @@ packages:
|
||||
'@push.rocks/smartdelay@3.1.0':
|
||||
resolution: {integrity: sha512-59xveBMbWmbFhh/rqhQnYG/klg/VONG9hV8+RQ7ftqsNRkcmUT+VM5etAbODgAUvsF4lxK+xVR0tbZOo0kGhRQ==}
|
||||
|
||||
'@push.rocks/smartdns@7.9.2':
|
||||
resolution: {integrity: sha512-joMroNy/1YjXjxUaW38HQTvlyRHETE2+vnKg1c1304gHqcThyRawtdcnQsvmoK9sO1ZaPAqBKL1QP9m87nCFYQ==}
|
||||
'@push.rocks/smartdns@7.9.3':
|
||||
resolution: {integrity: sha512-TkqDmYeO0ogIICWIM06hE/SeNpyASsqr7d+HJv8u3FyD2jRP9LHn0X0o8CjSJ+IoTHSNXFBDFrddyysFdnwSsg==}
|
||||
|
||||
'@push.rocks/smartenv@5.0.13':
|
||||
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
|
||||
@@ -1422,8 +1422,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.4':
|
||||
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
|
||||
|
||||
'@push.rocks/smartproxy@27.10.3':
|
||||
resolution: {integrity: sha512-2TvjgXUHtV0s8WH2RbtCS5+yjnFjbvQQ2ROmtVme1lgt2GUaAbekozUJNTE1ZMLEXc4xcZRdXIOfgBcQ6j/dmQ==}
|
||||
'@push.rocks/smartproxy@27.11.0':
|
||||
resolution: {integrity: sha512-ruyUMbrk28BTtrhcZpB5fX35FRQyyhJgVd7snPFa3Zttw0N8ahYrwKXpKfuagvOcaIpORMQoyR5WSv0C2ATFVA==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.6':
|
||||
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
|
||||
@@ -1482,8 +1482,8 @@ packages:
|
||||
'@push.rocks/smartversion@3.1.0':
|
||||
resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==}
|
||||
|
||||
'@push.rocks/smartvpn@1.19.4':
|
||||
resolution: {integrity: sha512-Cp6yyzRcZlqQMEWAQ/CG2tvUxSR4eSmzMTDQFVJsPtV+CbhXpulbqqz0penU6drVMiRGzXhwoQZtGYynigIXwA==}
|
||||
'@push.rocks/smartvpn@1.20.0':
|
||||
resolution: {integrity: sha512-k5cdbHGtCUMcZTwJr+7BwXNFxbeXZEe5MZ00y/f2Isi8yLAdfmdBJ5o32vwR0LJvWm2ZFn7ST8S1AkCY/K9L3w==}
|
||||
|
||||
'@push.rocks/smartwatch@6.4.0':
|
||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||
@@ -1712,8 +1712,8 @@ packages:
|
||||
'@serve.zone/interfaces@5.8.0':
|
||||
resolution: {integrity: sha512-0ekSKUL/b44wmmzuCRANzrjaJRAHtkqiL8cPiMASEs7UJBDqbJCrgtrlJK84pz5dxBz3jTcdznNd5qjB8c6H0A==}
|
||||
|
||||
'@serve.zone/remoteingress@4.17.1':
|
||||
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
|
||||
'@serve.zone/remoteingress@4.18.0':
|
||||
resolution: {integrity: sha512-/cW9wb/e57u9+715RzV5d8HCezWtR88LcpistTNSl7GACi5ai+C2tPy7ZQprnnrNhqjfgzWiAH4bKZafwONntg==}
|
||||
|
||||
'@smithy/chunked-blob-reader-native@4.2.3':
|
||||
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
|
||||
@@ -2158,8 +2158,8 @@ packages:
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/node@25.9.0':
|
||||
resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==}
|
||||
'@types/node@25.9.1':
|
||||
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
@@ -5194,7 +5194,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@fortawesome/fontawesome-common-types': 7.2.0
|
||||
|
||||
'@git.zone/tsbuild@4.4.1':
|
||||
'@git.zone/tsbuild@4.4.2':
|
||||
dependencies:
|
||||
'@git.zone/tspublish': 1.11.6
|
||||
'@push.rocks/early': 4.0.4
|
||||
@@ -5243,7 +5243,7 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@git.zone/tsdocker@2.3.0':
|
||||
'@git.zone/tsdocker@2.4.0':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/projectinfo': 5.1.0
|
||||
@@ -5370,7 +5370,7 @@ snapshots:
|
||||
|
||||
'@happy-dom/global-registrator@20.9.0':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
happy-dom: 20.9.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -6113,7 +6113,7 @@ snapshots:
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartdns': 7.9.2
|
||||
'@push.rocks/smartdns': 7.9.3
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartnetwork': 4.7.2
|
||||
'@push.rocks/smartstring': 4.1.1
|
||||
@@ -6323,7 +6323,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
|
||||
'@push.rocks/smartdns@7.9.2':
|
||||
'@push.rocks/smartdns@7.9.3':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartenv': 6.1.0
|
||||
@@ -6474,7 +6474,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartmail@2.2.1':
|
||||
dependencies:
|
||||
'@push.rocks/smartdns': 7.9.2
|
||||
'@push.rocks/smartdns': 7.9.3
|
||||
'@push.rocks/smartfile': 13.1.3
|
||||
'@push.rocks/smartmustache': 3.0.2
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
@@ -6593,7 +6593,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartnetwork@4.7.2':
|
||||
dependencies:
|
||||
'@push.rocks/smartdns': 7.9.2
|
||||
'@push.rocks/smartdns': 7.9.3
|
||||
'@push.rocks/smartrust': 1.4.0
|
||||
maxmind: 5.0.6
|
||||
transitivePeerDependencies:
|
||||
@@ -6675,7 +6675,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.4': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.10.3':
|
||||
'@push.rocks/smartproxy@27.11.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
@@ -6822,7 +6822,7 @@ snapshots:
|
||||
'@types/semver': 7.7.1
|
||||
semver: 7.7.4
|
||||
|
||||
'@push.rocks/smartvpn@1.19.4':
|
||||
'@push.rocks/smartvpn@1.20.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartnftables': 1.2.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
@@ -7064,7 +7064,7 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/remoteingress@4.17.1':
|
||||
'@serve.zone/remoteingress@4.18.0':
|
||||
dependencies:
|
||||
'@push.rocks/qenv': 6.1.4
|
||||
'@push.rocks/smartnftables': 1.2.0
|
||||
@@ -7583,7 +7583,7 @@ snapshots:
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
@@ -7611,7 +7611,7 @@ snapshots:
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
@@ -7632,16 +7632,16 @@ snapshots:
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/node@16.9.1': {}
|
||||
|
||||
@@ -7653,13 +7653,13 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.9.0':
|
||||
'@types/node@25.9.1':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/randomatic@3.1.5': {}
|
||||
|
||||
@@ -7671,7 +7671,7 @@ snapshots:
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -7699,11 +7699,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
@@ -8423,7 +8423,7 @@ snapshots:
|
||||
|
||||
happy-dom@20.9.0:
|
||||
dependencies:
|
||||
'@types/node': 25.9.0
|
||||
'@types/node': 25.9.1
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
entities: 7.0.1
|
||||
|
||||
@@ -198,9 +198,9 @@ await router.start();
|
||||
|
||||
## 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
|
||||
const targetProfile = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
|
||||
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
|
||||
import { DcRouterDb, DnsRecordDoc, DomainDoc, RouteDoc } from '../ts/db/index.js';
|
||||
import { DnsManager } from '../ts/dns/manager.dns.js';
|
||||
import { logger } from '../ts/logger.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
@@ -32,6 +32,9 @@ const createTestDb = async () => {
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearTestState = async () => {
|
||||
for (const record of await DnsRecordDoc.findAll()) {
|
||||
await record.delete();
|
||||
}
|
||||
for (const route of await RouteDoc.findAll()) {
|
||||
await route.delete();
|
||||
}
|
||||
@@ -40,6 +43,86 @@ const clearTestState = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('DnsManager keeps parallel ACME TXT challenges for the same host', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const now = Date.now();
|
||||
const domain = new DomainDoc();
|
||||
domain.id = 'central-eu';
|
||||
domain.name = 'central.eu';
|
||||
domain.source = 'dcrouter';
|
||||
domain.authoritative = true;
|
||||
domain.createdAt = now;
|
||||
domain.updatedAt = now;
|
||||
domain.createdBy = 'test';
|
||||
await domain.save();
|
||||
|
||||
const dnsManager = new DnsManager({});
|
||||
const provider = dnsManager.buildAcmeConvenientDnsProvider().convenience as any;
|
||||
const hostName = '_acme-challenge.blog.central.eu';
|
||||
|
||||
await provider.acmeSetDnsChallenge({ hostName, challenge: 'first-token' });
|
||||
await provider.acmeSetDnsChallenge({ hostName, challenge: 'second-token' });
|
||||
|
||||
const recordsAfterSet = await DnsRecordDoc.findByDomainId(domain.id);
|
||||
expect(recordsAfterSet.map((record) => record.value).sort()).toEqual([
|
||||
'first-token',
|
||||
'second-token',
|
||||
]);
|
||||
|
||||
await provider.acmeRemoveDnsChallenge({ hostName, challenge: 'first-token' });
|
||||
|
||||
const recordsAfterRemove = await DnsRecordDoc.findByDomainId(domain.id);
|
||||
expect(recordsAfterRemove.map((record) => record.value)).toEqual(['second-token']);
|
||||
});
|
||||
|
||||
tap.test('DnsManager local records answer mixed-case DNS queries', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const now = Date.now();
|
||||
const domain = new DomainDoc();
|
||||
domain.id = 'central-eu';
|
||||
domain.name = 'central.eu';
|
||||
domain.source = 'dcrouter';
|
||||
domain.authoritative = true;
|
||||
domain.createdAt = now;
|
||||
domain.updatedAt = now;
|
||||
domain.createdBy = 'test';
|
||||
await domain.save();
|
||||
|
||||
const registeredHandlers: Array<(question: { name: string; type: string }) => any> = [];
|
||||
const dnsManager = new DnsManager({});
|
||||
dnsManager.dnsServer = {
|
||||
registerHandler: (_name: string, _types: string[], handler: (question: { name: string; type: string }) => any) => {
|
||||
registeredHandlers.push(handler);
|
||||
},
|
||||
} as any;
|
||||
|
||||
await dnsManager.createRecord({
|
||||
domainId: domain.id,
|
||||
name: '_acme-challenge.central.eu',
|
||||
type: 'TXT',
|
||||
value: 'challenge-token',
|
||||
ttl: 120,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const answer = registeredHandlers[0]?.({
|
||||
name: '_aCMe-challeNge.Central.Eu',
|
||||
type: 'txt',
|
||||
});
|
||||
|
||||
expect(answer).toEqual({
|
||||
name: '_aCMe-challeNge.Central.Eu',
|
||||
type: 'TXT',
|
||||
class: 'IN',
|
||||
ttl: 120,
|
||||
data: 'challenge-token',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
@@ -269,6 +269,7 @@ tap.test('MetricsManager queues IP intelligence without awaiting enrichment', as
|
||||
},
|
||||
securityPolicyManager: {
|
||||
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
|
||||
listIpIntelligence: async () => [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
@@ -279,4 +280,50 @@ tap.test('MetricsManager queues IP intelligence without awaiting enrichment', as
|
||||
expect(queuedIps[0]).toContain('1.1.1.1');
|
||||
});
|
||||
|
||||
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map(),
|
||||
throughputByRoute: new Map(),
|
||||
domainRequestsByIP: new Map(),
|
||||
connectionsByIP: new Map([
|
||||
['8.8.8.8', 4],
|
||||
['8.8.4.4', 3],
|
||||
['1.1.1.1', 5],
|
||||
]),
|
||||
throughputByIP: new Map([
|
||||
['8.8.8.8', { in: 500, out: 250 }],
|
||||
['8.8.4.4', { in: 700, out: 350 }],
|
||||
['1.1.1.1', { in: 2000, out: 1000 }],
|
||||
]),
|
||||
});
|
||||
|
||||
const manager = new MetricsManager({
|
||||
smartProxy: {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: { getRoutes: () => [] },
|
||||
},
|
||||
securityPolicyManager: {
|
||||
queueObservedIps: () => undefined,
|
||||
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
|
||||
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
|
||||
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
|
||||
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
|
||||
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const stats = await manager.getNetworkStats();
|
||||
|
||||
expect(stats.topASNs).toHaveLength(2);
|
||||
expect(stats.topASNs[0].asn).toEqual(15169);
|
||||
expect(stats.topASNs[0].organization).toEqual('Google LLC');
|
||||
expect(stats.topASNs[0].activeConnections).toEqual(7);
|
||||
expect(stats.topASNs[0].ipCount).toEqual(2);
|
||||
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
|
||||
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
|
||||
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
|
||||
expect(stats.topASNs[1].asn).toEqual(13335);
|
||||
expect(stats.topASNs[1].activeConnections).toEqual(5);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -77,7 +77,7 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
|
||||
},
|
||||
} as any;
|
||||
(dcRouter as any).routeConfigManager = {
|
||||
setVpnClientIpsResolver: (resolver: unknown) => {
|
||||
setVpnClientAccessResolver: (resolver: unknown) => {
|
||||
resolverValues.push(resolver);
|
||||
},
|
||||
applyRoutes: async () => {
|
||||
@@ -121,15 +121,15 @@ tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN client
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual([]);
|
||||
expect(prepared.security.ipBlockList).toContain('*');
|
||||
expect(prepared.security.ipAllowList).toEqual(['*']);
|
||||
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(
|
||||
() => undefined,
|
||||
undefined,
|
||||
() => ['10.8.0.2'],
|
||||
() => ['client-1'],
|
||||
);
|
||||
const 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);
|
||||
|
||||
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.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
undefined,
|
||||
() => ['10.8.0.2'],
|
||||
() => ['client-1'],
|
||||
);
|
||||
const 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);
|
||||
|
||||
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.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingClientIps(
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'hagen-app',
|
||||
match: { domains: 'app.hagen.team', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
} as any,
|
||||
'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 () => {
|
||||
@@ -238,7 +240,7 @@ tap.test('TargetProfileManager allows source-IP reachable routes for opted-in pr
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingClientIps(
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'restricted-public-route',
|
||||
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',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
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();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
@@ -265,7 +266,7 @@ tap.test('TargetProfileManager does not allow non-matching client source IPs', a
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingClientIps(
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'restricted-public-route',
|
||||
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',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
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();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
@@ -292,20 +292,19 @@ tap.test('TargetProfileManager source-IP matching respects route block lists', a
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingClientIps(
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'blocked-route',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: {
|
||||
ipAllowList: ['203.0.113.0/24'],
|
||||
ipBlockList: ['203.0.113.10'],
|
||||
ipBlockList: ['*'],
|
||||
},
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
new Map([['client-1', '203.0.113.10']]),
|
||||
);
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
@@ -322,7 +321,7 @@ tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP rea
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingClientIps(
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'public-route',
|
||||
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',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
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();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
@@ -348,7 +346,7 @@ tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP m
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingClientIps(
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'vpn-only-route',
|
||||
vpnOnly: true,
|
||||
@@ -359,10 +357,9 @@ tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP m
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
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 () => {
|
||||
@@ -393,7 +390,7 @@ tap.test('TargetProfileManager includes source-IP reachable route domains in cli
|
||||
}],
|
||||
]) 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');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.34.0',
|
||||
version: '13.36.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+11
-14
@@ -26,7 +26,7 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, 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 { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
@@ -605,7 +605,7 @@ export class DcRouter {
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.createVpnRouteAllowListResolver(),
|
||||
this.createVpnClientAccessResolver(),
|
||||
this.referenceResolver,
|
||||
// Sync routes to RemoteIngressManager whenever routes change,
|
||||
// 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.
|
||||
*/
|
||||
private createVpnRouteAllowListResolver(): ((
|
||||
private createVpnClientAccessResolver(): ((
|
||||
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
|
||||
routeId?: string,
|
||||
) => TIpAllowEntry[]) | undefined {
|
||||
) => TVpnClientAllowEntry[]) | undefined {
|
||||
if (!this.options.vpnConfig?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -2416,12 +2416,11 @@ export class DcRouter {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
return this.targetProfileManager.getMatchingVpnClients(
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
this.vpnManager.getClientSourceIpMap(),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -2453,22 +2452,21 @@ export class DcRouter {
|
||||
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
|
||||
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||
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)
|
||||
this.routeConfigManager?.applyRoutes().catch((err) => {
|
||||
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
|
||||
});
|
||||
},
|
||||
onClientSourceIpsChanged: () => {
|
||||
this.routeConfigManager?.applyRoutes().catch((err) => {
|
||||
logger.log('warn', `Failed to re-apply routes after VPN client source IP change: ${err?.message || err}`);
|
||||
});
|
||||
// SmartProxy now receives the real source IP per connection via PROXY v2.
|
||||
// Source-IP changes are reflected in status/UI only; route config is static.
|
||||
},
|
||||
getClientDirectTargets: (targetProfileIds: string[]) => {
|
||||
if (!this.targetProfileManager) return [];
|
||||
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 ips = new Set<string>([subnet]);
|
||||
|
||||
@@ -2479,7 +2477,6 @@ export class DcRouter {
|
||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||
targetProfileIds,
|
||||
allRoutes,
|
||||
sourceIp,
|
||||
);
|
||||
|
||||
// Add target IPs directly
|
||||
@@ -2506,7 +2503,7 @@ export class DcRouter {
|
||||
await this.vpnManager.start();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -2602,7 +2599,7 @@ export class DcRouter {
|
||||
this.options.vpnConfig = config;
|
||||
this.vpnDomainIpCache.clear();
|
||||
this.warnedWildcardVpnDomains.clear();
|
||||
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver());
|
||||
this.routeConfigManager?.setVpnClientAccessResolver(this.createVpnClientAccessResolver());
|
||||
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
await this.setupVpnServer();
|
||||
|
||||
@@ -11,8 +11,7 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
||||
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
||||
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
|
||||
|
||||
export interface IRouteMutationResult {
|
||||
success: boolean;
|
||||
@@ -57,7 +56,7 @@ export class RouteConfigManager {
|
||||
constructor(
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
||||
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||
@@ -73,10 +72,10 @@ export class RouteConfigManager {
|
||||
return this.routes.get(id);
|
||||
}
|
||||
|
||||
public setVpnClientIpsResolver(
|
||||
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
public setVpnClientAccessResolver(
|
||||
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
|
||||
): void {
|
||||
this.getVpnClientIpsForRoute = resolver;
|
||||
this.getVpnClientAccessForRoute = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,49 +607,42 @@ export class RouteConfigManager {
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
|
||||
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
|
||||
|
||||
if (!dcRoute.vpnOnly) {
|
||||
const existingAllowList = route.security?.ipAllowList;
|
||||
if (!Array.isArray(existingAllowList) || existingAllowList.length === 0 || vpnEntries.length === 0) {
|
||||
return route;
|
||||
}
|
||||
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: this.mergeIpAllowEntries(existingAllowList as TIpAllowEntry[], vpnEntries),
|
||||
},
|
||||
};
|
||||
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
|
||||
return route;
|
||||
}
|
||||
|
||||
const existingBlockList = route.security?.ipBlockList || [];
|
||||
const ipBlockList = vpnEntries.length
|
||||
? existingBlockList
|
||||
: [...new Set([...existingBlockList, '*'])];
|
||||
const existingVpnSecurity = route.security?.vpn || {};
|
||||
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
|
||||
existingVpnSecurity.allowedClients || [],
|
||||
vpnEntries,
|
||||
);
|
||||
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: vpnEntries,
|
||||
ipBlockList,
|
||||
vpn: {
|
||||
...existingVpnSecurity,
|
||||
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
|
||||
allowedClients: mergedAllowedClients,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mergeIpAllowEntries(
|
||||
existingEntries: TIpAllowEntry[],
|
||||
vpnEntries: TIpAllowEntry[],
|
||||
): TIpAllowEntry[] {
|
||||
const merged: TIpAllowEntry[] = [];
|
||||
private mergeVpnClientAllowEntries(
|
||||
existingEntries: TVpnClientAllowEntry[],
|
||||
vpnEntries: TVpnClientAllowEntry[],
|
||||
): TVpnClientAllowEntry[] {
|
||||
const merged: TVpnClientAllowEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of [...existingEntries, ...vpnEntries]) {
|
||||
const key = typeof entry === 'string'
|
||||
? `ip:${entry}`
|
||||
: `domain:${entry.ip}:${[...entry.domains].sort().join(',')}`;
|
||||
? `client:${entry}`
|
||||
: `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(entry);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.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).
|
||||
@@ -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
|
||||
* matches the route. Returns IP allow entries for injection into ipAllowList.
|
||||
* Find all enabled VPN clients whose assigned TargetProfile matches the route.
|
||||
* Returns SmartProxy VPN client allow entries for authenticated metadata checks.
|
||||
*
|
||||
* 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
|
||||
* 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,
|
||||
routeId: string | undefined,
|
||||
clients: VpnClientDoc[],
|
||||
allRoutes: Map<string, IRoute> = new Map(),
|
||||
clientSourceIps: Map<string, string> = new Map(),
|
||||
): Array<string | { ip: string; domains: string[] }> {
|
||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||
): TVpnClientAllowEntry[] {
|
||||
const entries: TVpnClientAllowEntry[] = [];
|
||||
const routeDomains = this.getRouteDomains(route);
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
if (!client.enabled || !client.clientId) continue;
|
||||
if (!client.targetProfileIds?.length) continue;
|
||||
|
||||
// Collect scoped domains from all matching profiles for this client
|
||||
let fullAccess = false;
|
||||
const scopedDomains = new Set<string>();
|
||||
const clientSourceIp = clientSourceIps.get(client.clientId);
|
||||
|
||||
for (const profileId of client.targetProfileIds) {
|
||||
const profile = this.profiles.get(profileId);
|
||||
@@ -258,10 +256,8 @@ export class TargetProfileManager {
|
||||
}
|
||||
|
||||
if (
|
||||
!route.vpnOnly
|
||||
&& profile.allowRoutesByClientSourceIp === true
|
||||
&& clientSourceIp
|
||||
&& this.routeAllowsSourceIp(route, clientSourceIp, routeDomains)
|
||||
profile.allowRoutesByClientSourceIp === true
|
||||
&& this.routeHasSourcePolicy(route)
|
||||
) {
|
||||
fullAccess = true;
|
||||
break;
|
||||
@@ -269,9 +265,9 @@ export class TargetProfileManager {
|
||||
}
|
||||
|
||||
if (fullAccess) {
|
||||
entries.push(client.assignedIp);
|
||||
entries.push(client.clientId);
|
||||
} 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(
|
||||
targetProfileIds: string[],
|
||||
allRoutes: Map<string, IRoute>,
|
||||
clientSourceIp?: string,
|
||||
): { domains: string[]; targetIps: string[] } {
|
||||
const domains = new Set<string>();
|
||||
const targetIps = new Set<string>();
|
||||
@@ -322,9 +317,7 @@ export class TargetProfileManager {
|
||||
routeNameIndex,
|
||||
);
|
||||
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
|
||||
&& clientSourceIp
|
||||
&& !dcRoute.vpnOnly
|
||||
&& this.routeAllowsSourceIp(dcRoute, clientSourceIp, routeDomains);
|
||||
&& this.routeHasSourcePolicy(dcRoute);
|
||||
if (profileMatchesRoute || sourceIpMatchesRoute) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
@@ -450,197 +443,14 @@ export class TargetProfileManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
private routeAllowsSourceIp(
|
||||
route: IDcRouterRouteConfig,
|
||||
sourceIp: string,
|
||||
routeDomains: string[],
|
||||
): boolean {
|
||||
private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
|
||||
const security = (route as any).security;
|
||||
const ipAllowList = this.normalizeIpEntries(security?.ipAllowList);
|
||||
const ipBlockList = this.normalizeIpEntries(security?.ipBlockList);
|
||||
|
||||
if (this.ipEntriesMatchSource(ipBlockList, sourceIp, routeDomains)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
const blockEntries = Array.isArray(security?.ipBlockList)
|
||||
? security.ipBlockList
|
||||
: security?.ipBlockList
|
||||
? [security.ipBlockList]
|
||||
: [];
|
||||
return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
|
||||
}
|
||||
|
||||
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
|
||||
|
||||
+25
-8
@@ -209,9 +209,9 @@ export class DnsManager {
|
||||
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
|
||||
if (!this.dnsServer) return;
|
||||
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
|
||||
if (question.name === rec.name && question.type === rec.type) {
|
||||
if (question.name.toLowerCase() === rec.name.toLowerCase() && question.type.toUpperCase() === rec.type) {
|
||||
return {
|
||||
name: rec.name,
|
||||
name: question.name,
|
||||
type: rec.type,
|
||||
class: 'IN',
|
||||
ttl: rec.ttl,
|
||||
@@ -313,17 +313,23 @@ export class DnsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all DNS records matching a name and type under a domain.
|
||||
* Used for ACME challenge cleanup (may have multiple TXT records at the same name).
|
||||
* Delete DNS records matching a name and type under a domain.
|
||||
* When value is provided, only that exact record is removed so parallel ACME
|
||||
* challenges for the same host can coexist.
|
||||
*/
|
||||
public async deleteRecordsByNameAndType(
|
||||
domainId: string,
|
||||
name: string,
|
||||
type: TDnsRecordType,
|
||||
value?: string,
|
||||
): Promise<void> {
|
||||
const records = await DnsRecordDoc.findByDomainId(domainId);
|
||||
for (const rec of records) {
|
||||
if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
|
||||
if (
|
||||
rec.name.toLowerCase() === name.toLowerCase()
|
||||
&& rec.type === type
|
||||
&& (value === undefined || rec.value === value)
|
||||
) {
|
||||
await this.deleteRecord(rec.id);
|
||||
}
|
||||
}
|
||||
@@ -358,9 +364,15 @@ export class DnsManager {
|
||||
'Add the domain in Domains before issuing certificates.',
|
||||
);
|
||||
}
|
||||
// Clean leftover challenge records first to avoid duplicates.
|
||||
// Clean only the same challenge value. Exact + wildcard SAN orders can
|
||||
// legitimately need multiple TXT records at the same name.
|
||||
try {
|
||||
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||
await self.deleteRecordsByNameAndType(
|
||||
domainDoc.id,
|
||||
dnsChallenge.hostName,
|
||||
'TXT',
|
||||
dnsChallenge.challenge,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -381,7 +393,12 @@ export class DnsManager {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||
await self.deleteRecordsByNameAndType(
|
||||
domainDoc.id,
|
||||
dnsChallenge.hostName,
|
||||
'TXT',
|
||||
dnsChallenge.challenge,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DcRouter } from '../classes.dcrouter.js';
|
||||
import { MetricsCache } from './classes.metricscache.js';
|
||||
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { IAsnActivity } from '../../ts_interfaces/data/stats.js';
|
||||
|
||||
export class MetricsManager {
|
||||
private metricsLogger: plugins.smartlog.Smartlog;
|
||||
@@ -545,7 +546,7 @@ export class MetricsManager {
|
||||
// Get network metrics from SmartProxy
|
||||
public async getNetworkStats() {
|
||||
// Use shorter cache TTL for network stats to ensure real-time updates
|
||||
return this.metricsCache.get('networkStats', () => {
|
||||
return this.metricsCache.get('networkStats', async () => {
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
|
||||
if (!proxyMetrics) {
|
||||
@@ -554,6 +555,7 @@ export class MetricsManager {
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [] as Array<{ ip: string; count: number }>,
|
||||
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
|
||||
topASNs: [] as IAsnActivity[],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||
@@ -725,10 +727,15 @@ export class MetricsManager {
|
||||
.slice(0, 10)
|
||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||
|
||||
this.dcRouter.securityPolicyManager?.queueObservedIps([
|
||||
const observedIps = [...new Set([
|
||||
...connectionsByIP.keys(),
|
||||
...throughputByIP.keys(),
|
||||
...topIPs.map((item) => item.ip),
|
||||
...topIPsByBandwidth.map((item) => item.ip),
|
||||
]);
|
||||
])];
|
||||
this.dcRouter.securityPolicyManager?.queueObservedIps(observedIps);
|
||||
|
||||
const topASNs = await this.buildTopASNs(observedIps, allIPData);
|
||||
|
||||
// Build domain activity using per-IP domain request counts from Rust engine
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
@@ -872,6 +879,7 @@ export class MetricsManager {
|
||||
throughputRate,
|
||||
topIPs,
|
||||
topIPsByBandwidth,
|
||||
topASNs,
|
||||
totalDataTransferred,
|
||||
throughputHistory,
|
||||
throughputByIP,
|
||||
@@ -885,6 +893,60 @@ export class MetricsManager {
|
||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||
}
|
||||
|
||||
private async buildTopASNs(
|
||||
observedIps: string[],
|
||||
allIPData: Map<string, { count: number; bwIn: number; bwOut: number }>,
|
||||
): Promise<IAsnActivity[]> {
|
||||
const manager = this.dcRouter.securityPolicyManager;
|
||||
if (!manager || observedIps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const intelligenceRecords = await manager.listIpIntelligence({
|
||||
ipAddresses: observedIps,
|
||||
limit: Math.max(100, observedIps.length),
|
||||
});
|
||||
const asnActivity = new Map<number, IAsnActivity>();
|
||||
|
||||
for (const record of intelligenceRecords) {
|
||||
if (typeof record.asn !== 'number') continue;
|
||||
|
||||
const ipData = allIPData.get(record.ipAddress);
|
||||
if (!ipData) continue;
|
||||
|
||||
const existing = asnActivity.get(record.asn);
|
||||
const activity = existing || {
|
||||
asn: record.asn,
|
||||
organization: record.asnOrg || record.registrantOrg || `AS${record.asn}`,
|
||||
country: record.countryCode || record.country || record.registrantCountry || null,
|
||||
activeConnections: 0,
|
||||
ipCount: 0,
|
||||
bytesInPerSecond: 0,
|
||||
bytesOutPerSecond: 0,
|
||||
sampleIps: [],
|
||||
};
|
||||
|
||||
activity.activeConnections += ipData.count;
|
||||
activity.bytesInPerSecond += ipData.bwIn;
|
||||
activity.bytesOutPerSecond += ipData.bwOut;
|
||||
activity.ipCount++;
|
||||
if (activity.sampleIps.length < 5) {
|
||||
activity.sampleIps.push(record.ipAddress);
|
||||
}
|
||||
asnActivity.set(record.asn, activity);
|
||||
}
|
||||
|
||||
return [...asnActivity.values()]
|
||||
.sort((a, b) => {
|
||||
const connectionDiff = b.activeConnections - a.activeConnections;
|
||||
if (connectionDiff !== 0) return connectionDiff;
|
||||
const bandwidthA = a.bytesInPerSecond + a.bytesOutPerSecond;
|
||||
const bandwidthB = b.bytesInPerSecond + b.bytesOutPerSecond;
|
||||
return bandwidthB - bandwidthA;
|
||||
})
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
// --- Time-series helpers ---
|
||||
|
||||
private static minuteKey(ts: number = Date.now()): number {
|
||||
|
||||
@@ -103,6 +103,7 @@ export class SecurityHandler {
|
||||
throughputRate: networkStats.throughputRate,
|
||||
topIPs: networkStats.topIPs,
|
||||
topIPsByBandwidth: networkStats.topIPsByBandwidth,
|
||||
topASNs: networkStats.topASNs,
|
||||
totalDataTransferred: networkStats.totalDataTransferred,
|
||||
throughputHistory: networkStats.throughputHistory || [],
|
||||
throughputByIP,
|
||||
@@ -121,6 +122,7 @@ export class SecurityHandler {
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
topIPsByBandwidth: [],
|
||||
topASNs: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [],
|
||||
throughputByIP: [],
|
||||
|
||||
@@ -334,6 +334,7 @@ export class StatsHandler {
|
||||
connections: ip.count,
|
||||
bandwidth: { in: ip.bwIn, out: ip.bwOut },
|
||||
})),
|
||||
topASNs: stats.topASNs || [],
|
||||
domainActivity: stats.domainActivity || [],
|
||||
throughputHistory: stats.throughputHistory || [],
|
||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||
|
||||
@@ -152,6 +152,8 @@ export class VpnManager {
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
socketForwardProxyProtocolSource: 'remoteIp',
|
||||
socketForwardProxyProtocolVpnMetadata: true,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint,
|
||||
clientAllowedIPs: [subnet],
|
||||
|
||||
@@ -159,6 +159,17 @@ export interface IDomainActivity {
|
||||
requestsLastMinute?: number;
|
||||
}
|
||||
|
||||
export interface IAsnActivity {
|
||||
asn: number;
|
||||
organization: string;
|
||||
country: string | null;
|
||||
activeConnections: number;
|
||||
ipCount: number;
|
||||
bytesInPerSecond: number;
|
||||
bytesOutPerSecond: number;
|
||||
sampleIps: string[];
|
||||
}
|
||||
|
||||
export interface INetworkMetrics {
|
||||
totalBandwidth: {
|
||||
in: number;
|
||||
@@ -186,6 +197,7 @@ export interface INetworkMetrics {
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
topASNs: IAsnActivity[];
|
||||
domainActivity: IDomainActivity[];
|
||||
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
|
||||
requestsPerSecond?: number;
|
||||
|
||||
@@ -190,6 +190,7 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
|
||||
requestsTotal: number;
|
||||
backends?: statsInterfaces.IBackendInfo[];
|
||||
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||
topASNs: statsInterfaces.IAsnActivity[];
|
||||
domainActivity: statsInterfaces.IDomainActivity[];
|
||||
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.34.0',
|
||||
version: '13.36.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface INetworkState {
|
||||
totalBytes: { in: number; out: number };
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||
topASNs: interfaces.data.IAsnActivity[];
|
||||
throughputByIP: Array<{ ip: string; in: number; out: number }>;
|
||||
ipIntelligence: interfaces.data.IIpIntelligenceRecord[];
|
||||
domainActivity: interfaces.data.IDomainActivity[];
|
||||
@@ -176,6 +177,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
||||
totalBytes: { in: 0, out: 0 },
|
||||
topIPs: [],
|
||||
topIPsByBandwidth: [],
|
||||
topASNs: [],
|
||||
throughputByIP: [],
|
||||
ipIntelligence: [],
|
||||
domainActivity: [],
|
||||
@@ -689,6 +691,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
: { in: 0, out: 0 },
|
||||
topIPs: networkStatsResponse.topIPs || [],
|
||||
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
|
||||
topASNs: networkStatsResponse.topASNs || [],
|
||||
throughputByIP: networkStatsResponse.throughputByIP || [],
|
||||
ipIntelligence: currentState.ipIntelligence,
|
||||
domainActivity: networkStatsResponse.domainActivity || [],
|
||||
@@ -3152,6 +3155,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
bwIn: e.bandwidth?.in || 0,
|
||||
bwOut: e.bandwidth?.out || 0,
|
||||
})),
|
||||
topASNs: network.topASNs || [],
|
||||
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
|
||||
domainActivity: network.domainActivity || [],
|
||||
throughputHistory: network.throughputHistory || [],
|
||||
|
||||
@@ -308,6 +308,9 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
<!-- Top IPs by Connection Count -->
|
||||
${this.renderTopIPs()}
|
||||
|
||||
<!-- Top ASNs by Connection Count -->
|
||||
${this.renderTopASNs()}
|
||||
|
||||
<!-- Top IPs by Bandwidth -->
|
||||
${this.renderTopIPsByBandwidth()}
|
||||
|
||||
@@ -450,6 +453,28 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
];
|
||||
}
|
||||
|
||||
private getAsnDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Block ASN',
|
||||
iconName: 'lucide:radio-tower',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.createBlockRuleDialog('asn', String(actionData.item.asn), 'Blocked ASN from Network Activity');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Block Organization',
|
||||
iconName: 'lucide:building-2',
|
||||
type: ['contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.organization),
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.createBlockRuleDialog('organization', actionData.item.organization, 'Blocked organization from Network Activity');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private calculateThroughput(): { in: number; out: number } {
|
||||
// Use real throughput data from network state
|
||||
return {
|
||||
@@ -619,6 +644,40 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTopASNs(): TemplateResult {
|
||||
if (!this.networkState.topASNs || this.networkState.topASNs.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${this.networkState.topASNs}
|
||||
.rowKey=${'asn'}
|
||||
.highlightUpdates=${'flash'}
|
||||
.displayFunction=${(asnData: appstate.INetworkState['topASNs'][number]) => {
|
||||
return {
|
||||
'ASN': `AS${asnData.asn}`,
|
||||
'Organization': this.formatOptional(asnData.organization),
|
||||
'Connections': asnData.activeConnections,
|
||||
'IPs': asnData.ipCount,
|
||||
'Bandwidth In': this.formatBitsPerSecond(asnData.bytesInPerSecond),
|
||||
'Bandwidth Out': this.formatBitsPerSecond(asnData.bytesOutPerSecond),
|
||||
'Total Bandwidth': this.formatBitsPerSecond(asnData.bytesInPerSecond + asnData.bytesOutPerSecond),
|
||||
'Country': this.formatOptional(asnData.country),
|
||||
'Sample IPs': asnData.sampleIps.join(', '),
|
||||
};
|
||||
}}
|
||||
.dataActions=${this.getAsnDataActions()}
|
||||
heading1="Top Connected ASNs"
|
||||
heading2="Organizations causing the most active connections across observed IPs"
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
.pagination=${false}
|
||||
dataName="ASN"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTopIPsByBandwidth(): TemplateResult {
|
||||
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
|
||||
return html``;
|
||||
|
||||
@@ -97,7 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
'Route Refs': profile.routeRefs?.length
|
||||
? 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(),
|
||||
})}
|
||||
.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=${'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-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>
|
||||
`,
|
||||
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=${'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-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>
|
||||
`,
|
||||
menuOptions: [
|
||||
|
||||
Reference in New Issue
Block a user