Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5be93c8d38 | |||
| 788ccea81e | |||
| 47140e5403 | |||
| a6ffa24e36 | |||
| c0e432fd9b | |||
| a3d8a3a388 |
25
changelog.md
25
changelog.md
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-26 - 27.0.0 - BREAKING CHANGE(smart-proxy)
|
||||
remove route helper APIs and standardize route configuration on plain route objects
|
||||
|
||||
- Removes TypeScript route helper exports and related Rust config helpers in favor of defining routes directly with match and action properties.
|
||||
- Updates documentation and tests to use plain IRouteConfig objects and SocketHandlers imports instead of helper factory functions.
|
||||
- Moves socket handlers to a top-level utils export and keeps direct socket-handler route configuration as the supported pattern.
|
||||
|
||||
## 2026-03-26 - 26.3.0 - feat(nftables)
|
||||
move NFTables forwarding management from the Rust engine to @push.rocks/smartnftables
|
||||
|
||||
- add @push.rocks/smartnftables as a runtime dependency and export it via the plugin layer
|
||||
- remove the internal rustproxy-nftables crate along with Rust-side NFTables rule application and status management
|
||||
- apply and clean up NFTables port-forwarding rules in the TypeScript SmartProxy lifecycle and route update flow
|
||||
- change getNfTablesStatus to return local smartnftables status instead of querying the Rust bridge
|
||||
- update README documentation to describe NFTables support as provided through @push.rocks/smartnftables
|
||||
|
||||
## 2026-03-26 - 26.2.4 - fix(rustproxy-http)
|
||||
improve HTTP/3 connection reuse and clean up stale proxy state
|
||||
|
||||
- Reuse pooled HTTP/3 SendRequest handles to skip repeated SETTINGS handshakes and reduce request overhead on QUIC pool hits
|
||||
- Add periodic cleanup for per-route rate limiters and orphaned backend metrics to prevent unbounded memory growth after traffic or backend errors stop
|
||||
- Enforce HTTP max connection lifetime alongside idle timeouts and apply configured lifetime values from the TCP listener
|
||||
- Reduce HTTP/3 body copying by using owned Bytes paths for request and response streaming, and replace the custom response body adapter with a stream-based implementation
|
||||
- Harden auxiliary proxy components by capping datagram handler buffer growth and removing duplicate RustProxy exit listeners
|
||||
|
||||
## 2026-03-25 - 26.2.3 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
10
deno.lock
generated
10
deno.lock
generated
@@ -7,6 +7,7 @@
|
||||
"npm:@git.zone/tstest@^3.6.0": "3.6.0_typescript@6.0.2",
|
||||
"npm:@push.rocks/smartcrypto@^2.0.4": "2.0.4",
|
||||
"npm:@push.rocks/smartlog@^3.2.1": "3.2.1",
|
||||
"npm:@push.rocks/smartnftables@^1.0.1": "1.0.1",
|
||||
"npm:@push.rocks/smartrust@^1.3.2": "1.3.2",
|
||||
"npm:@push.rocks/smartserve@^2.0.3": "2.0.3",
|
||||
"npm:@tsclass/tsclass@^9.5.0": "9.5.0",
|
||||
@@ -2298,6 +2299,14 @@
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartnetwork/-/smartnetwork-4.4.0.tgz"
|
||||
},
|
||||
"@push.rocks/smartnftables@1.0.1": {
|
||||
"integrity": "sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==",
|
||||
"dependencies": [
|
||||
"@push.rocks/smartlog",
|
||||
"@push.rocks/smartpromise"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartnftables/-/smartnftables-1.0.1.tgz"
|
||||
},
|
||||
"@push.rocks/smartnpm@2.0.6": {
|
||||
"integrity": "sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==",
|
||||
"dependencies": [
|
||||
@@ -6729,6 +6738,7 @@
|
||||
"npm:@git.zone/tstest@^3.6.0",
|
||||
"npm:@push.rocks/smartcrypto@^2.0.4",
|
||||
"npm:@push.rocks/smartlog@^3.2.1",
|
||||
"npm:@push.rocks/smartnftables@^1.0.1",
|
||||
"npm:@push.rocks/smartrust@^1.3.2",
|
||||
"npm:@push.rocks/smartserve@^2.0.3",
|
||||
"npm:@tsclass/tsclass@^9.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "26.2.3",
|
||||
"version": "27.0.0",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
@@ -28,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@push.rocks/smartcrypto": "^2.0.4",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartnftables": "^1.0.1",
|
||||
"@push.rocks/smartrust": "^1.3.2",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"minimatch": "^10.2.4"
|
||||
|
||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@push.rocks/smartlog':
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1
|
||||
'@push.rocks/smartnftables':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@push.rocks/smartrust':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2
|
||||
@@ -468,89 +471,105 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -978,6 +997,9 @@ packages:
|
||||
'@push.rocks/smartnetwork@4.4.0':
|
||||
resolution: {integrity: sha512-OvFtz41cvQ7lcXwaIOhghNUUlNoMxvwKDctbDvMyuZyEH08SpLjhyv2FuKbKL/mgwA/WxakTbohoC8SW7t+kiw==}
|
||||
|
||||
'@push.rocks/smartnftables@1.0.1':
|
||||
resolution: {integrity: sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==}
|
||||
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
||||
|
||||
@@ -1121,36 +1143,42 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.11':
|
||||
resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11':
|
||||
resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11':
|
||||
resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.11':
|
||||
resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.11':
|
||||
resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.11':
|
||||
resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==}
|
||||
@@ -1192,21 +1220,25 @@ packages:
|
||||
resolution: {integrity: sha512-Z4reus7UxGM4+JuhiIht8KuGP1KgM7nNhOlXUHcQCMswP/Rymj5oJQN3TDWgijFUZs09ULl8t3T+AQAVTd/WvA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rspack/binding-linux-arm64-musl@1.7.10':
|
||||
resolution: {integrity: sha512-LYaoVmWizG4oQ3g+St3eM5qxsyfH07kLirP7NJcDMgvu3eQ29MeyTZ3ugkgW6LvlmJue7eTQyf6CZlanoF5SSg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rspack/binding-linux-x64-gnu@1.7.10':
|
||||
resolution: {integrity: sha512-aIm2G4Kcm3qxDTNqKarK0oaLY2iXnCmpRQQhAcMlR0aS2LmxL89XzVeRr9GFA1MzGrAsZONWCLkxQvn3WUbm4Q==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rspack/binding-linux-x64-musl@1.7.10':
|
||||
resolution: {integrity: sha512-SIHQbAgB9IPH0H3H+i5rN5jo9yA/yTMq8b7XfRkTMvZ7P7MXxJ0dE8EJu3BmCLM19sqnTc2eX+SVfE8ZMDzghA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rspack/binding-wasm32-wasi@1.7.10':
|
||||
resolution: {integrity: sha512-J9HDXHD1tj+9FmX4+K3CTkO7dCE2bootlR37YuC2Owc0Lwl1/i2oGT71KHnMqI9faF/hipAaQM5OywkiiuNB7w==}
|
||||
@@ -5130,6 +5162,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@push.rocks/smartnftables@1.0.1':
|
||||
dependencies:
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
dependencies:
|
||||
'@push.rocks/consolecolor': 2.0.3
|
||||
|
||||
@@ -462,35 +462,57 @@ For TLS termination modes (`terminate` and `terminate-and-reencrypt`), SmartProx
|
||||
|
||||
**HTTP to HTTPS Redirect**:
|
||||
```typescript
|
||||
import { createHttpToHttpsRedirect } from '@push.rocks/smartproxy';
|
||||
import { SocketHandlers } from '@push.rocks/smartproxy';
|
||||
|
||||
const redirectRoute = createHttpToHttpsRedirect(['example.com', 'www.example.com']);
|
||||
const redirectRoute = {
|
||||
name: 'http-to-https',
|
||||
match: { ports: 80, domains: ['example.com', 'www.example.com'] },
|
||||
action: {
|
||||
type: 'socket-handler' as const,
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Complete HTTPS Server (with redirect)**:
|
||||
```typescript
|
||||
import { createCompleteHttpsServer } from '@push.rocks/smartproxy';
|
||||
|
||||
const routes = createCompleteHttpsServer(
|
||||
'example.com',
|
||||
{ host: 'localhost', port: 8080 },
|
||||
{ certificate: 'auto' }
|
||||
);
|
||||
const routes = [
|
||||
{
|
||||
name: 'https-server',
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate' as const, certificate: 'auto' as const }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'http-redirect',
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'socket-handler' as const,
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
**Load Balancer with Health Checks**:
|
||||
```typescript
|
||||
import { createLoadBalancerRoute } from '@push.rocks/smartproxy';
|
||||
|
||||
const lbRoute = createLoadBalancerRoute(
|
||||
'api.example.com',
|
||||
[
|
||||
{ host: 'backend1', port: 8080 },
|
||||
{ host: 'backend2', port: 8080 },
|
||||
{ host: 'backend3', port: 8080 }
|
||||
],
|
||||
{ tls: { mode: 'terminate', certificate: 'auto' } }
|
||||
);
|
||||
const lbRoute = {
|
||||
name: 'load-balancer',
|
||||
match: { ports: 443, domains: 'api.example.com' },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [
|
||||
{ host: 'backend1', port: 8080 },
|
||||
{ host: 'backend2', port: 8080 },
|
||||
{ host: 'backend3', port: 8080 }
|
||||
],
|
||||
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
|
||||
loadBalancing: { algorithm: 'round-robin' as const }
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Smart SNI Requirement (v22.3+)
|
||||
|
||||
324
readme.md
324
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartproxy 🚀
|
||||
|
||||
**A high-performance, Rust-powered proxy toolkit for Node.js** — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, UDP/QUIC/HTTP3, load balancing, custom protocol handlers, and kernel-level NFTables forwarding.
|
||||
**A high-performance, Rust-powered proxy toolkit for Node.js** — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, UDP/QUIC/HTTP3, load balancing, custom protocol handlers, and kernel-level NFTables forwarding via [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables).
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
@@ -44,7 +44,7 @@ Whether you're building microservices, deploying edge infrastructure, proxying U
|
||||
Get up and running in 30 seconds:
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createCompleteHttpsServer } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||
|
||||
// Create a proxy with automatic HTTPS
|
||||
const proxy = new SmartProxy({
|
||||
@@ -53,13 +53,25 @@ const proxy = new SmartProxy({
|
||||
useProduction: true
|
||||
},
|
||||
routes: [
|
||||
// Complete HTTPS setup in one call! ✨
|
||||
...createCompleteHttpsServer('app.example.com', {
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}, {
|
||||
certificate: 'auto' // Automatic Let's Encrypt cert 🎩
|
||||
})
|
||||
// HTTPS route with automatic Let's Encrypt cert
|
||||
{
|
||||
name: 'https-app',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
},
|
||||
// HTTP → HTTPS redirect
|
||||
{
|
||||
name: 'http-redirect',
|
||||
match: { ports: 80, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -111,31 +123,38 @@ SmartProxy supports three TLS handling modes:
|
||||
### 🌐 HTTP to HTTPS Redirect
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createHttpToHttpsRedirect } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createHttpToHttpsRedirect(['example.com', '*.example.com'])
|
||||
]
|
||||
routes: [{
|
||||
name: 'http-to-https',
|
||||
match: { ports: 80, domains: ['example.com', '*.example.com'] },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
### ⚖️ Load Balancer with Health Checks
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createLoadBalancerRoute } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createLoadBalancerRoute(
|
||||
'app.example.com',
|
||||
[
|
||||
routes: [{
|
||||
name: 'load-balancer',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{ host: 'server1.internal', port: 8080 },
|
||||
{ host: 'server2.internal', port: 8080 },
|
||||
{ host: 'server3.internal', port: 8080 }
|
||||
],
|
||||
{
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
loadBalancing: {
|
||||
algorithm: 'round-robin',
|
||||
healthCheck: {
|
||||
path: '/health',
|
||||
@@ -145,57 +164,68 @@ const proxy = new SmartProxy({
|
||||
healthyThreshold: 2
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
### 🔌 WebSocket Proxy
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createWebSocketRoute } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createWebSocketRoute(
|
||||
'ws.example.com',
|
||||
{ host: 'websocket-server', port: 8080 },
|
||||
{
|
||||
path: '/socket',
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
routes: [{
|
||||
name: 'websocket',
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
|
||||
priority: 100,
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'websocket-server', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: {
|
||||
enabled: true,
|
||||
pingInterval: 30000,
|
||||
pingTimeout: 10000
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
### 🚦 API Gateway with Rate Limiting
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createApiGatewayRoute, addRateLimiting } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
let apiRoute = createApiGatewayRoute(
|
||||
'api.example.com',
|
||||
'/api',
|
||||
{ host: 'api-backend', port: 8080 },
|
||||
{
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
addCorsHeaders: true
|
||||
}
|
||||
);
|
||||
|
||||
// Add rate limiting — 100 requests per minute per IP
|
||||
apiRoute = addRateLimiting(apiRoute, {
|
||||
maxRequests: 100,
|
||||
window: 60,
|
||||
keyBy: 'ip'
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'api-gateway',
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
|
||||
priority: 100,
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'api-backend', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
headers: {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
},
|
||||
security: {
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 100,
|
||||
window: 60,
|
||||
keyBy: 'ip'
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
const proxy = new SmartProxy({ routes: [apiRoute] });
|
||||
```
|
||||
|
||||
### 🎮 Custom Protocol Handler (TCP)
|
||||
@@ -203,36 +233,40 @@ const proxy = new SmartProxy({ routes: [apiRoute] });
|
||||
SmartProxy lets you implement any protocol with full socket control. Routes with JavaScript socket handlers are automatically relayed from the Rust engine back to your TypeScript code:
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createSocketHandlerRoute, SocketHandlers } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||
|
||||
// Use pre-built handlers
|
||||
const echoRoute = createSocketHandlerRoute(
|
||||
'echo.example.com',
|
||||
7777,
|
||||
SocketHandlers.echo
|
||||
);
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
// Use pre-built handlers
|
||||
{
|
||||
name: 'echo-server',
|
||||
match: { ports: 7777, domains: 'echo.example.com' },
|
||||
action: { type: 'socket-handler', socketHandler: SocketHandlers.echo }
|
||||
},
|
||||
// Or create your own custom protocol
|
||||
{
|
||||
name: 'custom-protocol',
|
||||
match: { ports: 9999, domains: 'custom.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: async (socket) => {
|
||||
console.log(`New connection on custom protocol`);
|
||||
socket.write('Welcome to my custom protocol!\n');
|
||||
|
||||
// Or create your own custom protocol
|
||||
const customRoute = createSocketHandlerRoute(
|
||||
'custom.example.com',
|
||||
9999,
|
||||
async (socket) => {
|
||||
console.log(`New connection on custom protocol`);
|
||||
socket.write('Welcome to my custom protocol!\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
switch (command) {
|
||||
case 'PING': socket.write('PONG\n'); break;
|
||||
case 'TIME': socket.write(`${new Date().toISOString()}\n`); break;
|
||||
case 'QUIT': socket.end('Goodbye!\n'); break;
|
||||
default: socket.write(`Unknown: ${command}\n`);
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
switch (command) {
|
||||
case 'PING': socket.write('PONG\n'); break;
|
||||
case 'TIME': socket.write(`${new Date().toISOString()}\n`); break;
|
||||
case 'QUIT': socket.end('Goodbye!\n'); break;
|
||||
default: socket.write(`Unknown: ${command}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const proxy = new SmartProxy({ routes: [echoRoute, customRoute] });
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
**Pre-built Socket Handlers:**
|
||||
@@ -384,23 +418,26 @@ const dualStackRoute: IRouteConfig = {
|
||||
|
||||
### ⚡ High-Performance NFTables Forwarding
|
||||
|
||||
For ultra-low latency on Linux, use kernel-level forwarding (requires root):
|
||||
For ultra-low latency on Linux, use kernel-level forwarding via [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables) (requires root):
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createNfTablesTerminateRoute } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesTerminateRoute(
|
||||
'fast.example.com',
|
||||
{ host: 'backend', port: 8080 },
|
||||
{
|
||||
ports: 443,
|
||||
certificate: 'auto',
|
||||
routes: [{
|
||||
name: 'nftables-fast',
|
||||
match: { ports: 443, domains: 'fast.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'backend', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
nftables: {
|
||||
protocol: 'tcp',
|
||||
preserveSourceIP: true // Backend sees real client IP
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
@@ -409,15 +446,18 @@ const proxy = new SmartProxy({
|
||||
Forward encrypted traffic to backends without terminating TLS — the proxy routes based on the SNI hostname alone:
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createHttpsPassthroughRoute } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createHttpsPassthroughRoute('secure.example.com', {
|
||||
host: 'backend-that-handles-tls',
|
||||
port: 8443
|
||||
})
|
||||
]
|
||||
routes: [{
|
||||
name: 'sni-passthrough',
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'backend-that-handles-tls', port: 8443 }],
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
@@ -524,15 +564,7 @@ Comprehensive per-route security options:
|
||||
}
|
||||
```
|
||||
|
||||
**Security modifier helpers** let you add security to any existing route:
|
||||
|
||||
```typescript
|
||||
import { addRateLimiting, addBasicAuth, addJwtAuth } from '@push.rocks/smartproxy';
|
||||
|
||||
let route = createHttpsTerminateRoute('api.example.com', { host: 'backend', port: 8080 });
|
||||
route = addRateLimiting(route, { maxRequests: 100, window: 60, keyBy: 'ip' });
|
||||
route = addBasicAuth(route, { users: [{ username: 'admin', password: 'secret' }] });
|
||||
```
|
||||
Security options are configured directly on each route's `security` property — no separate helpers needed.
|
||||
|
||||
### 📊 Runtime Management
|
||||
|
||||
@@ -694,22 +726,26 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
|
||||
│ │ Listener│ │ Reverse │ │ Matcher │ │ Cert Mgr │ │
|
||||
│ │ │ │ Proxy │ │ │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
|
||||
│ │ UDP │ │ Security│ │ Metrics │ │ NFTables │ │
|
||||
│ │ QUIC │ │ Enforce │ │ Collect │ │ Mgr │ │
|
||||
│ │ HTTP/3 │ │ │ │ │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ UDP │ │ Security│ │ Metrics │ │
|
||||
│ │ QUIC │ │ Enforce │ │ Collect │ │
|
||||
│ │ HTTP/3 │ │ │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
└──────────────────┬──────────────────────────────────┘
|
||||
│ Unix Socket Relay
|
||||
┌──────────────────▼──────────────────────────────────┐
|
||||
│ TypeScript Socket & Datagram Handler Servers │
|
||||
│ (for JS socket handlers, datagram handlers, │
|
||||
│ and dynamic routes) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ @push.rocks/smartnftables (kernel-level NFTables) │
|
||||
│ (DNAT/SNAT, firewall, rate limiting via nft CLI) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Rust Engine** handles all networking: TCP, UDP, TLS, QUIC, HTTP proxying, connection management, security, and metrics
|
||||
- **TypeScript** provides the npm API, configuration types, route helpers, validation, and handler callbacks
|
||||
- **TypeScript** provides the npm API, configuration types, validation, and handler callbacks
|
||||
- **NFTables** managed by [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables) — kernel-level DNAT/SNAT forwarding, firewall rules, and rate limiting via the `nft` CLI
|
||||
- **IPC** — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary
|
||||
- **Socket/Datagram Relay** — Unix domain socket servers for routes requiring TypeScript-side handling (socket handlers, datagram handlers, dynamic host/port functions)
|
||||
|
||||
@@ -854,47 +890,13 @@ interface IRouteQuic {
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ Helper Functions Reference
|
||||
|
||||
All helpers are fully typed and return `IRouteConfig` or `IRouteConfig[]`:
|
||||
## 🛠️ Exports Reference
|
||||
|
||||
```typescript
|
||||
import {
|
||||
// HTTP/HTTPS
|
||||
createHttpRoute, // Plain HTTP route
|
||||
createHttpsTerminateRoute, // HTTPS with TLS termination
|
||||
createHttpsPassthroughRoute, // SNI passthrough (no termination)
|
||||
createHttpToHttpsRedirect, // HTTP → HTTPS redirect
|
||||
createCompleteHttpsServer, // HTTPS + redirect combo (returns IRouteConfig[])
|
||||
|
||||
// Load Balancing
|
||||
createLoadBalancerRoute, // Multi-backend with health checks
|
||||
createSmartLoadBalancer, // Dynamic domain-based backend selection
|
||||
|
||||
// API & WebSocket
|
||||
createApiRoute, // API route with path matching
|
||||
createApiGatewayRoute, // API gateway with CORS
|
||||
createWebSocketRoute, // WebSocket-enabled route
|
||||
|
||||
// Custom Protocols
|
||||
createSocketHandlerRoute, // Custom TCP socket handler
|
||||
SocketHandlers, // Pre-built handlers (echo, proxy, block, etc.)
|
||||
|
||||
// NFTables (Linux, requires root)
|
||||
createNfTablesRoute, // Kernel-level packet forwarding
|
||||
createNfTablesTerminateRoute, // NFTables + TLS termination
|
||||
createCompleteNfTablesHttpsServer, // NFTables HTTPS + redirect combo
|
||||
|
||||
// Dynamic Routing
|
||||
createPortMappingRoute, // Port mapping with context
|
||||
createOffsetPortMappingRoute, // Simple port offset
|
||||
createDynamicRoute, // Dynamic host/port via functions
|
||||
createPortOffset, // Port offset factory
|
||||
|
||||
// Security Modifiers
|
||||
addRateLimiting, // Add rate limiting to any route
|
||||
addBasicAuth, // Add basic auth to any route
|
||||
addJwtAuth, // Add JWT auth to any route
|
||||
// Core
|
||||
SmartProxy, // Main proxy class
|
||||
SocketHandlers, // Pre-built socket handlers (echo, proxy, block, httpRedirect, httpServer, etc.)
|
||||
|
||||
// Route Utilities
|
||||
mergeRouteConfigs, // Deep-merge two route configs
|
||||
@@ -906,7 +908,7 @@ import {
|
||||
} from '@push.rocks/smartproxy';
|
||||
```
|
||||
|
||||
> **Tip:** For UDP datagram handler routes or QUIC/HTTP3 routes, construct `IRouteConfig` objects directly — there are no helper functions for these yet. See the [UDP Datagram Handler](#-udp-datagram-handler) and [QUIC / HTTP3 Forwarding](#-quic--http3-forwarding) examples above.
|
||||
All routes are configured as plain `IRouteConfig` objects with `match` and `action` properties — see the examples throughout this document.
|
||||
|
||||
## 📖 API Documentation
|
||||
|
||||
@@ -938,8 +940,8 @@ class SmartProxy extends EventEmitter {
|
||||
getCertificateStatus(routeName: string): Promise<any>;
|
||||
getEligibleDomainsForCertificates(): string[];
|
||||
|
||||
// NFTables
|
||||
getNfTablesStatus(): Promise<Record<string, any>>;
|
||||
// NFTables (managed by @push.rocks/smartnftables)
|
||||
getNfTablesStatus(): INftStatus | null;
|
||||
|
||||
// Events
|
||||
on(event: 'error', handler: (err: Error) => void): this;
|
||||
@@ -991,11 +993,11 @@ interface ISmartProxyOptions {
|
||||
sendProxyProtocol?: boolean; // Send PROXY protocol to targets
|
||||
|
||||
// Timeouts
|
||||
connectionTimeout?: number; // Backend connection timeout (default: 30s)
|
||||
initialDataTimeout?: number; // Initial data/SNI timeout (default: 120s)
|
||||
socketTimeout?: number; // Socket inactivity timeout (default: 1h)
|
||||
maxConnectionLifetime?: number; // Max connection lifetime (default: 24h)
|
||||
inactivityTimeout?: number; // Inactivity timeout (default: 4h)
|
||||
connectionTimeout?: number; // Backend connection timeout (default: 60s)
|
||||
initialDataTimeout?: number; // Initial data/SNI timeout (default: 60s)
|
||||
socketTimeout?: number; // Socket inactivity timeout (default: 60s)
|
||||
maxConnectionLifetime?: number; // Max connection lifetime (default: 1h)
|
||||
inactivityTimeout?: number; // Inactivity timeout (default: 75s)
|
||||
gracefulShutdownTimeout?: number; // Shutdown grace period (default: 30s)
|
||||
|
||||
// Connection limits
|
||||
@@ -1004,8 +1006,8 @@ interface ISmartProxyOptions {
|
||||
|
||||
// Keep-alive
|
||||
keepAliveTreatment?: 'standard' | 'extended' | 'immortal';
|
||||
keepAliveInactivityMultiplier?: number; // (default: 6)
|
||||
extendedKeepAliveLifetime?: number; // (default: 7 days)
|
||||
keepAliveInactivityMultiplier?: number; // (default: 4)
|
||||
extendedKeepAliveLifetime?: number; // (default: 1h)
|
||||
|
||||
// Metrics
|
||||
metrics?: {
|
||||
@@ -1137,7 +1139,7 @@ SmartProxy searches for the Rust binary in this order:
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
16
rust/Cargo.lock
generated
16
rust/Cargo.lock
generated
@@ -1238,7 +1238,6 @@ dependencies = [
|
||||
"rustproxy-config",
|
||||
"rustproxy-http",
|
||||
"rustproxy-metrics",
|
||||
"rustproxy-nftables",
|
||||
"rustproxy-passthrough",
|
||||
"rustproxy-routing",
|
||||
"rustproxy-security",
|
||||
@@ -1270,6 +1269,7 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"dashmap",
|
||||
"futures",
|
||||
"h3",
|
||||
"h3-quinn",
|
||||
"http-body",
|
||||
@@ -1303,20 +1303,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustproxy-nftables"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
"rustproxy-config",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustproxy-passthrough"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -7,7 +7,6 @@ members = [
|
||||
"crates/rustproxy-tls",
|
||||
"crates/rustproxy-passthrough",
|
||||
"crates/rustproxy-http",
|
||||
"crates/rustproxy-nftables",
|
||||
"crates/rustproxy-metrics",
|
||||
"crates/rustproxy-security",
|
||||
]
|
||||
@@ -107,6 +106,5 @@ rustproxy-routing = { path = "crates/rustproxy-routing" }
|
||||
rustproxy-tls = { path = "crates/rustproxy-tls" }
|
||||
rustproxy-passthrough = { path = "crates/rustproxy-passthrough" }
|
||||
rustproxy-http = { path = "crates/rustproxy-http" }
|
||||
rustproxy-nftables = { path = "crates/rustproxy-nftables" }
|
||||
rustproxy-metrics = { path = "crates/rustproxy-metrics" }
|
||||
rustproxy-security = { path = "crates/rustproxy-security" }
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
use crate::route_types::*;
|
||||
use crate::tls_types::*;
|
||||
|
||||
/// Create a simple HTTP forwarding route.
|
||||
/// Equivalent to SmartProxy's `createHttpRoute()`.
|
||||
pub fn create_http_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(80),
|
||||
domains: Some(domains.into()),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(vec![RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single(target_host.into()),
|
||||
port: PortSpec::Fixed(target_port),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
}]),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: None,
|
||||
options: None,
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: None,
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an HTTPS termination route.
|
||||
/// Equivalent to SmartProxy's `createHttpsTerminateRoute()`.
|
||||
pub fn create_https_terminate_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
let mut route = create_http_route(domains, target_host, target_port);
|
||||
route.route_match.ports = PortRange::Single(443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Terminate,
|
||||
certificate: Some(CertificateSpec::Auto("auto".to_string())),
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
|
||||
/// Create a TLS passthrough route.
|
||||
/// Equivalent to SmartProxy's `createHttpsPassthroughRoute()`.
|
||||
pub fn create_https_passthrough_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
let mut route = create_http_route(domains, target_host, target_port);
|
||||
route.route_match.ports = PortRange::Single(443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Passthrough,
|
||||
certificate: None,
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
|
||||
/// Create an HTTP-to-HTTPS redirect route.
|
||||
/// Equivalent to SmartProxy's `createHttpToHttpsRedirect()`.
|
||||
pub fn create_http_to_https_redirect(
|
||||
domains: impl Into<DomainSpec>,
|
||||
) -> RouteConfig {
|
||||
let domains = domains.into();
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(80),
|
||||
domains: Some(domains),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: None,
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: Some(RouteAdvanced {
|
||||
timeout: None,
|
||||
headers: None,
|
||||
keep_alive: None,
|
||||
static_files: None,
|
||||
test_response: Some(RouteTestResponse {
|
||||
status: 301,
|
||||
headers: {
|
||||
let mut h = std::collections::HashMap::new();
|
||||
h.insert("Location".to_string(), "https://{domain}{path}".to_string());
|
||||
h
|
||||
},
|
||||
body: String::new(),
|
||||
}),
|
||||
url_rewrite: None,
|
||||
}),
|
||||
options: None,
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: Some("HTTP to HTTPS Redirect".to_string()),
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a complete HTTPS server with HTTP redirect.
|
||||
/// Equivalent to SmartProxy's `createCompleteHttpsServer()`.
|
||||
pub fn create_complete_https_server(
|
||||
domain: impl Into<String>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> Vec<RouteConfig> {
|
||||
let domain = domain.into();
|
||||
let target_host = target_host.into();
|
||||
|
||||
vec![
|
||||
create_http_to_https_redirect(DomainSpec::Single(domain.clone())),
|
||||
create_https_terminate_route(
|
||||
DomainSpec::Single(domain),
|
||||
target_host,
|
||||
target_port,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Create a load balancer route.
|
||||
/// Equivalent to SmartProxy's `createLoadBalancerRoute()`.
|
||||
pub fn create_load_balancer_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
targets: Vec<(String, u16)>,
|
||||
tls: Option<RouteTls>,
|
||||
) -> RouteConfig {
|
||||
let route_targets: Vec<RouteTarget> = targets
|
||||
.into_iter()
|
||||
.map(|(host, port)| RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single(host),
|
||||
port: PortSpec::Fixed(port),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let port = if tls.is_some() { 443 } else { 80 };
|
||||
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(port),
|
||||
domains: Some(domains.into()),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(route_targets),
|
||||
tls,
|
||||
websocket: None,
|
||||
load_balancing: Some(RouteLoadBalancing {
|
||||
algorithm: LoadBalancingAlgorithm::RoundRobin,
|
||||
health_check: None,
|
||||
}),
|
||||
advanced: None,
|
||||
options: None,
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: Some("Load Balancer".to_string()),
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience conversions for DomainSpec
|
||||
impl From<&str> for DomainSpec {
|
||||
fn from(s: &str) -> Self {
|
||||
DomainSpec::Single(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DomainSpec {
|
||||
fn from(s: String) -> Self {
|
||||
DomainSpec::Single(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<String>> for DomainSpec {
|
||||
fn from(v: Vec<String>) -> Self {
|
||||
DomainSpec::List(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<&str>> for DomainSpec {
|
||||
fn from(v: Vec<&str>) -> Self {
|
||||
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tls_types::TlsMode;
|
||||
|
||||
#[test]
|
||||
fn test_create_http_route() {
|
||||
let route = create_http_route("example.com", "localhost", 8080);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
let domains = route.route_match.domains.as_ref().unwrap().to_vec();
|
||||
assert_eq!(domains, vec!["example.com"]);
|
||||
let target = &route.action.targets.as_ref().unwrap()[0];
|
||||
assert_eq!(target.host.first(), "localhost");
|
||||
assert_eq!(target.port.resolve(80), 8080);
|
||||
assert!(route.action.tls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_https_terminate_route() {
|
||||
let route = create_https_terminate_route("api.example.com", "backend", 3000);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
|
||||
let tls = route.action.tls.as_ref().unwrap();
|
||||
assert_eq!(tls.mode, TlsMode::Terminate);
|
||||
assert!(tls.certificate.as_ref().unwrap().is_auto());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_https_passthrough_route() {
|
||||
let route = create_https_passthrough_route("secure.example.com", "backend", 443);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
|
||||
let tls = route.action.tls.as_ref().unwrap();
|
||||
assert_eq!(tls.mode, TlsMode::Passthrough);
|
||||
assert!(tls.certificate.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_http_to_https_redirect() {
|
||||
let route = create_http_to_https_redirect("example.com");
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
assert!(route.action.targets.is_none());
|
||||
let test_response = route.action.advanced.as_ref().unwrap().test_response.as_ref().unwrap();
|
||||
assert_eq!(test_response.status, 301);
|
||||
assert!(test_response.headers.contains_key("Location"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_complete_https_server() {
|
||||
let routes = create_complete_https_server("example.com", "backend", 8080);
|
||||
assert_eq!(routes.len(), 2);
|
||||
// First route is HTTP redirect
|
||||
assert_eq!(routes[0].route_match.ports.to_ports(), vec![80]);
|
||||
// Second route is HTTPS terminate
|
||||
assert_eq!(routes[1].route_match.ports.to_ports(), vec![443]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_load_balancer_route() {
|
||||
let targets = vec![
|
||||
("backend1".to_string(), 8080),
|
||||
("backend2".to_string(), 8080),
|
||||
("backend3".to_string(), 8080),
|
||||
];
|
||||
let route = create_load_balancer_route("*.example.com", targets, None);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
assert_eq!(route.action.targets.as_ref().unwrap().len(), 3);
|
||||
let lb = route.action.load_balancing.as_ref().unwrap();
|
||||
assert_eq!(lb.algorithm, LoadBalancingAlgorithm::RoundRobin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_spec_from_str() {
|
||||
let spec: DomainSpec = "example.com".into();
|
||||
assert_eq!(spec.to_vec(), vec!["example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_spec_from_vec() {
|
||||
let spec: DomainSpec = vec!["a.com", "b.com"].into();
|
||||
assert_eq!(spec.to_vec(), vec!["a.com", "b.com"]);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ pub mod proxy_options;
|
||||
pub mod tls_types;
|
||||
pub mod security_types;
|
||||
pub mod validation;
|
||||
pub mod helpers;
|
||||
|
||||
// Re-export all primary types
|
||||
pub use route_types::*;
|
||||
@@ -16,4 +15,3 @@ pub use proxy_options::*;
|
||||
pub use tls_types::*;
|
||||
pub use security_types::*;
|
||||
pub use validation::*;
|
||||
pub use helpers::*;
|
||||
|
||||
@@ -331,12 +331,48 @@ impl RustProxyOptions {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::helpers::*;
|
||||
use crate::route_types::*;
|
||||
use crate::tls_types::*;
|
||||
|
||||
fn make_route(domain: &str, host: &str, port: u16, listen_port: u16) -> RouteConfig {
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(listen_port),
|
||||
domains: Some(DomainSpec::Single(domain.to_string())),
|
||||
path: None, client_ip: None, transport: None, tls_version: None, headers: None, protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(vec![RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single(host.to_string()),
|
||||
port: PortSpec::Fixed(port),
|
||||
tls: None, websocket: None, load_balancing: None, send_proxy_protocol: None,
|
||||
headers: None, advanced: None, backend_transport: None, priority: None,
|
||||
}]),
|
||||
tls: None, websocket: None, load_balancing: None, advanced: None,
|
||||
options: None, send_proxy_protocol: None, udp: None,
|
||||
},
|
||||
headers: None, security: None, name: None, description: None,
|
||||
priority: None, tags: None, enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_passthrough_route(domain: &str, host: &str, port: u16) -> RouteConfig {
|
||||
let mut route = make_route(domain, host, port, 443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Passthrough,
|
||||
certificate: None, acme: None, versions: None, ciphers: None,
|
||||
honor_cipher_order: None, session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_minimal() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![create_http_route("example.com", "localhost", 8080)],
|
||||
routes: vec![make_route("example.com", "localhost", 8080, 80)],
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&options).unwrap();
|
||||
@@ -348,8 +384,8 @@ mod tests {
|
||||
fn test_serde_roundtrip_full() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![
|
||||
create_http_route("a.com", "backend1", 8080),
|
||||
create_https_passthrough_route("b.com", "backend2", 443),
|
||||
make_route("a.com", "backend1", 8080, 80),
|
||||
make_passthrough_route("b.com", "backend2", 443),
|
||||
],
|
||||
connection_timeout: Some(5000),
|
||||
socket_timeout: Some(60000),
|
||||
@@ -402,9 +438,9 @@ mod tests {
|
||||
fn test_all_listening_ports() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![
|
||||
create_http_route("a.com", "backend", 8080), // port 80
|
||||
create_https_passthrough_route("b.com", "backend", 443), // port 443
|
||||
create_http_route("c.com", "backend", 9090), // port 80 (duplicate)
|
||||
make_route("a.com", "backend", 8080, 80), // port 80
|
||||
make_passthrough_route("b.com", "backend", 443), // port 443
|
||||
make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -60,16 +60,6 @@ pub enum RouteActionType {
|
||||
SocketHandler,
|
||||
}
|
||||
|
||||
// ─── Forwarding Engine ───────────────────────────────────────────────
|
||||
|
||||
/// Forwarding engine specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ForwardingEngine {
|
||||
Node,
|
||||
Nftables,
|
||||
}
|
||||
|
||||
// ─── Route Match ─────────────────────────────────────────────────────
|
||||
|
||||
/// Domain specification: single string or array.
|
||||
@@ -89,6 +79,31 @@ impl DomainSpec {
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience conversions for DomainSpec
|
||||
impl From<&str> for DomainSpec {
|
||||
fn from(s: &str) -> Self {
|
||||
DomainSpec::Single(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DomainSpec {
|
||||
fn from(s: String) -> Self {
|
||||
DomainSpec::Single(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<String>> for DomainSpec {
|
||||
fn from(v: Vec<String>) -> Self {
|
||||
DomainSpec::List(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<&str>> for DomainSpec {
|
||||
fn from(v: Vec<&str>) -> Self {
|
||||
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Header match value: either exact string or regex pattern.
|
||||
/// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -341,38 +356,6 @@ pub struct RouteAdvanced {
|
||||
pub url_rewrite: Option<RouteUrlRewrite>,
|
||||
}
|
||||
|
||||
// ─── NFTables Options ────────────────────────────────────────────────
|
||||
|
||||
/// NFTables protocol type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NfTablesProtocol {
|
||||
Tcp,
|
||||
Udp,
|
||||
All,
|
||||
}
|
||||
|
||||
/// NFTables-specific configuration options.
|
||||
/// Matches TypeScript: `INfTablesOptions`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NfTablesOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preserve_source_ip: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<NfTablesProtocol>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_rate: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub table_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_ip_sets: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_advanced_nat: Option<bool>,
|
||||
}
|
||||
|
||||
// ─── Backend Protocol ────────────────────────────────────────────────
|
||||
|
||||
/// Backend protocol.
|
||||
@@ -541,14 +524,6 @@ pub struct RouteAction {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<ActionOptions>,
|
||||
|
||||
/// Forwarding engine specification
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forwarding_engine: Option<ForwardingEngine>,
|
||||
|
||||
/// NFTables-specific options
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nftables: Option<NfTablesOptions>,
|
||||
|
||||
/// PROXY protocol support (default for all targets)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub send_proxy_protocol: Option<bool>,
|
||||
|
||||
@@ -104,7 +104,49 @@ mod tests {
|
||||
use crate::route_types::*;
|
||||
|
||||
fn make_valid_route() -> RouteConfig {
|
||||
crate::helpers::create_http_route("example.com", "localhost", 8080)
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(80),
|
||||
domains: Some(DomainSpec::Single("example.com".to_string())),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(vec![RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single("localhost".to_string()),
|
||||
port: PortSpec::Fixed(8080),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
}]),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: None,
|
||||
options: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: None,
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -30,3 +30,4 @@ socket2 = { workspace = true }
|
||||
quinn = { workspace = true }
|
||||
h3 = { workspace = true }
|
||||
h3-quinn = { workspace = true }
|
||||
futures = { version = "0.3", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -56,7 +56,11 @@ struct PooledH2 {
|
||||
}
|
||||
|
||||
/// A pooled QUIC/HTTP/3 connection (multiplexed like H2).
|
||||
/// Stores the h3 `SendRequest` handle so pool hits skip the h3 SETTINGS handshake.
|
||||
pub struct PooledH3 {
|
||||
/// Multiplexed h3 request handle — clone to open a new stream.
|
||||
pub send_request: h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>,
|
||||
/// Raw QUIC connection — kept for liveness probing (close_reason) only.
|
||||
pub connection: quinn::Connection,
|
||||
pub created_at: Instant,
|
||||
pub generation: u64,
|
||||
@@ -197,7 +201,10 @@ impl ConnectionPool {
|
||||
|
||||
/// Try to get a pooled QUIC connection for the given key.
|
||||
/// QUIC connections are multiplexed — the connection is shared, not removed.
|
||||
pub fn checkout_h3(&self, key: &PoolKey) -> Option<(quinn::Connection, Duration)> {
|
||||
pub fn checkout_h3(
|
||||
&self,
|
||||
key: &PoolKey,
|
||||
) -> Option<(h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>, quinn::Connection, Duration)> {
|
||||
let entry = self.h3_pool.get(key)?;
|
||||
let pooled = entry.value();
|
||||
let age = pooled.created_at.elapsed();
|
||||
@@ -215,13 +222,20 @@ impl ConnectionPool {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((pooled.connection.clone(), age))
|
||||
Some((pooled.send_request.clone(), pooled.connection.clone(), age))
|
||||
}
|
||||
|
||||
/// Register a QUIC connection in the pool. Returns the generation ID.
|
||||
pub fn register_h3(&self, key: PoolKey, connection: quinn::Connection) -> u64 {
|
||||
/// Register a QUIC connection and its h3 SendRequest handle in the pool.
|
||||
/// Returns the generation ID.
|
||||
pub fn register_h3(
|
||||
&self,
|
||||
key: PoolKey,
|
||||
connection: quinn::Connection,
|
||||
send_request: h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>,
|
||||
) -> u64 {
|
||||
let gen = self.h2_generation.fetch_add(1, Ordering::Relaxed);
|
||||
self.h3_pool.insert(key, PooledH3 {
|
||||
send_request,
|
||||
connection,
|
||||
created_at: Instant::now(),
|
||||
generation: gen,
|
||||
|
||||
@@ -116,7 +116,7 @@ async fn handle_h3_request(
|
||||
cancel: CancellationToken,
|
||||
) -> anyhow::Result<()> {
|
||||
// Stream request body from H3 client via an mpsc channel.
|
||||
let (body_tx, body_rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
||||
let (body_tx, body_rx) = tokio::sync::mpsc::channel::<Bytes>(32);
|
||||
|
||||
// Spawn the H3 body reader task with cancellation
|
||||
let body_cancel = cancel.clone();
|
||||
@@ -132,8 +132,7 @@ async fn handle_h3_request(
|
||||
}
|
||||
};
|
||||
let mut chunk = chunk;
|
||||
let data = Bytes::copy_from_slice(chunk.chunk());
|
||||
chunk.advance(chunk.remaining());
|
||||
let data = chunk.copy_to_bytes(chunk.remaining());
|
||||
if body_tx.send(data).await.is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -179,8 +178,8 @@ async fn handle_h3_request(
|
||||
while let Some(frame) = resp_body.frame().await {
|
||||
match frame {
|
||||
Ok(frame) => {
|
||||
if let Some(data) = frame.data_ref() {
|
||||
stream.send_data(Bytes::copy_from_slice(data)).await
|
||||
if let Ok(data) = frame.into_data() {
|
||||
stream.send_data(data).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send H3 data: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,15 +72,16 @@ const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_s
|
||||
/// If no new request arrives within this duration, the connection is closed.
|
||||
const DEFAULT_HTTP_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Default HTTP max connection lifetime (1 hour).
|
||||
/// HTTP connections are forcefully closed after this duration regardless of activity.
|
||||
const DEFAULT_HTTP_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||
|
||||
/// Default WebSocket inactivity timeout (1 hour).
|
||||
const DEFAULT_WS_INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||
|
||||
/// Default WebSocket max lifetime (24 hours).
|
||||
const DEFAULT_WS_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(86400);
|
||||
|
||||
/// Timeout for QUIC (H3) backend connections. Short because UDP is often firewalled.
|
||||
const QUIC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
/// Protocol decision for backend connection.
|
||||
#[derive(Debug)]
|
||||
enum ProtocolDecision {
|
||||
@@ -222,6 +223,8 @@ pub struct HttpProxyService {
|
||||
protocol_cache: Arc<crate::protocol_cache::ProtocolCache>,
|
||||
/// HTTP keep-alive idle timeout: close connection if no new request arrives within this duration.
|
||||
http_idle_timeout: std::time::Duration,
|
||||
/// HTTP max connection lifetime: forcefully close connection after this duration regardless of activity.
|
||||
http_max_lifetime: std::time::Duration,
|
||||
/// WebSocket inactivity timeout (no data in either direction).
|
||||
ws_inactivity_timeout: std::time::Duration,
|
||||
/// WebSocket maximum connection lifetime.
|
||||
@@ -248,6 +251,7 @@ impl HttpProxyService {
|
||||
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
||||
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||
http_max_lifetime: DEFAULT_HTTP_MAX_LIFETIME,
|
||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||
@@ -275,21 +279,24 @@ impl HttpProxyService {
|
||||
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
||||
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||
http_max_lifetime: DEFAULT_HTTP_MAX_LIFETIME,
|
||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the HTTP keep-alive idle timeout, WebSocket inactivity timeout, and
|
||||
/// WebSocket max lifetime from connection config values.
|
||||
/// Set the HTTP keep-alive idle timeout, HTTP max lifetime, WebSocket inactivity
|
||||
/// timeout, and WebSocket max lifetime from connection config values.
|
||||
pub fn set_connection_timeouts(
|
||||
&mut self,
|
||||
http_idle_timeout: std::time::Duration,
|
||||
http_max_lifetime: std::time::Duration,
|
||||
ws_inactivity_timeout: std::time::Duration,
|
||||
ws_max_lifetime: std::time::Duration,
|
||||
) {
|
||||
self.http_idle_timeout = http_idle_timeout;
|
||||
self.http_max_lifetime = http_max_lifetime;
|
||||
self.ws_inactivity_timeout = ws_inactivity_timeout;
|
||||
self.ws_max_lifetime = ws_max_lifetime;
|
||||
}
|
||||
@@ -314,6 +321,15 @@ impl HttpProxyService {
|
||||
self.protocol_cache.clear();
|
||||
}
|
||||
|
||||
/// Clean up expired entries in all per-route rate limiters.
|
||||
/// Called from the background sampling task to prevent unbounded growth
|
||||
/// when traffic stops after a burst of unique IPs.
|
||||
pub fn cleanup_all_rate_limiters(&self) {
|
||||
for entry in self.route_rate_limiters.iter() {
|
||||
entry.value().cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the protocol cache for metrics/UI display.
|
||||
pub fn protocol_cache_snapshot(&self) -> Vec<crate::protocol_cache::ProtocolCacheEntry> {
|
||||
self.protocol_cache.snapshot()
|
||||
@@ -354,6 +370,7 @@ impl HttpProxyService {
|
||||
|
||||
// Capture timeouts before `self` is moved into the service closure.
|
||||
let idle_timeout = self.http_idle_timeout;
|
||||
let max_lifetime = self.http_max_lifetime;
|
||||
|
||||
// Activity tracker: updated at the START and END of each request.
|
||||
// The idle watchdog checks this to determine if the connection is idle
|
||||
@@ -412,15 +429,23 @@ impl HttpProxyService {
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
// Idle watchdog: check every 5s whether the connection has been idle
|
||||
// (no active requests AND no activity for idle_timeout).
|
||||
// This avoids killing long-running requests or upgraded connections.
|
||||
// Idle + lifetime watchdog: check every 5s whether the connection has been
|
||||
// idle (no active requests AND no activity for idle_timeout) or exceeded
|
||||
// the max connection lifetime.
|
||||
let check_interval = std::time::Duration::from_secs(5);
|
||||
let mut last_seen = 0u64;
|
||||
loop {
|
||||
tokio::time::sleep(check_interval).await;
|
||||
|
||||
// Never close while a request is in progress
|
||||
// Check max connection lifetime (unconditional — even active connections
|
||||
// must eventually be recycled to prevent resource accumulation).
|
||||
if start.elapsed() >= max_lifetime {
|
||||
debug!("HTTP connection exceeded max lifetime ({}s) from {}",
|
||||
max_lifetime.as_secs(), peer_addr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Never close for idleness while a request is in progress
|
||||
if active_requests.load(Ordering::Relaxed) > 0 {
|
||||
last_seen = last_activity.load(Ordering::Relaxed);
|
||||
continue;
|
||||
@@ -437,7 +462,7 @@ impl HttpProxyService {
|
||||
last_seen = current;
|
||||
}
|
||||
} => {
|
||||
debug!("HTTP connection idle timeout ({}s) from {}", idle_timeout.as_secs(), peer_addr);
|
||||
debug!("HTTP connection timeout from {}", peer_addr);
|
||||
conn.as_mut().graceful_shutdown();
|
||||
// Give any in-flight work 5s to drain after graceful shutdown
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn).await;
|
||||
@@ -791,10 +816,10 @@ impl HttpProxyService {
|
||||
};
|
||||
|
||||
// Try H3 pool checkout first
|
||||
if let Some((quic_conn, _age)) = self.connection_pool.checkout_h3(&h3_pool_key) {
|
||||
if let Some((pooled_sr, quic_conn, _age)) = self.connection_pool.checkout_h3(&h3_pool_key) {
|
||||
self.metrics.backend_pool_hit(&upstream_key);
|
||||
let result = self.forward_h3(
|
||||
quic_conn, parts, body, upstream_headers, &upstream_path,
|
||||
quic_conn, Some(pooled_sr), parts, body, upstream_headers, &upstream_path,
|
||||
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||
).await;
|
||||
self.upstream_selector.connection_ended(&upstream_key);
|
||||
@@ -807,7 +832,7 @@ impl HttpProxyService {
|
||||
self.metrics.backend_pool_miss(&upstream_key);
|
||||
self.metrics.backend_connection_opened(&upstream_key, std::time::Instant::now().elapsed());
|
||||
let result = self.forward_h3(
|
||||
quic_conn, parts, body, upstream_headers, &upstream_path,
|
||||
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||
).await;
|
||||
self.upstream_selector.connection_ended(&upstream_key);
|
||||
@@ -966,7 +991,7 @@ impl HttpProxyService {
|
||||
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||
};
|
||||
let result = self.forward_h3(
|
||||
quic_conn, parts, body, upstream_headers, &upstream_path,
|
||||
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||
).await;
|
||||
self.upstream_selector.connection_ended(&upstream_key);
|
||||
@@ -1009,7 +1034,7 @@ impl HttpProxyService {
|
||||
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||
};
|
||||
let result = self.forward_h3(
|
||||
quic_conn, parts, body, upstream_headers, &upstream_path,
|
||||
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||
).await;
|
||||
self.upstream_selector.connection_ended(&upstream_key);
|
||||
@@ -1068,7 +1093,7 @@ impl HttpProxyService {
|
||||
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||
};
|
||||
let result = self.forward_h3(
|
||||
quic_conn, parts, body, upstream_headers, &upstream_path,
|
||||
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||
).await;
|
||||
self.upstream_selector.connection_ended(&upstream_key);
|
||||
@@ -1111,7 +1136,7 @@ impl HttpProxyService {
|
||||
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||
};
|
||||
let result = self.forward_h3(
|
||||
quic_conn, parts, body, upstream_headers, &upstream_path,
|
||||
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||
).await;
|
||||
self.upstream_selector.connection_ended(&upstream_key);
|
||||
@@ -2744,7 +2769,12 @@ impl HttpProxyService {
|
||||
|
||||
let quic_crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls_config)
|
||||
.expect("Failed to create QUIC client crypto config");
|
||||
let client_config = quinn::ClientConfig::new(Arc::new(quic_crypto));
|
||||
|
||||
// Tune QUIC transport to match H2 flow-control: 2 MB per-stream receive window.
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
transport.stream_receive_window(quinn::VarInt::from_u32(2 * 1024 * 1024));
|
||||
let mut client_config = quinn::ClientConfig::new(Arc::new(quic_crypto));
|
||||
client_config.transport_config(Arc::new(transport));
|
||||
|
||||
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())
|
||||
.expect("Failed to create QUIC client endpoint");
|
||||
@@ -2766,8 +2796,8 @@ impl HttpProxyService {
|
||||
let server_name = host.to_string();
|
||||
let connecting = self.quinn_client_endpoint.connect(addr, &server_name)?;
|
||||
|
||||
let connection = tokio::time::timeout(QUIC_CONNECT_TIMEOUT, connecting).await
|
||||
.map_err(|_| format!("QUIC connect timeout (3s) for {}", host))??;
|
||||
let connection = tokio::time::timeout(self.connect_timeout, connecting).await
|
||||
.map_err(|_| format!("QUIC connect timeout ({:?}) for {}", self.connect_timeout, host))??;
|
||||
|
||||
debug!("QUIC backend connection established to {}:{}", host, port);
|
||||
Ok(connection)
|
||||
@@ -2777,6 +2807,7 @@ impl HttpProxyService {
|
||||
async fn forward_h3(
|
||||
&self,
|
||||
quic_conn: quinn::Connection,
|
||||
pooled_sender: Option<h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>>,
|
||||
parts: hyper::http::request::Parts,
|
||||
body: BoxBody<Bytes, hyper::Error>,
|
||||
upstream_headers: hyper::HeaderMap,
|
||||
@@ -2789,33 +2820,42 @@ impl HttpProxyService {
|
||||
conn_activity: &ConnActivity,
|
||||
backend_key: &str,
|
||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||
let h3_quinn_conn = h3_quinn::Connection::new(quic_conn.clone());
|
||||
let (mut driver, mut send_request) = match h3::client::builder()
|
||||
.send_grease(false)
|
||||
.build(h3_quinn_conn)
|
||||
.await
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
|
||||
self.metrics.backend_handshake_error(backend_key);
|
||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 handshake failed"));
|
||||
}
|
||||
};
|
||||
// Obtain the h3 SendRequest handle: skip handshake + driver on pool hit.
|
||||
let (mut send_request, gen_holder) = if let Some(sr) = pooled_sender {
|
||||
// Pool hit — reuse existing h3 session, no SETTINGS round-trip
|
||||
(sr, None)
|
||||
} else {
|
||||
// Fresh QUIC connection — full h3 handshake + driver spawn
|
||||
let h3_quinn_conn = h3_quinn::Connection::new(quic_conn.clone());
|
||||
let (mut driver, sr) = match h3::client::builder()
|
||||
.send_grease(false)
|
||||
.build(h3_quinn_conn)
|
||||
.await
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
|
||||
self.metrics.backend_handshake_error(backend_key);
|
||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 handshake failed"));
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn the h3 connection driver
|
||||
let driver_pool = Arc::clone(&self.connection_pool);
|
||||
let driver_pool_key = pool_key.clone();
|
||||
let gen_holder = Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX));
|
||||
let driver_gen = Arc::clone(&gen_holder);
|
||||
tokio::spawn(async move {
|
||||
let close_err = std::future::poll_fn(|cx| driver.poll_close(cx)).await;
|
||||
debug!("H3 connection driver closed: {:?}", close_err);
|
||||
let g = driver_gen.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if g != u64::MAX {
|
||||
driver_pool.remove_h3_if_generation(&driver_pool_key, g);
|
||||
let gen_holder = Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX));
|
||||
{
|
||||
let driver_pool = Arc::clone(&self.connection_pool);
|
||||
let driver_pool_key = pool_key.clone();
|
||||
let driver_gen = Arc::clone(&gen_holder);
|
||||
tokio::spawn(async move {
|
||||
let close_err = std::future::poll_fn(|cx| driver.poll_close(cx)).await;
|
||||
debug!("H3 connection driver closed: {:?}", close_err);
|
||||
let g = driver_gen.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if g != u64::MAX {
|
||||
driver_pool.remove_h3_if_generation(&driver_pool_key, g);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(sr, Some(gen_holder))
|
||||
};
|
||||
|
||||
// Build the H3 request
|
||||
let uri = hyper::Uri::builder()
|
||||
@@ -2845,7 +2885,7 @@ impl HttpProxyService {
|
||||
}
|
||||
};
|
||||
|
||||
// Stream request body
|
||||
// Stream request body (zero-copy: into_data yields owned Bytes)
|
||||
let rid: Option<Arc<str>> = route_id.map(Arc::from);
|
||||
let sip: Arc<str> = Arc::from(source_ip);
|
||||
|
||||
@@ -2855,9 +2895,9 @@ impl HttpProxyService {
|
||||
while let Some(frame) = body.frame().await {
|
||||
match frame {
|
||||
Ok(frame) => {
|
||||
if let Some(data) = frame.data_ref() {
|
||||
if let Ok(data) = frame.into_data() {
|
||||
self.metrics.record_bytes(data.len() as u64, 0, rid.as_deref(), Some(&sip));
|
||||
if let Err(e) = stream.send_data(Bytes::copy_from_slice(data)).await {
|
||||
if let Err(e) = stream.send_data(data).await {
|
||||
error!(backend = %backend_key, error = %e, "H3 send_data failed");
|
||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 body send failed"));
|
||||
}
|
||||
@@ -2899,8 +2939,23 @@ impl HttpProxyService {
|
||||
ResponseFilter::apply_headers(route, headers, None);
|
||||
}
|
||||
|
||||
// Stream response body back via an adapter
|
||||
let h3_body = H3ClientResponseBody { stream };
|
||||
// Stream response body back via unfold — correctly preserves waker across polls
|
||||
let body_stream = futures::stream::unfold(stream, |mut s| async move {
|
||||
match s.recv_data().await {
|
||||
Ok(Some(mut buf)) => {
|
||||
use bytes::Buf;
|
||||
let data = buf.copy_to_bytes(buf.remaining());
|
||||
Some((Ok::<_, hyper::Error>(http_body::Frame::data(data)), s))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("H3 response body recv error: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
let h3_body = http_body_util::StreamBody::new(body_stream);
|
||||
|
||||
let counting_body = CountingBody::new(
|
||||
h3_body,
|
||||
Arc::clone(&self.metrics),
|
||||
@@ -2917,10 +2972,16 @@ impl HttpProxyService {
|
||||
|
||||
let body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_body);
|
||||
|
||||
// Register connection in pool on success
|
||||
// Register connection in pool on success (fresh connections only)
|
||||
if status != StatusCode::BAD_GATEWAY {
|
||||
let g = self.connection_pool.register_h3(pool_key.clone(), quic_conn);
|
||||
gen_holder.store(g, std::sync::atomic::Ordering::Relaxed);
|
||||
if let Some(gh) = gen_holder {
|
||||
let g = self.connection_pool.register_h3(
|
||||
pool_key.clone(),
|
||||
quic_conn,
|
||||
send_request,
|
||||
);
|
||||
gh.store(g, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
self.metrics.set_backend_protocol(backend_key, "h3");
|
||||
@@ -2949,41 +3010,6 @@ fn parse_alt_svc_h3_port(header_value: &str) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Response body adapter for H3 client responses.
|
||||
/// Reads data from the h3 `RequestStream` recv side and presents it as an `http_body::Body`.
|
||||
struct H3ClientResponseBody {
|
||||
stream: h3::client::RequestStream<h3_quinn::BidiStream<Bytes>, Bytes>,
|
||||
}
|
||||
|
||||
impl http_body::Body for H3ClientResponseBody {
|
||||
type Data = Bytes;
|
||||
type Error = hyper::Error;
|
||||
|
||||
fn poll_frame(
|
||||
mut self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
|
||||
// h3's recv_data is async, so we need to poll it manually.
|
||||
// Use a small future to poll the recv_data call.
|
||||
use std::future::Future;
|
||||
let mut fut = Box::pin(self.stream.recv_data());
|
||||
match fut.as_mut().poll(_cx) {
|
||||
Poll::Ready(Ok(Some(mut buf))) => {
|
||||
use bytes::Buf;
|
||||
let data = Bytes::copy_from_slice(buf.chunk());
|
||||
buf.advance(buf.remaining());
|
||||
Poll::Ready(Some(Ok(http_body::Frame::data(data))))
|
||||
}
|
||||
Poll::Ready(Ok(None)) => Poll::Ready(None),
|
||||
Poll::Ready(Err(e)) => {
|
||||
warn!("H3 response body recv error: {}", e);
|
||||
Poll::Ready(None)
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insecure certificate verifier for backend TLS connections (fallback only).
|
||||
/// The production path uses the shared config from tls_handler which has the same
|
||||
/// behavior but with session resumption across all outbound connections.
|
||||
@@ -3052,6 +3078,7 @@ impl Default for HttpProxyService {
|
||||
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
||||
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||
http_max_lifetime: DEFAULT_HTTP_MAX_LIFETIME,
|
||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||
|
||||
@@ -624,6 +624,24 @@ impl MetricsCollector {
|
||||
self.ip_pending_tp.retain(|k, _| self.ip_connections.contains_key(k));
|
||||
self.ip_throughput.retain(|k, _| self.ip_connections.contains_key(k));
|
||||
self.ip_total_connections.retain(|k, _| self.ip_connections.contains_key(k));
|
||||
|
||||
// Safety-net: prune orphaned backend error/stats entries for backends
|
||||
// that have no active or total connections (error-only backends).
|
||||
// These accumulate when backend_connect_error/backend_handshake_error
|
||||
// create entries but backend_connection_opened is never called.
|
||||
let known_backends: HashSet<String> = self.backend_active.iter()
|
||||
.map(|e| e.key().clone())
|
||||
.chain(self.backend_total.iter().map(|e| e.key().clone()))
|
||||
.collect();
|
||||
self.backend_connect_errors.retain(|k, _| known_backends.contains(k));
|
||||
self.backend_handshake_errors.retain(|k, _| known_backends.contains(k));
|
||||
self.backend_request_errors.retain(|k, _| known_backends.contains(k));
|
||||
self.backend_connect_time_us.retain(|k, _| known_backends.contains(k));
|
||||
self.backend_connect_count.retain(|k, _| known_backends.contains(k));
|
||||
self.backend_pool_hits.retain(|k, _| known_backends.contains(k));
|
||||
self.backend_pool_misses.retain(|k, _| known_backends.contains(k));
|
||||
self.backend_h2_failures.retain(|k, _| known_backends.contains(k));
|
||||
self.backend_protocol.retain(|k, _| known_backends.contains(k));
|
||||
}
|
||||
|
||||
/// Remove per-route metrics for route IDs that are no longer active.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "rustproxy-nftables"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "NFTables kernel-level forwarding for RustProxy"
|
||||
|
||||
[dependencies]
|
||||
rustproxy-config = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
@@ -1,10 +0,0 @@
|
||||
//! # rustproxy-nftables
|
||||
//!
|
||||
//! NFTables kernel-level forwarding for RustProxy.
|
||||
//! Generates and manages nft CLI rules for DNAT/SNAT.
|
||||
|
||||
pub mod nft_manager;
|
||||
pub mod rule_builder;
|
||||
|
||||
pub use nft_manager::*;
|
||||
pub use rule_builder::*;
|
||||
@@ -1,238 +0,0 @@
|
||||
use thiserror::Error;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NftError {
|
||||
#[error("nft command failed: {0}")]
|
||||
CommandFailed(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Not running as root")]
|
||||
NotRoot,
|
||||
}
|
||||
|
||||
/// Manager for nftables rules.
|
||||
///
|
||||
/// Executes `nft` CLI commands to manage kernel-level packet forwarding.
|
||||
/// Requires root privileges; operations are skipped gracefully if not root.
|
||||
pub struct NftManager {
|
||||
table_name: String,
|
||||
/// Active rules indexed by route ID
|
||||
active_rules: HashMap<String, Vec<String>>,
|
||||
/// Whether the table has been initialized
|
||||
table_initialized: bool,
|
||||
}
|
||||
|
||||
impl NftManager {
|
||||
pub fn new(table_name: Option<String>) -> Self {
|
||||
Self {
|
||||
table_name: table_name.unwrap_or_else(|| "rustproxy".to_string()),
|
||||
active_rules: HashMap::new(),
|
||||
table_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we are running as root.
|
||||
fn is_root() -> bool {
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
|
||||
/// Execute a single nft command via the CLI.
|
||||
async fn exec_nft(command: &str) -> Result<String, NftError> {
|
||||
// The command starts with "nft ", strip it to get the args
|
||||
let args = if command.starts_with("nft ") {
|
||||
&command[4..]
|
||||
} else {
|
||||
command
|
||||
};
|
||||
|
||||
let output = tokio::process::Command::new("nft")
|
||||
.args(args.split_whitespace())
|
||||
.output()
|
||||
.await
|
||||
.map_err(NftError::Io)?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(NftError::CommandFailed(format!(
|
||||
"Command '{}' failed: {}",
|
||||
command, stderr
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the nftables table and chains are set up.
|
||||
async fn ensure_table(&mut self) -> Result<(), NftError> {
|
||||
if self.table_initialized {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let setup_commands = crate::rule_builder::build_table_setup(&self.table_name);
|
||||
for cmd in &setup_commands {
|
||||
Self::exec_nft(cmd).await?;
|
||||
}
|
||||
|
||||
self.table_initialized = true;
|
||||
info!("NFTables table '{}' initialized", self.table_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply rules for a route.
|
||||
///
|
||||
/// Executes the nft commands via the CLI. If not running as root,
|
||||
/// the rules are stored locally but not applied to the kernel.
|
||||
pub async fn apply_rules(&mut self, route_id: &str, rules: Vec<String>) -> Result<(), NftError> {
|
||||
if !Self::is_root() {
|
||||
warn!("Not running as root, nftables rules will not be applied to kernel");
|
||||
self.active_rules.insert(route_id.to_string(), rules);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.ensure_table().await?;
|
||||
|
||||
for cmd in &rules {
|
||||
Self::exec_nft(cmd).await?;
|
||||
debug!("Applied nft rule: {}", cmd);
|
||||
}
|
||||
|
||||
info!("Applied {} nftables rules for route '{}'", rules.len(), route_id);
|
||||
self.active_rules.insert(route_id.to_string(), rules);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove rules for a route.
|
||||
///
|
||||
/// Currently removes the route from tracking. To fully remove specific
|
||||
/// rules would require handle-based tracking; for now, cleanup() removes
|
||||
/// the entire table.
|
||||
pub async fn remove_rules(&mut self, route_id: &str) -> Result<(), NftError> {
|
||||
if let Some(rules) = self.active_rules.remove(route_id) {
|
||||
info!("Removed {} tracked nft rules for route '{}'", rules.len(), route_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up all managed rules by deleting the entire nftables table.
|
||||
pub async fn cleanup(&mut self) -> Result<(), NftError> {
|
||||
if !Self::is_root() {
|
||||
warn!("Not running as root, skipping nftables cleanup");
|
||||
self.active_rules.clear();
|
||||
self.table_initialized = false;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.table_initialized {
|
||||
let cleanup_commands = crate::rule_builder::build_table_cleanup(&self.table_name);
|
||||
for cmd in &cleanup_commands {
|
||||
match Self::exec_nft(cmd).await {
|
||||
Ok(_) => debug!("Cleanup: {}", cmd),
|
||||
Err(e) => warn!("Cleanup command failed (may be ok): {}", e),
|
||||
}
|
||||
}
|
||||
info!("NFTables table '{}' cleaned up", self.table_name);
|
||||
}
|
||||
|
||||
self.active_rules.clear();
|
||||
self.table_initialized = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the table name.
|
||||
pub fn table_name(&self) -> &str {
|
||||
&self.table_name
|
||||
}
|
||||
|
||||
/// Whether the table has been initialized in the kernel.
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.table_initialized
|
||||
}
|
||||
|
||||
/// Get the number of active route rule sets.
|
||||
pub fn active_route_count(&self) -> usize {
|
||||
self.active_rules.len()
|
||||
}
|
||||
|
||||
/// Get the status of all active rules.
|
||||
pub fn status(&self) -> HashMap<String, serde_json::Value> {
|
||||
let mut status = HashMap::new();
|
||||
for (route_id, rules) in &self.active_rules {
|
||||
status.insert(
|
||||
route_id.clone(),
|
||||
serde_json::json!({
|
||||
"ruleCount": rules.len(),
|
||||
"rules": rules,
|
||||
}),
|
||||
);
|
||||
}
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_default_table_name() {
|
||||
let mgr = NftManager::new(None);
|
||||
assert_eq!(mgr.table_name(), "rustproxy");
|
||||
assert!(!mgr.is_initialized());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_custom_table_name() {
|
||||
let mgr = NftManager::new(Some("custom".to_string()));
|
||||
assert_eq!(mgr.table_name(), "custom");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_rules_non_root() {
|
||||
let mut mgr = NftManager::new(None);
|
||||
// When not root, rules are stored but not applied to kernel
|
||||
let rules = vec!["nft add rule ip rustproxy prerouting tcp dport 443 dnat to 10.0.0.1:8443".to_string()];
|
||||
mgr.apply_rules("route-1", rules).await.unwrap();
|
||||
assert_eq!(mgr.active_route_count(), 1);
|
||||
|
||||
let status = mgr.status();
|
||||
assert!(status.contains_key("route-1"));
|
||||
assert_eq!(status["route-1"]["ruleCount"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_rules() {
|
||||
let mut mgr = NftManager::new(None);
|
||||
let rules = vec!["nft add rule test".to_string()];
|
||||
mgr.apply_rules("route-1", rules).await.unwrap();
|
||||
assert_eq!(mgr.active_route_count(), 1);
|
||||
|
||||
mgr.remove_rules("route-1").await.unwrap();
|
||||
assert_eq!(mgr.active_route_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_non_root() {
|
||||
let mut mgr = NftManager::new(None);
|
||||
let rules = vec!["nft add rule test".to_string()];
|
||||
mgr.apply_rules("route-1", rules).await.unwrap();
|
||||
mgr.apply_rules("route-2", vec!["nft add rule test2".to_string()]).await.unwrap();
|
||||
|
||||
mgr.cleanup().await.unwrap();
|
||||
assert_eq!(mgr.active_route_count(), 0);
|
||||
assert!(!mgr.is_initialized());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_status_multiple_routes() {
|
||||
let mut mgr = NftManager::new(None);
|
||||
mgr.apply_rules("web", vec!["rule1".to_string(), "rule2".to_string()]).await.unwrap();
|
||||
mgr.apply_rules("api", vec!["rule3".to_string()]).await.unwrap();
|
||||
|
||||
let status = mgr.status();
|
||||
assert_eq!(status.len(), 2);
|
||||
assert_eq!(status["web"]["ruleCount"], 2);
|
||||
assert_eq!(status["api"]["ruleCount"], 1);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
use rustproxy_config::{NfTablesOptions, NfTablesProtocol};
|
||||
|
||||
/// Build nftables DNAT rule for port forwarding.
|
||||
pub fn build_dnat_rule(
|
||||
table_name: &str,
|
||||
chain_name: &str,
|
||||
source_port: u16,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
options: &NfTablesOptions,
|
||||
) -> Vec<String> {
|
||||
let protocols: Vec<&str> = match options.protocol.as_ref().unwrap_or(&NfTablesProtocol::Tcp) {
|
||||
NfTablesProtocol::Tcp => vec!["tcp"],
|
||||
NfTablesProtocol::Udp => vec!["udp"],
|
||||
NfTablesProtocol::All => vec!["tcp", "udp"],
|
||||
};
|
||||
|
||||
let mut rules = Vec::new();
|
||||
|
||||
for protocol in &protocols {
|
||||
// DNAT rule
|
||||
rules.push(format!(
|
||||
"nft add rule ip {} {} {} dport {} dnat to {}:{}",
|
||||
table_name, chain_name, protocol, source_port, target_host, target_port,
|
||||
));
|
||||
|
||||
// SNAT rule if preserving source IP is not enabled
|
||||
if !options.preserve_source_ip.unwrap_or(false) {
|
||||
rules.push(format!(
|
||||
"nft add rule ip {} postrouting {} dport {} masquerade",
|
||||
table_name, protocol, target_port,
|
||||
));
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if let Some(max_rate) = &options.max_rate {
|
||||
rules.push(format!(
|
||||
"nft add rule ip {} {} {} dport {} limit rate {} accept",
|
||||
table_name, chain_name, protocol, source_port, max_rate,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
rules
|
||||
}
|
||||
|
||||
/// Build the initial table and chain setup commands.
|
||||
pub fn build_table_setup(table_name: &str) -> Vec<String> {
|
||||
vec![
|
||||
format!("nft add table ip {}", table_name),
|
||||
format!("nft add chain ip {} prerouting {{ type nat hook prerouting priority 0 \\; }}", table_name),
|
||||
format!("nft add chain ip {} postrouting {{ type nat hook postrouting priority 100 \\; }}", table_name),
|
||||
]
|
||||
}
|
||||
|
||||
/// Build cleanup commands to remove the table.
|
||||
pub fn build_table_cleanup(table_name: &str) -> Vec<String> {
|
||||
vec![format!("nft delete table ip {}", table_name)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_options() -> NfTablesOptions {
|
||||
NfTablesOptions {
|
||||
preserve_source_ip: None,
|
||||
protocol: None,
|
||||
max_rate: None,
|
||||
priority: None,
|
||||
table_name: None,
|
||||
use_ip_sets: None,
|
||||
use_advanced_nat: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_dnat_rule() {
|
||||
let options = make_options();
|
||||
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
|
||||
assert!(rules.len() >= 1);
|
||||
assert!(rules[0].contains("dnat to 10.0.0.1:8443"));
|
||||
assert!(rules[0].contains("dport 443"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preserve_source_ip() {
|
||||
let mut options = make_options();
|
||||
options.preserve_source_ip = Some(true);
|
||||
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
|
||||
// When preserving source IP, no masquerade rule
|
||||
assert!(rules.iter().all(|r| !r.contains("masquerade")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_without_preserve_source_ip() {
|
||||
let options = make_options();
|
||||
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
|
||||
assert!(rules.iter().any(|r| r.contains("masquerade")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limited_rule() {
|
||||
let mut options = make_options();
|
||||
options.max_rate = Some("100/second".to_string());
|
||||
let rules = build_dnat_rule("rustproxy", "prerouting", 80, "10.0.0.1", 8080, &options);
|
||||
assert!(rules.iter().any(|r| r.contains("limit rate 100/second")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_setup_commands() {
|
||||
let commands = build_table_setup("rustproxy");
|
||||
assert_eq!(commands.len(), 3);
|
||||
assert!(commands[0].contains("add table ip rustproxy"));
|
||||
assert!(commands[1].contains("prerouting"));
|
||||
assert!(commands[2].contains("postrouting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_cleanup() {
|
||||
let commands = build_table_cleanup("rustproxy");
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert!(commands[0].contains("delete table ip rustproxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protocol_all_generates_tcp_and_udp_rules() {
|
||||
let mut options = make_options();
|
||||
options.protocol = Some(NfTablesProtocol::All);
|
||||
let rules = build_dnat_rule("rustproxy", "prerouting", 53, "10.0.0.53", 53, &options);
|
||||
// Should have TCP DNAT + masquerade + UDP DNAT + masquerade = 4 rules
|
||||
assert_eq!(rules.len(), 4);
|
||||
assert!(rules.iter().any(|r| r.contains("tcp dport 53 dnat")));
|
||||
assert!(rules.iter().any(|r| r.contains("udp dport 53 dnat")));
|
||||
assert!(rules.iter().filter(|r| r.contains("masquerade")).count() == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protocol_udp() {
|
||||
let mut options = make_options();
|
||||
options.protocol = Some(NfTablesProtocol::Udp);
|
||||
let rules = build_dnat_rule("rustproxy", "prerouting", 53, "10.0.0.53", 53, &options);
|
||||
assert!(rules.iter().all(|r| !r.contains("tcp")));
|
||||
assert!(rules.iter().any(|r| r.contains("udp dport 53 dnat")));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ pub mod forwarder;
|
||||
pub mod proxy_protocol;
|
||||
pub mod tls_handler;
|
||||
pub mod connection_tracker;
|
||||
pub mod socket_relay;
|
||||
pub mod socket_opts;
|
||||
pub mod udp_session;
|
||||
pub mod udp_listener;
|
||||
@@ -22,7 +21,6 @@ pub use forwarder::*;
|
||||
pub use proxy_protocol::*;
|
||||
pub use tls_handler::*;
|
||||
pub use connection_tracker::*;
|
||||
pub use socket_relay::*;
|
||||
pub use socket_opts::*;
|
||||
pub use udp_session::*;
|
||||
pub use udp_listener::*;
|
||||
|
||||
@@ -1,126 +1,4 @@
|
||||
//! Socket handler relay for connecting client connections to a TypeScript handler
|
||||
//! via a Unix domain socket.
|
||||
//! Socket handler relay module.
|
||||
//!
|
||||
//! Protocol: Send a JSON metadata line terminated by `\n`, then bidirectional relay.
|
||||
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::io::{AsyncWriteExt, AsyncReadExt};
|
||||
use tokio::net::TcpStream;
|
||||
use serde::Serialize;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RelayMetadata {
|
||||
connection_id: u64,
|
||||
remote_ip: String,
|
||||
remote_port: u16,
|
||||
local_port: u16,
|
||||
sni: Option<String>,
|
||||
route_name: String,
|
||||
initial_data_base64: Option<String>,
|
||||
}
|
||||
|
||||
/// Relay a client connection to a TypeScript handler via Unix domain socket.
|
||||
///
|
||||
/// Protocol: Send a JSON metadata line terminated by `\n`, then bidirectional relay.
|
||||
pub async fn relay_to_handler(
|
||||
client: TcpStream,
|
||||
relay_socket_path: &str,
|
||||
connection_id: u64,
|
||||
remote_ip: String,
|
||||
remote_port: u16,
|
||||
local_port: u16,
|
||||
sni: Option<String>,
|
||||
route_name: String,
|
||||
initial_data: Option<&[u8]>,
|
||||
) -> std::io::Result<()> {
|
||||
debug!(
|
||||
"Relaying connection {} to handler socket {}",
|
||||
connection_id, relay_socket_path
|
||||
);
|
||||
|
||||
// Connect to TypeScript handler Unix socket
|
||||
let mut handler = UnixStream::connect(relay_socket_path).await?;
|
||||
|
||||
// Build and send metadata header
|
||||
let initial_data_base64 = initial_data.map(base64_encode);
|
||||
|
||||
let metadata = RelayMetadata {
|
||||
connection_id,
|
||||
remote_ip,
|
||||
remote_port,
|
||||
local_port,
|
||||
sni,
|
||||
route_name,
|
||||
initial_data_base64,
|
||||
};
|
||||
|
||||
let metadata_json = serde_json::to_string(&metadata)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
|
||||
handler.write_all(metadata_json.as_bytes()).await?;
|
||||
handler.write_all(b"\n").await?;
|
||||
|
||||
// Bidirectional relay between client and handler
|
||||
let (mut client_read, mut client_write) = client.into_split();
|
||||
let (mut handler_read, mut handler_write) = handler.into_split();
|
||||
|
||||
let c2h = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
let n = match client_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
if handler_write.write_all(&buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = handler_write.shutdown().await;
|
||||
});
|
||||
|
||||
let h2c = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
let n = match handler_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
if client_write.write_all(&buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = client_write.shutdown().await;
|
||||
});
|
||||
|
||||
let _ = tokio::join!(c2h, h2c);
|
||||
|
||||
debug!("Relay connection {} completed", connection_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple base64 encoding without external dependency.
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut result = String::new();
|
||||
for chunk in data.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
result.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||||
result.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
result.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
|
||||
} else {
|
||||
result.push('=');
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
result.push(CHARS[(n & 0x3F) as usize] as char);
|
||||
} else {
|
||||
result.push('=');
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
//! Note: The actual relay logic lives in `tcp_listener::relay_to_socket_handler()`
|
||||
//! which has proper timeouts, cancellation, and metrics integration.
|
||||
|
||||
@@ -182,6 +182,7 @@ impl TcpListenerManager {
|
||||
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
||||
http_proxy_svc.set_connection_timeouts(
|
||||
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
||||
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
||||
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
||||
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
||||
);
|
||||
@@ -220,6 +221,7 @@ impl TcpListenerManager {
|
||||
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
||||
http_proxy_svc.set_connection_timeouts(
|
||||
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
||||
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
||||
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
||||
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
||||
);
|
||||
@@ -263,6 +265,7 @@ impl TcpListenerManager {
|
||||
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
||||
http_proxy_svc.set_connection_timeouts(
|
||||
std::time::Duration::from_millis(config.socket_timeout_ms),
|
||||
std::time::Duration::from_millis(config.max_connection_lifetime_ms),
|
||||
std::time::Duration::from_millis(config.socket_timeout_ms),
|
||||
std::time::Duration::from_millis(config.max_connection_lifetime_ms),
|
||||
);
|
||||
|
||||
@@ -355,8 +355,6 @@ mod tests {
|
||||
load_balancing: None,
|
||||
advanced: None,
|
||||
options: None,
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ rustproxy-routing = { workspace = true }
|
||||
rustproxy-tls = { workspace = true }
|
||||
rustproxy-passthrough = { workspace = true }
|
||||
rustproxy-http = { workspace = true }
|
||||
rustproxy-nftables = { workspace = true }
|
||||
rustproxy-metrics = { workspace = true }
|
||||
rustproxy-security = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -7,14 +7,40 @@
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use rustproxy::RustProxy;
|
||||
//! use rustproxy_config::{RustProxyOptions, create_https_passthrough_route};
|
||||
//! use rustproxy_config::*;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let options = RustProxyOptions {
|
||||
//! routes: vec![
|
||||
//! create_https_passthrough_route("example.com", "backend", 443),
|
||||
//! ],
|
||||
//! routes: vec![RouteConfig {
|
||||
//! id: None,
|
||||
//! route_match: RouteMatch {
|
||||
//! ports: PortRange::Single(443),
|
||||
//! domains: Some(DomainSpec::Single("example.com".to_string())),
|
||||
//! path: None, client_ip: None, transport: None,
|
||||
//! tls_version: None, headers: None, protocol: None,
|
||||
//! },
|
||||
//! action: RouteAction {
|
||||
//! action_type: RouteActionType::Forward,
|
||||
//! targets: Some(vec![RouteTarget {
|
||||
//! target_match: None,
|
||||
//! host: HostSpec::Single("backend".to_string()),
|
||||
//! port: PortSpec::Fixed(443),
|
||||
//! tls: None, websocket: None, load_balancing: None,
|
||||
//! send_proxy_protocol: None, headers: None, advanced: None,
|
||||
//! backend_transport: None, priority: None,
|
||||
//! }]),
|
||||
//! tls: Some(RouteTls {
|
||||
//! mode: TlsMode::Passthrough,
|
||||
//! certificate: None, acme: None, versions: None,
|
||||
//! ciphers: None, honor_cipher_order: None, session_timeout: None,
|
||||
//! }),
|
||||
//! websocket: None, load_balancing: None, advanced: None,
|
||||
//! options: None, send_proxy_protocol: None, udp: None,
|
||||
//! },
|
||||
//! headers: None, security: None, name: None, description: None,
|
||||
//! priority: None, tags: None, enabled: None,
|
||||
//! }],
|
||||
//! ..Default::default()
|
||||
//! };
|
||||
//!
|
||||
@@ -41,16 +67,14 @@ pub use rustproxy_routing;
|
||||
pub use rustproxy_passthrough;
|
||||
pub use rustproxy_tls;
|
||||
pub use rustproxy_http;
|
||||
pub use rustproxy_nftables;
|
||||
pub use rustproxy_metrics;
|
||||
pub use rustproxy_security;
|
||||
|
||||
use rustproxy_config::{RouteConfig, RustProxyOptions, TlsMode, CertificateSpec, ForwardingEngine};
|
||||
use rustproxy_config::{RouteConfig, RustProxyOptions, TlsMode, CertificateSpec};
|
||||
use rustproxy_routing::RouteManager;
|
||||
use rustproxy_passthrough::{TcpListenerManager, UdpListenerManager, TlsCertConfig, ConnectionConfig};
|
||||
use rustproxy_metrics::{MetricsCollector, Metrics, Statistics};
|
||||
use rustproxy_tls::{CertManager, CertStore, CertBundle, CertMetadata, CertSource};
|
||||
use rustproxy_nftables::{NftManager, rule_builder};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Certificate status.
|
||||
@@ -74,7 +98,6 @@ pub struct RustProxy {
|
||||
challenge_server: Option<challenge_server::ChallengeServer>,
|
||||
renewal_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
sampling_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
nft_manager: Option<NftManager>,
|
||||
started: bool,
|
||||
started_at: Option<Instant>,
|
||||
/// Shared path to a Unix domain socket for relaying socket-handler connections back to TypeScript.
|
||||
@@ -121,7 +144,6 @@ impl RustProxy {
|
||||
challenge_server: None,
|
||||
renewal_handle: None,
|
||||
sampling_handle: None,
|
||||
nft_manager: None,
|
||||
started: false,
|
||||
started_at: None,
|
||||
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
|
||||
@@ -363,6 +385,7 @@ impl RustProxy {
|
||||
// Start the throughput sampling task with cooperative cancellation
|
||||
let metrics = Arc::clone(&self.metrics);
|
||||
let conn_tracker = self.listener_manager.as_ref().unwrap().conn_tracker().clone();
|
||||
let http_proxy = self.listener_manager.as_ref().unwrap().http_proxy().clone();
|
||||
let interval_ms = self.options.metrics.as_ref()
|
||||
.and_then(|m| m.sample_interval_ms)
|
||||
.unwrap_or(1000);
|
||||
@@ -378,14 +401,14 @@ impl RustProxy {
|
||||
metrics.sample_all();
|
||||
// Periodically clean up stale rate-limit timestamp entries
|
||||
conn_tracker.cleanup_stale_timestamps();
|
||||
// Clean up expired rate limiter entries to prevent unbounded
|
||||
// growth from unique IPs after traffic stops
|
||||
http_proxy.cleanup_all_rate_limiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Apply NFTables rules for routes using nftables forwarding engine
|
||||
self.apply_nftables_rules(&self.options.routes.clone()).await;
|
||||
|
||||
// Start renewal timer if ACME is enabled
|
||||
self.start_renewal_timer();
|
||||
|
||||
@@ -612,14 +635,6 @@ impl RustProxy {
|
||||
}
|
||||
self.challenge_server = None;
|
||||
|
||||
// Clean up NFTables rules
|
||||
if let Some(ref mut nft) = self.nft_manager {
|
||||
if let Err(e) = nft.cleanup().await {
|
||||
warn!("NFTables cleanup failed: {}", e);
|
||||
}
|
||||
}
|
||||
self.nft_manager = None;
|
||||
|
||||
if let Some(ref mut listener) = self.listener_manager {
|
||||
listener.graceful_stop().await;
|
||||
}
|
||||
@@ -821,9 +836,6 @@ impl RustProxy {
|
||||
}
|
||||
}
|
||||
|
||||
// Update NFTables rules: remove old, apply new
|
||||
self.update_nftables_rules(&routes).await;
|
||||
|
||||
self.options.routes = routes;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1145,99 +1157,6 @@ impl RustProxy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get NFTables status.
|
||||
pub async fn get_nftables_status(&self) -> Result<HashMap<String, serde_json::Value>> {
|
||||
match &self.nft_manager {
|
||||
Some(nft) => Ok(nft.status()),
|
||||
None => Ok(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply NFTables rules for routes using the nftables forwarding engine.
|
||||
async fn apply_nftables_rules(&mut self, routes: &[RouteConfig]) {
|
||||
let nft_routes: Vec<&RouteConfig> = routes.iter()
|
||||
.filter(|r| r.action.forwarding_engine.as_ref() == Some(&ForwardingEngine::Nftables))
|
||||
.collect();
|
||||
|
||||
if nft_routes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Applying NFTables rules for {} routes", nft_routes.len());
|
||||
|
||||
let table_name = nft_routes.iter()
|
||||
.find_map(|r| r.action.nftables.as_ref()?.table_name.clone())
|
||||
.unwrap_or_else(|| "rustproxy".to_string());
|
||||
|
||||
let mut nft = NftManager::new(Some(table_name));
|
||||
|
||||
for route in &nft_routes {
|
||||
let route_id = route.id.as_deref()
|
||||
.or(route.name.as_deref())
|
||||
.unwrap_or("unnamed");
|
||||
|
||||
let nft_options = match &route.action.nftables {
|
||||
Some(opts) => opts.clone(),
|
||||
None => rustproxy_config::NfTablesOptions {
|
||||
preserve_source_ip: None,
|
||||
protocol: None,
|
||||
max_rate: None,
|
||||
priority: None,
|
||||
table_name: None,
|
||||
use_ip_sets: None,
|
||||
use_advanced_nat: None,
|
||||
},
|
||||
};
|
||||
|
||||
let targets = match &route.action.targets {
|
||||
Some(targets) => targets,
|
||||
None => {
|
||||
warn!("NFTables route '{}' has no targets, skipping", route_id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let source_ports = route.route_match.ports.to_ports();
|
||||
for target in targets {
|
||||
let target_host = target.host.first().to_string();
|
||||
let target_port_spec = &target.port;
|
||||
|
||||
for &source_port in &source_ports {
|
||||
let resolved_port = target_port_spec.resolve(source_port);
|
||||
let rules = rule_builder::build_dnat_rule(
|
||||
nft.table_name(),
|
||||
"prerouting",
|
||||
source_port,
|
||||
&target_host,
|
||||
resolved_port,
|
||||
&nft_options,
|
||||
);
|
||||
|
||||
let rule_id = format!("{}-{}-{}", route_id, source_port, resolved_port);
|
||||
if let Err(e) = nft.apply_rules(&rule_id, rules).await {
|
||||
error!("Failed to apply NFTables rules for route '{}': {}", route_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.nft_manager = Some(nft);
|
||||
}
|
||||
|
||||
/// Update NFTables rules when routes change.
|
||||
async fn update_nftables_rules(&mut self, new_routes: &[RouteConfig]) {
|
||||
// Clean up old rules
|
||||
if let Some(ref mut nft) = self.nft_manager {
|
||||
if let Err(e) = nft.cleanup().await {
|
||||
warn!("NFTables cleanup during update failed: {}", e);
|
||||
}
|
||||
}
|
||||
self.nft_manager = None;
|
||||
|
||||
// Apply new rules
|
||||
self.apply_nftables_rules(new_routes).await;
|
||||
}
|
||||
|
||||
/// Extract TLS configurations from route configs.
|
||||
fn extract_tls_configs(routes: &[RouteConfig]) -> HashMap<String, TlsCertConfig> {
|
||||
let mut configs = HashMap::new();
|
||||
|
||||
@@ -147,7 +147,6 @@ async fn handle_request(
|
||||
"renewCertificate" => handle_renew_certificate(&id, &request.params, proxy).await,
|
||||
"getCertificateStatus" => handle_get_certificate_status(&id, &request.params, proxy).await,
|
||||
"getListeningPorts" => handle_get_listening_ports(&id, proxy),
|
||||
"getNftablesStatus" => handle_get_nftables_status(&id, proxy).await,
|
||||
"setSocketHandlerRelay" => handle_set_socket_handler_relay(&id, &request.params, proxy).await,
|
||||
"setDatagramHandlerRelay" => handle_set_datagram_handler_relay(&id, &request.params, proxy).await,
|
||||
"addListeningPort" => handle_add_listening_port(&id, &request.params, proxy).await,
|
||||
@@ -352,26 +351,6 @@ fn handle_get_listening_ports(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_get_nftables_status(
|
||||
id: &str,
|
||||
proxy: &Option<RustProxy>,
|
||||
) -> ManagementResponse {
|
||||
match proxy.as_ref() {
|
||||
Some(p) => {
|
||||
match p.get_nftables_status().await {
|
||||
Ok(status) => {
|
||||
match serde_json::to_value(&status) {
|
||||
Ok(v) => ManagementResponse::ok(id.to_string(), v),
|
||||
Err(e) => ManagementResponse::err(id.to_string(), format!("Failed to serialize: {}", e)),
|
||||
}
|
||||
}
|
||||
Err(e) => ManagementResponse::err(id.to_string(), format!("Failed to get status: {}", e)),
|
||||
}
|
||||
}
|
||||
None => ManagementResponse::ok(id.to_string(), serde_json::json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_set_socket_handler_relay(
|
||||
id: &str,
|
||||
params: &serde_json::Value,
|
||||
|
||||
@@ -297,8 +297,6 @@ pub fn make_test_route(
|
||||
load_balancing: None,
|
||||
advanced: None,
|
||||
options: None,
|
||||
forwarding_engine: None,
|
||||
nftables: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import {
|
||||
createHttpsTerminateRoute,
|
||||
createCompleteHttpsServer,
|
||||
createHttpRoute,
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
import {
|
||||
mergeRouteConfigs,
|
||||
cloneRoute,
|
||||
@@ -19,8 +13,11 @@ import {
|
||||
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
tap.test('route creation - createHttpsTerminateRoute produces correct structure', async () => {
|
||||
const route = createHttpsTerminateRoute('secure.example.com', { host: '127.0.0.1', port: 8443 });
|
||||
tap.test('route creation - HTTPS terminate route has correct structure', async () => {
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8443 }], tls: { mode: 'terminate', certificate: 'auto' } }
|
||||
};
|
||||
expect(route).toHaveProperty('match');
|
||||
expect(route).toHaveProperty('action');
|
||||
expect(route.action.type).toEqual('forward');
|
||||
@@ -29,20 +26,10 @@ tap.test('route creation - createHttpsTerminateRoute produces correct structure'
|
||||
expect(route.match.domains).toEqual('secure.example.com');
|
||||
});
|
||||
|
||||
tap.test('route creation - createCompleteHttpsServer returns redirect and main route', async () => {
|
||||
const routes = createCompleteHttpsServer('app.example.com', { host: '127.0.0.1', port: 3000 });
|
||||
expect(routes).toBeArray();
|
||||
expect(routes.length).toBeGreaterThanOrEqual(2);
|
||||
// Should have an HTTP→HTTPS redirect and an HTTPS route
|
||||
const hasRedirect = routes.some((r) => r.action.type === 'forward' && r.action.redirect !== undefined);
|
||||
const hasHttps = routes.some((r) => r.action.tls?.mode === 'terminate');
|
||||
expect(hasRedirect || hasHttps).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('route validation - validateRoutes on a set of routes', async () => {
|
||||
const routes: IRouteConfig[] = [
|
||||
createHttpRoute('a.com', { host: '127.0.0.1', port: 3000 }),
|
||||
createHttpRoute('b.com', { host: '127.0.0.1', port: 4000 }),
|
||||
{ match: { ports: 80, domains: 'a.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
|
||||
{ match: { ports: 80, domains: 'b.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] } },
|
||||
];
|
||||
const result = validateRoutes(routes);
|
||||
expect(result.valid).toBeTrue();
|
||||
@@ -51,7 +38,7 @@ tap.test('route validation - validateRoutes on a set of routes', async () => {
|
||||
|
||||
tap.test('route validation - validateRoutes catches invalid route in set', async () => {
|
||||
const routes: any[] = [
|
||||
createHttpRoute('valid.com', { host: '127.0.0.1', port: 3000 }),
|
||||
{ match: { ports: 80, domains: 'valid.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
|
||||
{ match: { ports: 80 } }, // missing action
|
||||
];
|
||||
const result = validateRoutes(routes);
|
||||
@@ -60,23 +47,30 @@ tap.test('route validation - validateRoutes catches invalid route in set', async
|
||||
});
|
||||
|
||||
tap.test('path matching - routeMatchesPath with exact path', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
route.match.path = '/api';
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com', path: '/api' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesPath(route, '/api')).toBeTrue();
|
||||
expect(routeMatchesPath(route, '/other')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('path matching - route without path matches everything', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
// No path set, should match any path
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesPath(route, '/anything')).toBeTrue();
|
||||
expect(routeMatchesPath(route, '/')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
|
||||
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
base.priority = 10;
|
||||
base.name = 'base-route';
|
||||
const base: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
priority: 10,
|
||||
name: 'base-route'
|
||||
};
|
||||
|
||||
const merged = mergeRouteConfigs(base, {
|
||||
priority: 50,
|
||||
@@ -85,14 +79,16 @@ tap.test('route merging - mergeRouteConfigs combines routes', async () => {
|
||||
|
||||
expect(merged.priority).toEqual(50);
|
||||
expect(merged.name).toEqual('merged-route');
|
||||
// Original route fields should be preserved
|
||||
expect(merged.match.domains).toEqual('example.com');
|
||||
expect(merged.action.targets![0].host).toEqual('127.0.0.1');
|
||||
});
|
||||
|
||||
tap.test('route merging - mergeRouteConfigs does not mutate original', async () => {
|
||||
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
base.name = 'original';
|
||||
const base: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
name: 'original'
|
||||
};
|
||||
|
||||
const merged = mergeRouteConfigs(base, { name: 'changed' });
|
||||
expect(base.name).toEqual('original');
|
||||
@@ -100,20 +96,21 @@ tap.test('route merging - mergeRouteConfigs does not mutate original', async ()
|
||||
});
|
||||
|
||||
tap.test('route cloning - cloneRoute produces independent copy', async () => {
|
||||
const original = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
original.priority = 42;
|
||||
original.name = 'original-route';
|
||||
const original: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
priority: 42,
|
||||
name: 'original-route'
|
||||
};
|
||||
|
||||
const cloned = cloneRoute(original);
|
||||
|
||||
// Should be equal in value
|
||||
expect(cloned.match.domains).toEqual('example.com');
|
||||
expect(cloned.priority).toEqual(42);
|
||||
expect(cloned.name).toEqual('original-route');
|
||||
expect(cloned.action.targets![0].host).toEqual('127.0.0.1');
|
||||
expect(cloned.action.targets![0].port).toEqual(3000);
|
||||
|
||||
// Should be independent - modifying clone shouldn't affect original
|
||||
cloned.name = 'cloned-route';
|
||||
cloned.priority = 99;
|
||||
expect(original.name).toEqual('original-route');
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import {
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createLoadBalancerRoute,
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
import {
|
||||
findMatchingRoutes,
|
||||
findBestMatchingRoute,
|
||||
@@ -22,24 +16,11 @@ import {
|
||||
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
tap.test('route creation - createHttpRoute produces correct structure', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
expect(route).toHaveProperty('match');
|
||||
expect(route).toHaveProperty('action');
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.targets).toBeArray();
|
||||
expect(route.action.targets![0].host).toEqual('127.0.0.1');
|
||||
expect(route.action.targets![0].port).toEqual(3000);
|
||||
});
|
||||
|
||||
tap.test('route creation - createHttpRoute with array of domains', async () => {
|
||||
const route = createHttpRoute(['a.com', 'b.com'], { host: 'localhost', port: 8080 });
|
||||
expect(route.match.domains).toEqual(['a.com', 'b.com']);
|
||||
});
|
||||
|
||||
tap.test('route validation - validateRouteConfig accepts valid route', async () => {
|
||||
const route = createHttpRoute('valid.example.com', { host: '10.0.0.1', port: 8080 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'valid.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.1', port: 8080 }] }
|
||||
};
|
||||
const result = validateRouteConfig(route);
|
||||
expect(result.valid).toBeTrue();
|
||||
expect(result.errors).toHaveLength(0);
|
||||
@@ -67,30 +48,44 @@ tap.test('route validation - isValidPort checks correctly', async () => {
|
||||
});
|
||||
|
||||
tap.test('domain matching - exact domain', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesDomain(route, 'example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(route, 'other.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('domain matching - wildcard domain', async () => {
|
||||
const route = createHttpRoute('*.example.com', { host: '127.0.0.1', port: 3000 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: '*.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesDomain(route, 'sub.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(route, 'example.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('port matching - single port', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
// createHttpRoute defaults to port 80
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesPort(route, 80)).toBeTrue();
|
||||
expect(routeMatchesPort(route, 443)).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
|
||||
const lowPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
lowPriority.priority = 10;
|
||||
const lowPriority: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
priority: 10
|
||||
};
|
||||
|
||||
const highPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
|
||||
highPriority.priority = 100;
|
||||
const highPriority: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] },
|
||||
priority: 100
|
||||
};
|
||||
|
||||
const routes: IRouteConfig[] = [lowPriority, highPriority];
|
||||
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||
@@ -100,9 +95,18 @@ tap.test('route finding - findBestMatchingRoute selects by priority', async () =
|
||||
});
|
||||
|
||||
tap.test('route finding - findMatchingRoutes returns all matches', async () => {
|
||||
const route1 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
const route2 = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
|
||||
const route3 = createHttpRoute('other.com', { host: '127.0.0.1', port: 5000 });
|
||||
const route1: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
const route2: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] }
|
||||
};
|
||||
const route3: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'other.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 5000 }] }
|
||||
};
|
||||
|
||||
const matches = findMatchingRoutes([route1, route2, route3], { domain: 'example.com', port: 80 });
|
||||
expect(matches).toHaveLength(2);
|
||||
|
||||
@@ -2,146 +2,101 @@ import * as path from 'path';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import {
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createHttpsPassthroughRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createCompleteHttpsServer,
|
||||
createLoadBalancerRoute,
|
||||
createApiRoute,
|
||||
createWebSocketRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
// Test to demonstrate various route configurations using the new helpers
|
||||
tap.test('Route-based configuration examples', async (tools) => {
|
||||
// Example 1: HTTP-only configuration
|
||||
const httpOnlyRoute = createHttpRoute(
|
||||
'http.example.com',
|
||||
{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
},
|
||||
{
|
||||
name: 'Basic HTTP Route'
|
||||
}
|
||||
);
|
||||
const httpOnlyRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'http.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'Basic HTTP Route'
|
||||
};
|
||||
|
||||
console.log('HTTP-only route created successfully:', httpOnlyRoute.name);
|
||||
expect(httpOnlyRoute.action.type).toEqual('forward');
|
||||
expect(httpOnlyRoute.match.domains).toEqual('http.example.com');
|
||||
|
||||
// Example 2: HTTPS Passthrough (SNI) configuration
|
||||
const httpsPassthroughRoute = createHttpsPassthroughRoute(
|
||||
'pass.example.com',
|
||||
{
|
||||
host: ['10.0.0.1', '10.0.0.2'], // Round-robin target IPs
|
||||
port: 443
|
||||
},
|
||||
{
|
||||
name: 'HTTPS Passthrough Route'
|
||||
}
|
||||
);
|
||||
const httpsPassthroughRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'pass.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.1', port: 443 }, { host: '10.0.0.2', port: 443 }], tls: { mode: 'passthrough' } },
|
||||
name: 'HTTPS Passthrough Route'
|
||||
};
|
||||
|
||||
expect(httpsPassthroughRoute).toBeTruthy();
|
||||
expect(httpsPassthroughRoute.action.tls?.mode).toEqual('passthrough');
|
||||
expect(Array.isArray(httpsPassthroughRoute.action.targets)).toBeTrue();
|
||||
|
||||
// Example 3: HTTPS Termination to HTTP Backend
|
||||
const terminateToHttpRoute = createHttpsTerminateRoute(
|
||||
'secure.example.com',
|
||||
{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
},
|
||||
{
|
||||
certificate: 'auto',
|
||||
name: 'HTTPS Termination to HTTP Backend'
|
||||
}
|
||||
);
|
||||
const terminateToHttpRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'HTTPS Termination to HTTP Backend'
|
||||
};
|
||||
|
||||
// Create the HTTP to HTTPS redirect for this domain
|
||||
const httpToHttpsRedirect = createHttpToHttpsRedirect(
|
||||
'secure.example.com',
|
||||
443,
|
||||
{
|
||||
name: 'HTTP to HTTPS Redirect for secure.example.com'
|
||||
}
|
||||
);
|
||||
const httpToHttpsRedirect: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'secure.example.com' },
|
||||
action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
|
||||
name: 'HTTP to HTTPS Redirect for secure.example.com'
|
||||
};
|
||||
|
||||
expect(terminateToHttpRoute).toBeTruthy();
|
||||
expect(terminateToHttpRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(httpToHttpsRedirect.action.type).toEqual('socket-handler');
|
||||
|
||||
// Example 4: Load Balancer with HTTPS
|
||||
const loadBalancerRoute = createLoadBalancerRoute(
|
||||
'proxy.example.com',
|
||||
['internal-api-1.local', 'internal-api-2.local'],
|
||||
8443,
|
||||
{
|
||||
tls: {
|
||||
mode: 'terminate-and-reencrypt',
|
||||
certificate: 'auto'
|
||||
},
|
||||
name: 'Load Balanced HTTPS Route'
|
||||
}
|
||||
);
|
||||
const loadBalancerRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'proxy.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{ host: 'internal-api-1.local', port: 8443 },
|
||||
{ host: 'internal-api-2.local', port: 8443 }
|
||||
],
|
||||
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
|
||||
},
|
||||
name: 'Load Balanced HTTPS Route'
|
||||
};
|
||||
|
||||
expect(loadBalancerRoute).toBeTruthy();
|
||||
expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
expect(Array.isArray(loadBalancerRoute.action.targets)).toBeTrue();
|
||||
|
||||
// Example 5: API Route
|
||||
const apiRoute = createApiRoute(
|
||||
'api.example.com',
|
||||
'/api',
|
||||
{ host: 'localhost', port: 8081 },
|
||||
{
|
||||
name: 'API Route',
|
||||
useTls: true,
|
||||
addCorsHeaders: true
|
||||
}
|
||||
);
|
||||
const apiRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/api' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8081 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
name: 'API Route'
|
||||
};
|
||||
|
||||
expect(apiRoute.action.type).toEqual('forward');
|
||||
expect(apiRoute.match.path).toBeTruthy();
|
||||
|
||||
// Example 6: Complete HTTPS Server with HTTP Redirect
|
||||
const httpsServerRoutes = createCompleteHttpsServer(
|
||||
'complete.example.com',
|
||||
{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
const httpsRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'complete.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'Complete HTTPS Server'
|
||||
};
|
||||
|
||||
const httpsRedirectRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'complete.example.com' },
|
||||
action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
|
||||
name: 'Complete HTTPS Server - Redirect'
|
||||
};
|
||||
|
||||
const webSocketRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/ws' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8082 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: { enabled: true }
|
||||
},
|
||||
{
|
||||
certificate: 'auto',
|
||||
name: 'Complete HTTPS Server'
|
||||
}
|
||||
);
|
||||
|
||||
expect(Array.isArray(httpsServerRoutes)).toBeTrue();
|
||||
expect(httpsServerRoutes.length).toEqual(2); // HTTPS route and HTTP redirect
|
||||
expect(httpsServerRoutes[0].action.tls?.mode).toEqual('terminate');
|
||||
expect(httpsServerRoutes[1].action.type).toEqual('socket-handler');
|
||||
|
||||
// Example 7: Static File Server - removed (use nginx/apache behind proxy)
|
||||
|
||||
// Example 8: WebSocket Route
|
||||
const webSocketRoute = createWebSocketRoute(
|
||||
'ws.example.com',
|
||||
'/ws',
|
||||
{ host: 'localhost', port: 8082 },
|
||||
{
|
||||
useTls: true,
|
||||
name: 'WebSocket Route'
|
||||
}
|
||||
);
|
||||
name: 'WebSocket Route'
|
||||
};
|
||||
|
||||
expect(webSocketRoute.action.type).toEqual('forward');
|
||||
expect(webSocketRoute.action.websocket?.enabled).toBeTrue();
|
||||
|
||||
// Create a SmartProxy instance with all routes
|
||||
const allRoutes: IRouteConfig[] = [
|
||||
httpOnlyRoute,
|
||||
httpsPassthroughRoute,
|
||||
@@ -149,19 +104,17 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
httpToHttpsRedirect,
|
||||
loadBalancerRoute,
|
||||
apiRoute,
|
||||
...httpsServerRoutes,
|
||||
httpsRoute,
|
||||
httpsRedirectRoute,
|
||||
webSocketRoute
|
||||
];
|
||||
|
||||
// We're not actually starting the SmartProxy in this test,
|
||||
// just verifying that the configuration is valid
|
||||
const smartProxy = new SmartProxy({
|
||||
routes: allRoutes
|
||||
});
|
||||
|
||||
// Just verify that all routes are configured correctly
|
||||
console.log(`Created ${allRoutes.length} example routes`);
|
||||
expect(allRoutes.length).toEqual(9); // One less without static file route
|
||||
expect(allRoutes.length).toEqual(9);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import route-based helpers
|
||||
import {
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createHttpsPassthroughRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createCompleteHttpsServer
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
// Create helper functions for backward compatibility
|
||||
const helpers = {
|
||||
httpOnly: (domains: string | string[], target: any) => createHttpRoute(domains, target),
|
||||
tlsTerminateToHttp: (domains: string | string[], target: any) =>
|
||||
createHttpsTerminateRoute(domains, target),
|
||||
tlsTerminateToHttps: (domains: string | string[], target: any) =>
|
||||
createHttpsTerminateRoute(domains, target, { reencrypt: true }),
|
||||
httpsPassthrough: (domains: string | string[], target: any) =>
|
||||
createHttpsPassthroughRoute(domains, target)
|
||||
};
|
||||
|
||||
// Route-based utility functions for testing
|
||||
function findRouteForDomain(routes: any[], domain: string): any {
|
||||
return routes.find(route => {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
@@ -31,55 +12,44 @@ function findRouteForDomain(routes: any[], domain: string): any {
|
||||
});
|
||||
}
|
||||
|
||||
// Replace the old test with route-based tests
|
||||
tap.test('Route Helpers - Create HTTP routes', async () => {
|
||||
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] }
|
||||
};
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {
|
||||
const route = helpers.tlsTerminateToHttp('secure.example.com', { host: 'localhost', port: 3000 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } }
|
||||
};
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.match.domains).toEqual('secure.example.com');
|
||||
expect(route.action.tls?.mode).toEqual('terminate');
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - Create HTTPS passthrough routes', async () => {
|
||||
const route = helpers.httpsPassthrough('passthrough.example.com', { host: 'backend', port: 443 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'passthrough.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 443 }], tls: { mode: 'passthrough' } }
|
||||
};
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.match.domains).toEqual('passthrough.example.com');
|
||||
expect(route.action.tls?.mode).toEqual('passthrough');
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - Create HTTPS to HTTPS routes', async () => {
|
||||
const route = helpers.tlsTerminateToHttps('reencrypt.example.com', { host: 'backend', port: 443 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'reencrypt.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 443 }], tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' } }
|
||||
};
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.match.domains).toEqual('reencrypt.example.com');
|
||||
expect(route.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - Create complete HTTPS server with redirect', async () => {
|
||||
const routes = createCompleteHttpsServer(
|
||||
'full.example.com',
|
||||
{ host: 'localhost', port: 3000 },
|
||||
{ certificate: 'auto' }
|
||||
);
|
||||
|
||||
expect(routes.length).toEqual(2);
|
||||
|
||||
// Check HTTP to HTTPS redirect - find route by port
|
||||
const redirectRoute = routes.find(r => r.match.ports === 80);
|
||||
expect(redirectRoute.action.type).toEqual('socket-handler');
|
||||
expect(redirectRoute.action.socketHandler).toBeDefined();
|
||||
expect(redirectRoute.match.ports).toEqual(80);
|
||||
|
||||
// Check HTTPS route
|
||||
const httpsRoute = routes.find(r => r.action.type === 'forward');
|
||||
expect(httpsRoute.match.ports).toEqual(443);
|
||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
||||
});
|
||||
|
||||
// Export test runner
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Check if we have root privileges to run NFTables tests
|
||||
async function checkRootPrivileges(): Promise<boolean> {
|
||||
try {
|
||||
// Check if we're running as root
|
||||
const { stdout } = await exec('id -u');
|
||||
return stdout.trim() === '0';
|
||||
} catch (err) {
|
||||
@@ -17,7 +16,6 @@ async function checkRootPrivileges(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tests should run
|
||||
const isRoot = await checkRootPrivileges();
|
||||
|
||||
if (!isRoot) {
|
||||
@@ -29,68 +27,70 @@ if (!isRoot) {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Define the test with proper skip condition
|
||||
const testFn = isRoot ? tap.test : tap.skip.test;
|
||||
|
||||
testFn('NFTables integration tests', async () => {
|
||||
|
||||
|
||||
console.log('Running NFTables tests with root privileges');
|
||||
|
||||
// Create test routes
|
||||
const routes = [
|
||||
createNfTablesRoute('tcp-forward', {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: 9080,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
createNfTablesRoute('udp-forward', {
|
||||
host: 'localhost',
|
||||
port: 5353
|
||||
}, {
|
||||
ports: 5354,
|
||||
protocol: 'udp'
|
||||
}),
|
||||
|
||||
createNfTablesRoute('port-range', {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: [{ from: 9000, to: 9100 }],
|
||||
protocol: 'tcp'
|
||||
})
|
||||
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
match: { ports: 9080 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'tcp-forward'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: 5354 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: 5353 }],
|
||||
nftables: { protocol: 'udp' }
|
||||
},
|
||||
name: 'udp-forward'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: [{ from: 9000, to: 9100 }] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'port-range'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const smartProxy = new SmartProxy({
|
||||
enableDetailedLogging: true,
|
||||
routes
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
|
||||
await smartProxy.start();
|
||||
console.log('SmartProxy started with NFTables routes');
|
||||
|
||||
// Get NFTables status
|
||||
|
||||
const status = await smartProxy.getNfTablesStatus();
|
||||
console.log('NFTables status:', JSON.stringify(status, null, 2));
|
||||
|
||||
// Verify all routes are provisioned
|
||||
|
||||
expect(Object.keys(status).length).toEqual(routes.length);
|
||||
|
||||
|
||||
for (const routeStatus of Object.values(status)) {
|
||||
expect(routeStatus.active).toBeTrue();
|
||||
expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Stop the proxy
|
||||
|
||||
await smartProxy.stop();
|
||||
console.log('SmartProxy stopped');
|
||||
|
||||
// Verify all rules are cleaned up
|
||||
|
||||
const finalStatus = await smartProxy.getNfTablesStatus();
|
||||
expect(Object.keys(finalStatus).length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as http from 'http';
|
||||
@@ -10,13 +9,13 @@ import { fileURLToPath } from 'url';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Check if we have root privileges
|
||||
async function checkRootPrivileges(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await exec('id -u');
|
||||
@@ -26,7 +25,6 @@ async function checkRootPrivileges(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tests should run
|
||||
const runTests = await checkRootPrivileges();
|
||||
|
||||
if (!runTests) {
|
||||
@@ -36,10 +34,8 @@ if (!runTests) {
|
||||
console.log('Skipping NFTables integration tests');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
// Skip tests when not running as root - tests are marked with tap.skip.test
|
||||
}
|
||||
|
||||
// Test server and client utilities
|
||||
let testTcpServer: net.Server;
|
||||
let testHttpServer: http.Server;
|
||||
let testHttpsServer: https.Server;
|
||||
@@ -53,10 +49,8 @@ const PROXY_HTTP_PORT = 5001;
|
||||
const PROXY_HTTPS_PORT = 5002;
|
||||
const TEST_DATA = 'Hello through NFTables!';
|
||||
|
||||
// Helper to create test certificates
|
||||
async function createTestCertificates() {
|
||||
try {
|
||||
// Import the certificate helper
|
||||
const certsModule = await import('./helpers/certificates.js');
|
||||
const certificates = certsModule.loadTestCertificates();
|
||||
return {
|
||||
@@ -65,7 +59,6 @@ async function createTestCertificates() {
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load test certificates:', err);
|
||||
// Use dummy certificates for testing
|
||||
return {
|
||||
cert: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'),
|
||||
key: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8')
|
||||
@@ -75,111 +68,112 @@ async function createTestCertificates() {
|
||||
|
||||
tap.skip.test('setup NFTables integration test environment', async () => {
|
||||
console.log('Running NFTables integration tests with root privileges');
|
||||
|
||||
// Create a basic TCP test server
|
||||
|
||||
testTcpServer = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
socket.write(`Server says: ${data.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testTcpServer.listen(TEST_TCP_PORT, () => {
|
||||
console.log(`TCP test server listening on port ${TEST_TCP_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create an HTTP test server
|
||||
|
||||
testHttpServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end(`HTTP Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpServer.listen(TEST_HTTP_PORT, () => {
|
||||
console.log(`HTTP test server listening on port ${TEST_HTTP_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create an HTTPS test server
|
||||
|
||||
const certs = await createTestCertificates();
|
||||
testHttpsServer = https.createServer({ key: certs.key, cert: certs.cert }, (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end(`HTTPS Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpsServer.listen(TEST_HTTPS_PORT, () => {
|
||||
console.log(`HTTPS test server listening on port ${TEST_HTTPS_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create SmartProxy with various NFTables routes
|
||||
|
||||
smartProxy = new SmartProxy({
|
||||
enableDetailedLogging: true,
|
||||
routes: [
|
||||
// TCP forwarding route
|
||||
createNfTablesRoute('tcp-nftables', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: PROXY_TCP_PORT,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
// HTTP forwarding route
|
||||
createNfTablesRoute('http-nftables', {
|
||||
host: 'localhost',
|
||||
port: TEST_HTTP_PORT
|
||||
}, {
|
||||
ports: PROXY_HTTP_PORT,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
// HTTPS termination route
|
||||
createNfTablesTerminateRoute('https-nftables.example.com', {
|
||||
host: 'localhost',
|
||||
port: TEST_HTTPS_PORT
|
||||
}, {
|
||||
ports: PROXY_HTTPS_PORT,
|
||||
protocol: 'tcp',
|
||||
certificate: certs
|
||||
}),
|
||||
|
||||
// Route with IP allow list
|
||||
createNfTablesRoute('secure-tcp', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: 5003,
|
||||
protocol: 'tcp',
|
||||
ipAllowList: ['127.0.0.1', '::1']
|
||||
}),
|
||||
|
||||
// Route with QoS settings
|
||||
createNfTablesRoute('qos-tcp', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: 5004,
|
||||
protocol: 'tcp',
|
||||
maxRate: '10mbps',
|
||||
priority: 1
|
||||
})
|
||||
{
|
||||
match: { ports: PROXY_TCP_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'tcp-nftables'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: PROXY_HTTP_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_HTTP_PORT }],
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'http-nftables'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: PROXY_HTTPS_PORT, domains: 'https-nftables.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_HTTPS_PORT }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'https-nftables'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: 5003 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||
nftables: { protocol: 'tcp', ipAllowList: ['127.0.0.1', '::1'] }
|
||||
},
|
||||
name: 'secure-tcp'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: 5004 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||
nftables: { protocol: 'tcp', maxRate: '10mbps', priority: 1 }
|
||||
},
|
||||
name: 'qos-tcp'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
console.log('SmartProxy created, now starting...');
|
||||
|
||||
// Start the proxy
|
||||
|
||||
try {
|
||||
await smartProxy.start();
|
||||
console.log('SmartProxy started successfully');
|
||||
|
||||
// Verify proxy is listening on expected ports
|
||||
|
||||
const listeningPorts = smartProxy.getListeningPorts();
|
||||
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
|
||||
} catch (err) {
|
||||
@@ -190,8 +184,7 @@ tap.skip.test('setup NFTables integration test environment', async () => {
|
||||
|
||||
tap.skip.test('should forward TCP connections through NFTables', async () => {
|
||||
console.log(`Attempting to connect to proxy TCP port ${PROXY_TCP_PORT}...`);
|
||||
|
||||
// First verify our test server is running
|
||||
|
||||
try {
|
||||
const testClient = new net.Socket();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -205,40 +198,39 @@ tap.skip.test('should forward TCP connections through NFTables', async () => {
|
||||
} catch (err) {
|
||||
console.error(`Test server on port ${TEST_TCP_PORT} is not accessible: ${err}`);
|
||||
}
|
||||
|
||||
// Connect to the proxy port
|
||||
|
||||
const client = new net.Socket();
|
||||
|
||||
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
let responseData = '';
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
reject(new Error(`Connection timeout after 5 seconds to proxy port ${PROXY_TCP_PORT}`));
|
||||
}, 5000);
|
||||
|
||||
|
||||
client.connect(PROXY_TCP_PORT, 'localhost', () => {
|
||||
console.log(`Connected to proxy port ${PROXY_TCP_PORT}, sending data...`);
|
||||
client.write(TEST_DATA);
|
||||
});
|
||||
|
||||
|
||||
client.on('data', (data) => {
|
||||
console.log(`Received data from proxy: ${data.toString()}`);
|
||||
responseData += data.toString();
|
||||
client.end();
|
||||
});
|
||||
|
||||
|
||||
client.on('end', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(responseData);
|
||||
});
|
||||
|
||||
|
||||
client.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`Connection error on proxy port ${PROXY_TCP_PORT}: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
expect(response).toEqual(`Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
@@ -254,21 +246,20 @@ tap.skip.test('should forward HTTP connections through NFTables', async () => {
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
|
||||
|
||||
expect(response).toEqual(`HTTP Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
tap.skip.test('should handle HTTPS termination with NFTables', async () => {
|
||||
// Skip this test if running without proper certificates
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: PROXY_HTTPS_PORT,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
rejectUnauthorized: false // For self-signed cert
|
||||
rejectUnauthorized: false
|
||||
};
|
||||
|
||||
|
||||
https.get(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
@@ -279,43 +270,40 @@ tap.skip.test('should handle HTTPS termination with NFTables', async () => {
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
|
||||
|
||||
expect(response).toEqual(`HTTPS Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
tap.skip.test('should respect IP allow lists in NFTables', async () => {
|
||||
// This test should pass since we're connecting from localhost
|
||||
const client = new net.Socket();
|
||||
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
resolve(false);
|
||||
}, 2000);
|
||||
|
||||
|
||||
client.connect(5003, 'localhost', () => {
|
||||
clearTimeout(timeout);
|
||||
client.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
|
||||
client.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
expect(connected).toBeTrue();
|
||||
});
|
||||
|
||||
tap.skip.test('should get NFTables status', async () => {
|
||||
const status = await smartProxy.getNfTablesStatus();
|
||||
|
||||
// Check that we have status for our routes
|
||||
|
||||
const statusKeys = Object.keys(status);
|
||||
expect(statusKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// Check status structure for one of the routes
|
||||
|
||||
const firstStatus = status[statusKeys[0]];
|
||||
expect(firstStatus).toHaveProperty('active');
|
||||
expect(firstStatus).toHaveProperty('ruleCount');
|
||||
@@ -324,21 +312,20 @@ tap.skip.test('should get NFTables status', async () => {
|
||||
});
|
||||
|
||||
tap.skip.test('cleanup NFTables integration test environment', async () => {
|
||||
// Stop the proxy and test servers
|
||||
await smartProxy.stop();
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testTcpServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpsServer.close(() => {
|
||||
resolve();
|
||||
@@ -346,4 +333,4 @@ tap.skip.test('cleanup NFTables integration test environment', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import {
|
||||
createPortMappingRoute,
|
||||
createOffsetPortMappingRoute,
|
||||
createDynamicRoute,
|
||||
createSmartLoadBalancer,
|
||||
createPortOffset
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
|
||||
|
||||
// Test server and client utilities
|
||||
let testServers: Array<{ server: net.Server; port: number }> = [];
|
||||
let smartProxy: SmartProxy;
|
||||
|
||||
let TEST_PORTS: number[]; // 3 test server ports
|
||||
let PROXY_PORTS: number[]; // 6 proxy ports
|
||||
let TEST_PORTS: number[];
|
||||
let PROXY_PORTS: number[];
|
||||
const TEST_DATA = 'Hello through dynamic port mapper!';
|
||||
|
||||
// Cleanup function to close all servers and proxies
|
||||
function cleanup() {
|
||||
console.log('Starting cleanup...');
|
||||
const promises = [];
|
||||
|
||||
// Close test servers
|
||||
|
||||
for (const { server, port } of testServers) {
|
||||
promises.push(new Promise<void>(resolve => {
|
||||
console.log(`Closing test server on port ${port}`);
|
||||
@@ -34,31 +24,28 @@ function cleanup() {
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Stop SmartProxy
|
||||
|
||||
if (smartProxy) {
|
||||
console.log('Stopping SmartProxy...');
|
||||
promises.push(smartProxy.stop().then(() => {
|
||||
console.log('SmartProxy stopped');
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
// Helper: Creates a test TCP server that listens on a given port
|
||||
function createTestServer(port: number): Promise<net.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
// Echo the received data back with a server identifier
|
||||
socket.write(`Server ${port} says: ${data.toString()}`);
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
console.error(`[Test Server] Socket error on port ${port}:`, error);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`[Test Server] Listening on port ${port}`);
|
||||
testServers.push({ server, port });
|
||||
@@ -67,32 +54,31 @@ function createTestServer(port: number): Promise<net.Server> {
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Creates a test client connection with timeout
|
||||
function createTestClient(port: number, data: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new net.Socket();
|
||||
let response = '';
|
||||
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
reject(new Error(`Client connection timeout to port ${port}`));
|
||||
}, 5000);
|
||||
|
||||
|
||||
client.connect(port, 'localhost', () => {
|
||||
console.log(`[Test Client] Connected to server on port ${port}`);
|
||||
client.write(data);
|
||||
});
|
||||
|
||||
|
||||
client.on('data', (chunk) => {
|
||||
response += chunk.toString();
|
||||
client.end();
|
||||
});
|
||||
|
||||
|
||||
client.on('end', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
});
|
||||
|
||||
|
||||
client.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
@@ -100,123 +86,108 @@ function createTestClient(port: number, data: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// Set up test environment
|
||||
tap.test('setup port mapping test environment', async () => {
|
||||
const allPorts = await findFreePorts(9);
|
||||
TEST_PORTS = allPorts.slice(0, 3);
|
||||
PROXY_PORTS = allPorts.slice(3, 9);
|
||||
|
||||
// Create multiple test servers on different ports
|
||||
await Promise.all([
|
||||
createTestServer(TEST_PORTS[0]),
|
||||
createTestServer(TEST_PORTS[1]),
|
||||
createTestServer(TEST_PORTS[2]),
|
||||
]);
|
||||
|
||||
// Compute dynamic offset between proxy and test ports
|
||||
const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
|
||||
|
||||
// Create a SmartProxy with dynamic port mapping routes
|
||||
smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
// Simple function that returns the same port (identity mapping)
|
||||
createPortMappingRoute({
|
||||
sourcePortRange: PROXY_PORTS[0],
|
||||
targetHost: 'localhost',
|
||||
portMapper: (context) => TEST_PORTS[0],
|
||||
name: 'Identity Port Mapping'
|
||||
}),
|
||||
|
||||
// Offset port mapping using dynamic offset
|
||||
createOffsetPortMappingRoute({
|
||||
ports: PROXY_PORTS[1],
|
||||
targetHost: 'localhost',
|
||||
offset: portOffset,
|
||||
name: `Offset Port Mapping (${portOffset})`
|
||||
}),
|
||||
|
||||
// Dynamic route with conditional port mapping
|
||||
createDynamicRoute({
|
||||
ports: [PROXY_PORTS[2], PROXY_PORTS[3]],
|
||||
targetHost: (context) => {
|
||||
// Dynamic host selection based on port
|
||||
return context.port === PROXY_PORTS[2] ? 'localhost' : '127.0.0.1';
|
||||
{
|
||||
match: { ports: PROXY_PORTS[0] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: (context: IRouteContext) => TEST_PORTS[0]
|
||||
}]
|
||||
},
|
||||
portMapper: (context) => {
|
||||
// Port mapping logic based on incoming port
|
||||
if (context.port === PROXY_PORTS[2]) {
|
||||
return TEST_PORTS[0];
|
||||
} else {
|
||||
return TEST_PORTS[2];
|
||||
}
|
||||
name: 'Identity Port Mapping'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: PROXY_PORTS[1] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: (context: IRouteContext) => context.port + portOffset
|
||||
}]
|
||||
},
|
||||
name: `Offset Port Mapping (${portOffset})`
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: [PROXY_PORTS[2], PROXY_PORTS[3]] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: (context: IRouteContext) => {
|
||||
return context.port === PROXY_PORTS[2] ? 'localhost' : '127.0.0.1';
|
||||
},
|
||||
port: (context: IRouteContext) => {
|
||||
if (context.port === PROXY_PORTS[2]) {
|
||||
return TEST_PORTS[0];
|
||||
} else {
|
||||
return TEST_PORTS[2];
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
name: 'Dynamic Host and Port Mapping'
|
||||
}),
|
||||
},
|
||||
|
||||
// Smart load balancer for domain-based routing
|
||||
createSmartLoadBalancer({
|
||||
ports: PROXY_PORTS[4],
|
||||
domainTargets: {
|
||||
'test1.example.com': 'localhost',
|
||||
'test2.example.com': '127.0.0.1'
|
||||
{
|
||||
match: { ports: PROXY_PORTS[4] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: (context: IRouteContext) => {
|
||||
if (context.domain === 'test1.example.com') return 'localhost';
|
||||
if (context.domain === 'test2.example.com') return '127.0.0.1';
|
||||
return 'localhost';
|
||||
},
|
||||
port: (context: IRouteContext) => {
|
||||
if (context.domain === 'test1.example.com') {
|
||||
return TEST_PORTS[0];
|
||||
} else {
|
||||
return TEST_PORTS[1];
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
portMapper: (context) => {
|
||||
// Use different backend ports based on domain
|
||||
if (context.domain === 'test1.example.com') {
|
||||
return TEST_PORTS[0];
|
||||
} else {
|
||||
return TEST_PORTS[1];
|
||||
}
|
||||
},
|
||||
defaultTarget: 'localhost',
|
||||
name: 'Smart Domain Load Balancer'
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Start the SmartProxy
|
||||
await smartProxy.start();
|
||||
});
|
||||
|
||||
// Test 1: Simple identity port mapping
|
||||
tap.test('should map port using identity function', async () => {
|
||||
const response = await createTestClient(PROXY_PORTS[0], TEST_DATA);
|
||||
expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
// Test 2: Offset port mapping
|
||||
tap.test('should map port using offset function', async () => {
|
||||
const response = await createTestClient(PROXY_PORTS[1], TEST_DATA);
|
||||
expect(response).toEqual(`Server ${TEST_PORTS[1]} says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
// Test 3: Dynamic port and host mapping (conditional logic)
|
||||
tap.test('should map port using dynamic logic', async () => {
|
||||
const response = await createTestClient(PROXY_PORTS[2], TEST_DATA);
|
||||
expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
// Test 4: Test reuse of createPortOffset helper
|
||||
tap.test('should use createPortOffset helper for port mapping', async () => {
|
||||
// Test the createPortOffset helper with dynamic offset
|
||||
const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
|
||||
const offsetFn = createPortOffset(portOffset);
|
||||
const context = {
|
||||
port: PROXY_PORTS[1],
|
||||
clientIp: '127.0.0.1',
|
||||
serverIp: '127.0.0.1',
|
||||
isTls: false,
|
||||
timestamp: Date.now(),
|
||||
connectionId: 'test-connection'
|
||||
} as IRouteContext;
|
||||
|
||||
const mappedPort = offsetFn(context);
|
||||
expect(mappedPort).toEqual(TEST_PORTS[1]);
|
||||
});
|
||||
|
||||
// Test 5: Test error handling for invalid port mapping functions
|
||||
tap.test('should handle errors in port mapping functions', async () => {
|
||||
// Create a route with a function that throws an error
|
||||
const errorRoute: IRouteConfig = {
|
||||
match: {
|
||||
ports: PROXY_PORTS[5]
|
||||
@@ -232,34 +203,27 @@ tap.test('should handle errors in port mapping functions', async () => {
|
||||
},
|
||||
name: 'Error Route'
|
||||
};
|
||||
|
||||
// Add the route to SmartProxy
|
||||
|
||||
await smartProxy.updateRoutes([...smartProxy.settings.routes, errorRoute]);
|
||||
|
||||
// The connection should fail or timeout
|
||||
|
||||
try {
|
||||
await createTestClient(PROXY_PORTS[5], TEST_DATA);
|
||||
// Connection should not succeed
|
||||
expect(false).toBeTrue();
|
||||
} catch (error) {
|
||||
// Connection failed as expected
|
||||
expect(true).toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
tap.test('cleanup port mapping test environment', async () => {
|
||||
// Add timeout to prevent hanging if SmartProxy shutdown has issues
|
||||
const cleanupPromise = cleanup();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Cleanup timeout after 5 seconds')), 5000)
|
||||
);
|
||||
|
||||
|
||||
try {
|
||||
await Promise.race([cleanupPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
console.error('Cleanup error:', error);
|
||||
// Force cleanup even if there's an error
|
||||
testServers = [];
|
||||
smartProxy = null as any;
|
||||
}
|
||||
@@ -267,4 +231,4 @@ tap.test('cleanup port mapping test environment', async () => {
|
||||
await assertPortsFree([...TEST_PORTS, ...PROXY_PORTS]);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
// Import from core modules
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
|
||||
// Import route utilities and helpers
|
||||
// Import route utilities
|
||||
import {
|
||||
findMatchingRoutes,
|
||||
findBestMatchingRoute,
|
||||
@@ -28,16 +28,7 @@ import {
|
||||
assertValidRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||
|
||||
import {
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createHttpsPassthroughRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createCompleteHttpsServer,
|
||||
createLoadBalancerRoute,
|
||||
createApiRoute,
|
||||
createWebSocketRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
|
||||
|
||||
// Import test helpers
|
||||
import { loadTestCertificates } from './helpers/certificates.js';
|
||||
@@ -47,12 +38,12 @@ import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.
|
||||
// --------------------------------- Route Creation Tests ---------------------------------
|
||||
|
||||
tap.test('Routes: Should create basic HTTP route', async () => {
|
||||
// Create a simple HTTP route
|
||||
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }, {
|
||||
const httpRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'Basic HTTP Route'
|
||||
});
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(httpRoute.match.ports).toEqual(80);
|
||||
expect(httpRoute.match.domains).toEqual('example.com');
|
||||
expect(httpRoute.action.type).toEqual('forward');
|
||||
@@ -62,14 +53,17 @@ tap.test('Routes: Should create basic HTTP route', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
|
||||
// Create an HTTPS route with TLS termination
|
||||
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8080 }, {
|
||||
certificate: 'auto',
|
||||
const httpsRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
name: 'HTTPS Route'
|
||||
});
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(httpsRoute.match.ports).toEqual(443); // Default HTTPS port
|
||||
expect(httpsRoute.match.ports).toEqual(443);
|
||||
expect(httpsRoute.match.domains).toEqual('secure.example.com');
|
||||
expect(httpsRoute.action.type).toEqual('forward');
|
||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
||||
@@ -80,10 +74,15 @@ tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
|
||||
// Create an HTTP to HTTPS redirect
|
||||
const redirectRoute = createHttpToHttpsRedirect('example.com', 443);
|
||||
const redirectRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
},
|
||||
name: 'HTTP to HTTPS Redirect for example.com'
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(redirectRoute.match.ports).toEqual(80);
|
||||
expect(redirectRoute.match.domains).toEqual('example.com');
|
||||
expect(redirectRoute.action.type).toEqual('socket-handler');
|
||||
@@ -91,22 +90,34 @@ tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create complete HTTPS server with redirects', async () => {
|
||||
// Create a complete HTTPS server setup
|
||||
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 8080 }, {
|
||||
certificate: 'auto'
|
||||
});
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
name: 'HTTPS Terminate Route for example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
},
|
||||
name: 'HTTP to HTTPS Redirect for example.com'
|
||||
}
|
||||
];
|
||||
|
||||
// Validate that we got two routes (HTTPS route and HTTP redirect)
|
||||
expect(routes.length).toEqual(2);
|
||||
|
||||
// Validate HTTPS route
|
||||
const httpsRoute = routes[0];
|
||||
expect(httpsRoute.match.ports).toEqual(443);
|
||||
expect(httpsRoute.match.domains).toEqual('example.com');
|
||||
expect(httpsRoute.action.type).toEqual('forward');
|
||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
||||
|
||||
// Validate HTTP redirect route
|
||||
const redirectRoute = routes[1];
|
||||
expect(redirectRoute.match.ports).toEqual(80);
|
||||
expect(redirectRoute.action.type).toEqual('socket-handler');
|
||||
@@ -114,21 +125,17 @@ tap.test('Routes: Should create complete HTTPS server with redirects', async ()
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create load balancer route', async () => {
|
||||
// Create a load balancer route
|
||||
const lbRoute = createLoadBalancerRoute(
|
||||
'app.example.com',
|
||||
['10.0.0.1', '10.0.0.2', '10.0.0.3'],
|
||||
8080,
|
||||
{
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
},
|
||||
name: 'Load Balanced Route'
|
||||
}
|
||||
);
|
||||
const lbRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: ['10.0.0.1', '10.0.0.2', '10.0.0.3'], port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
loadBalancing: { algorithm: 'round-robin' }
|
||||
},
|
||||
name: 'Load Balanced Route'
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(lbRoute.match.domains).toEqual('app.example.com');
|
||||
expect(lbRoute.action.type).toEqual('forward');
|
||||
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
|
||||
@@ -139,23 +146,32 @@ tap.test('Routes: Should create load balancer route', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create API route with CORS', async () => {
|
||||
// Create an API route with CORS headers
|
||||
const apiRoute = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
addCorsHeaders: true,
|
||||
const apiRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
headers: {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
},
|
||||
priority: 100,
|
||||
name: 'API Route'
|
||||
});
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(apiRoute.match.domains).toEqual('api.example.com');
|
||||
expect(apiRoute.match.path).toEqual('/v1/*');
|
||||
expect(apiRoute.action.type).toEqual('forward');
|
||||
expect(apiRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check CORS headers
|
||||
|
||||
expect(apiRoute.headers).toBeDefined();
|
||||
if (apiRoute.headers?.response) {
|
||||
expect(apiRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
|
||||
@@ -164,23 +180,25 @@ tap.test('Routes: Should create API route with CORS', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create WebSocket route', async () => {
|
||||
// Create a WebSocket route
|
||||
const wsRoute = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 5000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
pingInterval: 15000,
|
||||
const wsRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 5000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: { enabled: true, pingInterval: 15000 }
|
||||
},
|
||||
priority: 100,
|
||||
name: 'WebSocket Route'
|
||||
});
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(wsRoute.match.domains).toEqual('ws.example.com');
|
||||
expect(wsRoute.match.path).toEqual('/socket');
|
||||
expect(wsRoute.action.type).toEqual('forward');
|
||||
expect(wsRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
|
||||
|
||||
// Check WebSocket configuration
|
||||
|
||||
expect(wsRoute.action.websocket).toBeDefined();
|
||||
if (wsRoute.action.websocket) {
|
||||
expect(wsRoute.action.websocket.enabled).toBeTrue();
|
||||
@@ -191,22 +209,27 @@ tap.test('Routes: Should create WebSocket route', async () => {
|
||||
// Static file serving has been removed - should be handled by external servers
|
||||
|
||||
tap.test('SmartProxy: Should create instance with route-based config', async () => {
|
||||
// Create TLS certificates for testing
|
||||
const certs = loadTestCertificates();
|
||||
|
||||
// Create a SmartProxy instance with route-based configuration
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createHttpRoute('example.com', { host: 'localhost', port: 3000 }, {
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route'
|
||||
}),
|
||||
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8443 }, {
|
||||
certificate: {
|
||||
key: certs.privateKey,
|
||||
cert: certs.publicKey
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8443 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: { key: certs.privateKey, cert: certs.publicKey }
|
||||
}
|
||||
},
|
||||
name: 'HTTPS Route'
|
||||
})
|
||||
}
|
||||
],
|
||||
defaults: {
|
||||
target: {
|
||||
@@ -218,13 +241,11 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
|
||||
maxConnections: 100
|
||||
}
|
||||
},
|
||||
// Additional settings
|
||||
initialDataTimeout: 10000,
|
||||
inactivityTimeout: 300000,
|
||||
enableDetailedLogging: true
|
||||
});
|
||||
|
||||
// Simply verify the instance was created successfully
|
||||
expect(typeof proxy).toEqual('object');
|
||||
expect(typeof proxy.start).toEqual('function');
|
||||
expect(typeof proxy.stop).toEqual('function');
|
||||
@@ -233,94 +254,109 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
|
||||
// --------------------------------- Edge Case Tests ---------------------------------
|
||||
|
||||
tap.test('Edge Case - Empty Routes Array', async () => {
|
||||
// Attempting to find routes in an empty array
|
||||
const emptyRoutes: IRouteConfig[] = [];
|
||||
const matches = findMatchingRoutes(emptyRoutes, { domain: 'example.com', port: 80 });
|
||||
|
||||
|
||||
expect(matches).toBeInstanceOf(Array);
|
||||
expect(matches.length).toEqual(0);
|
||||
|
||||
|
||||
const bestMatch = findBestMatchingRoute(emptyRoutes, { domain: 'example.com', port: 80 });
|
||||
expect(bestMatch).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Multiple Matching Routes with Same Priority', async () => {
|
||||
// Create multiple routes with identical priority but different targets
|
||||
const route1 = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
||||
const route2 = createHttpRoute('example.com', { host: 'server2', port: 3000 });
|
||||
const route3 = createHttpRoute('example.com', { host: 'server3', port: 3000 });
|
||||
|
||||
// Set all to the same priority
|
||||
const route1: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
const route2: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server2', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
const route3: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server3', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
|
||||
route1.priority = 100;
|
||||
route2.priority = 100;
|
||||
route3.priority = 100;
|
||||
|
||||
|
||||
const routes = [route1, route2, route3];
|
||||
|
||||
// Find matching routes
|
||||
|
||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||
|
||||
// Should find all three routes
|
||||
|
||||
expect(matches.length).toEqual(3);
|
||||
|
||||
// First match could be any of the routes since they have the same priority
|
||||
// But the implementation should be consistent (likely keep the original order)
|
||||
|
||||
const bestMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||
expect(bestMatch).not.toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
|
||||
// Create routes with wildcard domains and path patterns
|
||||
const wildcardApiRoute = createApiRoute('*.example.com', '/api', { host: 'api-server', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto'
|
||||
});
|
||||
|
||||
const exactApiRoute = createApiRoute('api.example.com', '/api', { host: 'specific-api-server', port: 3001 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
priority: 200 // Higher priority
|
||||
});
|
||||
|
||||
const wildcardApiRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: '*.example.com', path: '/api/*' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'api-server', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
priority: 100,
|
||||
name: 'API Route for *.example.com'
|
||||
};
|
||||
|
||||
const exactApiRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'specific-api-server', port: 3001 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
priority: 200,
|
||||
name: 'API Route for api.example.com'
|
||||
};
|
||||
|
||||
const routes = [wildcardApiRoute, exactApiRoute];
|
||||
|
||||
// Test with a specific subdomain that matches both routes
|
||||
|
||||
const matches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
|
||||
|
||||
// Should match both routes
|
||||
|
||||
expect(matches.length).toEqual(2);
|
||||
|
||||
// The exact domain match should have higher priority
|
||||
|
||||
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
|
||||
expect(bestMatch).not.toBeUndefined();
|
||||
if (bestMatch) {
|
||||
expect(bestMatch.action.targets[0].port).toEqual(3001); // Should match the exact domain route
|
||||
expect(bestMatch.action.targets[0].port).toEqual(3001);
|
||||
}
|
||||
|
||||
// Test with a different subdomain - should only match the wildcard route
|
||||
|
||||
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
|
||||
expect(otherMatches.length).toEqual(1);
|
||||
expect(otherMatches[0].action.targets[0].port).toEqual(3000); // Should match the wildcard domain route
|
||||
expect(otherMatches[0].action.targets[0].port).toEqual(3000);
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Disabled Routes', async () => {
|
||||
// Create enabled and disabled routes
|
||||
const enabledRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
||||
const disabledRoute = createHttpRoute('example.com', { host: 'server2', port: 3001 });
|
||||
const enabledRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
const disabledRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server2', port: 3001 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
disabledRoute.enabled = false;
|
||||
|
||||
|
||||
const routes = [enabledRoute, disabledRoute];
|
||||
|
||||
// Find matching routes
|
||||
|
||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||
|
||||
// Should only find the enabled route
|
||||
|
||||
expect(matches.length).toEqual(1);
|
||||
expect(matches[0].action.targets[0].port).toEqual(3000);
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||
// Create route with complex path and headers matching
|
||||
const complexRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'api.example.com',
|
||||
@@ -344,22 +380,20 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||
},
|
||||
name: 'Complex API Route'
|
||||
};
|
||||
|
||||
// Test with matching criteria
|
||||
|
||||
const matchingPath = routeMatchesPath(complexRoute, '/api/v2/users');
|
||||
expect(matchingPath).toBeTrue();
|
||||
|
||||
|
||||
const matchingHeaders = routeMatchesHeaders(complexRoute, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'valid-key',
|
||||
'Accept': 'application/json'
|
||||
});
|
||||
expect(matchingHeaders).toBeTrue();
|
||||
|
||||
// Test with non-matching criteria
|
||||
|
||||
const nonMatchingPath = routeMatchesPath(complexRoute, '/api/v1/users');
|
||||
expect(nonMatchingPath).toBeFalse();
|
||||
|
||||
|
||||
const nonMatchingHeaders = routeMatchesHeaders(complexRoute, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'invalid-key'
|
||||
@@ -368,7 +402,6 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Port Range Matching', async () => {
|
||||
// Create route with port range matching
|
||||
const portRangeRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -383,17 +416,14 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
||||
},
|
||||
name: 'Port Range Route'
|
||||
};
|
||||
|
||||
// Test with ports in the range
|
||||
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue(); // Lower bound
|
||||
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue(); // Middle
|
||||
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue(); // Upper bound
|
||||
|
||||
// Test with ports outside the range
|
||||
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse(); // Just below
|
||||
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse(); // Just above
|
||||
|
||||
// Test with multiple port ranges
|
||||
|
||||
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
|
||||
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
|
||||
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue();
|
||||
|
||||
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse();
|
||||
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse();
|
||||
|
||||
const multiRangeRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -411,7 +441,7 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
||||
},
|
||||
name: 'Multi Range Route'
|
||||
};
|
||||
|
||||
|
||||
expect(routeMatchesPort(multiRangeRoute, 85)).toBeTrue();
|
||||
expect(routeMatchesPort(multiRangeRoute, 8500)).toBeTrue();
|
||||
expect(routeMatchesPort(multiRangeRoute, 100)).toBeFalse();
|
||||
@@ -420,55 +450,56 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
||||
// --------------------------------- Wildcard Domain Tests ---------------------------------
|
||||
|
||||
tap.test('Wildcard Domain Handling', async () => {
|
||||
// Create routes with different wildcard patterns
|
||||
const simpleDomainRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
||||
const wildcardSubdomainRoute = createHttpRoute('*.example.com', { host: 'server2', port: 3001 });
|
||||
const specificSubdomainRoute = createHttpRoute('api.example.com', { host: 'server3', port: 3002 });
|
||||
const simpleDomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
const wildcardSubdomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: '*.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server2', port: 3001 }] },
|
||||
name: 'HTTP Route for *.example.com'
|
||||
};
|
||||
const specificSubdomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'api.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server3', port: 3002 }] },
|
||||
name: 'HTTP Route for api.example.com'
|
||||
};
|
||||
|
||||
// Set explicit priorities to ensure deterministic matching
|
||||
specificSubdomainRoute.priority = 200; // Highest priority for specific domain
|
||||
wildcardSubdomainRoute.priority = 100; // Medium priority for wildcard
|
||||
simpleDomainRoute.priority = 50; // Lowest priority for generic domain
|
||||
specificSubdomainRoute.priority = 200;
|
||||
wildcardSubdomainRoute.priority = 100;
|
||||
simpleDomainRoute.priority = 50;
|
||||
|
||||
const routes = [simpleDomainRoute, wildcardSubdomainRoute, specificSubdomainRoute];
|
||||
|
||||
// Test exact domain match
|
||||
expect(routeMatchesDomain(simpleDomainRoute, 'example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(simpleDomainRoute, 'sub.example.com')).toBeFalse();
|
||||
|
||||
// Test wildcard subdomain match
|
||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'any.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'nested.sub.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'example.com')).toBeFalse();
|
||||
|
||||
// Test specific subdomain match
|
||||
expect(routeMatchesDomain(specificSubdomainRoute, 'api.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(specificSubdomainRoute, 'other.example.com')).toBeFalse();
|
||||
expect(routeMatchesDomain(specificSubdomainRoute, 'sub.api.example.com')).toBeFalse();
|
||||
|
||||
// Test finding best match when multiple domains match
|
||||
const specificSubdomainRequest = { domain: 'api.example.com', port: 80 };
|
||||
const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest);
|
||||
expect(bestSpecificMatch).not.toBeUndefined();
|
||||
if (bestSpecificMatch) {
|
||||
// Find which route was matched
|
||||
const matchedPort = bestSpecificMatch.action.targets[0].port;
|
||||
console.log(`Matched route with port: ${matchedPort}`);
|
||||
|
||||
// Verify it's the specific subdomain route (with highest priority)
|
||||
expect(bestSpecificMatch.priority).toEqual(200);
|
||||
}
|
||||
|
||||
// Test with a subdomain that matches wildcard but not specific
|
||||
const otherSubdomainRequest = { domain: 'other.example.com', port: 80 };
|
||||
const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest);
|
||||
expect(bestWildcardMatch).not.toBeUndefined();
|
||||
if (bestWildcardMatch) {
|
||||
// Find which route was matched
|
||||
const matchedPort = bestWildcardMatch.action.targets[0].port;
|
||||
console.log(`Matched route with port: ${matchedPort}`);
|
||||
|
||||
// Verify it's the wildcard subdomain route (with medium priority)
|
||||
expect(bestWildcardMatch.priority).toEqual(100);
|
||||
}
|
||||
});
|
||||
@@ -476,56 +507,83 @@ tap.test('Wildcard Domain Handling', async () => {
|
||||
// --------------------------------- Integration Tests ---------------------------------
|
||||
|
||||
tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
// Create a comprehensive set of routes for a full application
|
||||
const routes: IRouteConfig[] = [
|
||||
// Main website with HTTPS and HTTP redirect
|
||||
...createCompleteHttpsServer('example.com', { host: 'web-server', port: 8080 }, {
|
||||
certificate: 'auto'
|
||||
}),
|
||||
|
||||
// API endpoints
|
||||
createApiRoute('api.example.com', '/v1', { host: 'api-server', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
addCorsHeaders: true
|
||||
}),
|
||||
|
||||
// WebSocket for real-time updates
|
||||
createWebSocketRoute('ws.example.com', '/live', { host: 'websocket-server', port: 5000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto'
|
||||
}),
|
||||
|
||||
|
||||
// Legacy system with passthrough
|
||||
createHttpsPassthroughRoute('legacy.example.com', { host: 'legacy-server', port: 443 })
|
||||
{
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'web-server', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
name: 'HTTPS Terminate Route for example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
},
|
||||
name: 'HTTP to HTTPS Redirect for example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'api-server', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
headers: {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
},
|
||||
priority: 100,
|
||||
name: 'API Route for api.example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/live' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'websocket-server', port: 5000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: { enabled: true }
|
||||
},
|
||||
priority: 100,
|
||||
name: 'WebSocket Route for ws.example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'legacy.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'legacy-server', port: 443 }],
|
||||
tls: { mode: 'passthrough' }
|
||||
},
|
||||
name: 'HTTPS Passthrough Route for legacy.example.com'
|
||||
}
|
||||
];
|
||||
|
||||
// Validate all routes
|
||||
|
||||
const validationResult = validateRoutes(routes);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
expect(validationResult.errors.length).toEqual(0);
|
||||
|
||||
// Test route matching for different endpoints
|
||||
|
||||
// Web server (HTTPS)
|
||||
|
||||
const webServerMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
|
||||
expect(webServerMatch).not.toBeUndefined();
|
||||
if (webServerMatch) {
|
||||
expect(webServerMatch.action.type).toEqual('forward');
|
||||
expect(webServerMatch.action.targets[0].host).toEqual('web-server');
|
||||
}
|
||||
|
||||
// Web server (HTTP redirect via socket handler)
|
||||
|
||||
const webRedirectMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||
expect(webRedirectMatch).not.toBeUndefined();
|
||||
if (webRedirectMatch) {
|
||||
expect(webRedirectMatch.action.type).toEqual('socket-handler');
|
||||
}
|
||||
|
||||
// API server
|
||||
const apiMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'api.example.com',
|
||||
|
||||
const apiMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'api.example.com',
|
||||
port: 443,
|
||||
path: '/v1/users'
|
||||
});
|
||||
@@ -534,10 +592,9 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
expect(apiMatch.action.type).toEqual('forward');
|
||||
expect(apiMatch.action.targets[0].host).toEqual('api-server');
|
||||
}
|
||||
|
||||
// WebSocket server
|
||||
const wsMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'ws.example.com',
|
||||
|
||||
const wsMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'ws.example.com',
|
||||
port: 443,
|
||||
path: '/live'
|
||||
});
|
||||
@@ -547,12 +604,9 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
|
||||
expect(wsMatch.action.websocket?.enabled).toBeTrue();
|
||||
}
|
||||
|
||||
// Static assets route was removed - static file serving should be handled externally
|
||||
|
||||
// Legacy system
|
||||
const legacyMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'legacy.example.com',
|
||||
|
||||
const legacyMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'legacy.example.com',
|
||||
port: 443
|
||||
});
|
||||
expect(legacyMatch).not.toBeUndefined();
|
||||
@@ -565,7 +619,6 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
// --------------------------------- Protocol Match Field Tests ---------------------------------
|
||||
|
||||
tap.test('Routes: Should accept protocol field on route match', async () => {
|
||||
// Create a route with protocol: 'http'
|
||||
const httpOnlyRoute: IRouteConfig = {
|
||||
match: {
|
||||
ports: 443,
|
||||
@@ -583,16 +636,13 @@ tap.test('Routes: Should accept protocol field on route match', async () => {
|
||||
name: 'HTTP-only Route',
|
||||
};
|
||||
|
||||
// Validate the route - protocol field should not cause errors
|
||||
const validation = validateRouteConfig(httpOnlyRoute);
|
||||
expect(validation.valid).toBeTrue();
|
||||
|
||||
// Verify the protocol field is preserved
|
||||
expect(httpOnlyRoute.match.protocol).toEqual('http');
|
||||
});
|
||||
|
||||
tap.test('Routes: Should accept protocol tcp on route match', async () => {
|
||||
// Create a route with protocol: 'tcp'
|
||||
const tcpOnlyRoute: IRouteConfig = {
|
||||
match: {
|
||||
ports: 443,
|
||||
@@ -616,28 +666,26 @@ tap.test('Routes: Should accept protocol tcp on route match', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Protocol field should work with terminate-and-reencrypt', async () => {
|
||||
// Create a terminate-and-reencrypt route that only accepts HTTP
|
||||
const reencryptRoute = createHttpsTerminateRoute(
|
||||
'secure.example.com',
|
||||
{ host: 'backend', port: 443 },
|
||||
{ reencrypt: true, certificate: 'auto', name: 'Reencrypt HTTP Route' }
|
||||
);
|
||||
const reencryptRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'backend', port: 443 }],
|
||||
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
|
||||
},
|
||||
name: 'Reencrypt HTTP Route'
|
||||
};
|
||||
|
||||
// Set protocol restriction to http
|
||||
reencryptRoute.match.protocol = 'http';
|
||||
|
||||
// Validate the route
|
||||
const validation = validateRouteConfig(reencryptRoute);
|
||||
expect(validation.valid).toBeTrue();
|
||||
|
||||
// Verify TLS mode
|
||||
expect(reencryptRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
// Verify protocol field is preserved
|
||||
expect(reencryptRoute.match.protocol).toEqual('http');
|
||||
});
|
||||
|
||||
tap.test('Routes: Protocol field should not affect domain/port matching', async () => {
|
||||
// Routes with and without protocol field should both match the same domain/port
|
||||
const routeWithProtocol: IRouteConfig = {
|
||||
match: {
|
||||
ports: 443,
|
||||
@@ -669,11 +717,9 @@ tap.test('Routes: Protocol field should not affect domain/port matching', async
|
||||
|
||||
const routes = [routeWithProtocol, routeWithoutProtocol];
|
||||
|
||||
// Both routes should match the domain/port (protocol is a hint for Rust-side matching)
|
||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 443 });
|
||||
expect(matches.length).toEqual(2);
|
||||
|
||||
// The one with higher priority should be first
|
||||
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
|
||||
expect(best).not.toBeUndefined();
|
||||
expect(best!.name).toEqual('With Protocol');
|
||||
@@ -696,11 +742,9 @@ tap.test('Routes: Protocol field preserved through route cloning', async () => {
|
||||
|
||||
const cloned = cloneRoute(original);
|
||||
|
||||
// Verify protocol is preserved in clone
|
||||
expect(cloned.match.protocol).toEqual('http');
|
||||
expect(cloned.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
|
||||
// Modify clone should not affect original
|
||||
cloned.match.protocol = 'tcp';
|
||||
expect(original.match.protocol).toEqual('http');
|
||||
});
|
||||
@@ -720,10 +764,9 @@ tap.test('Routes: Protocol field preserved through route merging', async () => {
|
||||
name: 'Merge Base',
|
||||
};
|
||||
|
||||
// Merge with override that changes name but not protocol
|
||||
const merged = mergeRouteConfigs(base, { name: 'Merged Route' });
|
||||
expect(merged.match.protocol).toEqual('http');
|
||||
expect(merged.name).toEqual('Merged Route');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import from individual modules to avoid naming conflicts
|
||||
import {
|
||||
// Route helpers
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createApiRoute,
|
||||
createWebSocketRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createHttpsPassthroughRoute,
|
||||
createCompleteHttpsServer,
|
||||
createLoadBalancerRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
import {
|
||||
// Route validators
|
||||
validateRouteConfig,
|
||||
validateRoutes,
|
||||
isValidDomain,
|
||||
@@ -27,7 +13,6 @@ import {
|
||||
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||
|
||||
import {
|
||||
// Route utilities
|
||||
mergeRouteConfigs,
|
||||
findMatchingRoutes,
|
||||
findBestMatchingRoute,
|
||||
@@ -39,16 +24,6 @@ import {
|
||||
cloneRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
|
||||
|
||||
import {
|
||||
// Route patterns
|
||||
createApiGatewayRoute,
|
||||
createWebSocketRoute as createWebSocketPattern,
|
||||
createLoadBalancerRoute as createLbPattern,
|
||||
addRateLimiting,
|
||||
addBasicAuth,
|
||||
addJwtAuth
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
import type {
|
||||
IRouteConfig,
|
||||
IRouteMatch,
|
||||
@@ -84,7 +59,7 @@ tap.test('Route Validation - isValidPort', async () => {
|
||||
expect(isValidPort(443)).toBeTrue();
|
||||
expect(isValidPort(8080)).toBeTrue();
|
||||
expect(isValidPort([80, 443])).toBeTrue();
|
||||
|
||||
|
||||
// Invalid ports
|
||||
expect(isValidPort(0)).toBeFalse();
|
||||
expect(isValidPort(65536)).toBeFalse();
|
||||
@@ -101,7 +76,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
|
||||
const validResult = validateRouteMatch(validMatch);
|
||||
expect(validResult.valid).toBeTrue();
|
||||
expect(validResult.errors.length).toEqual(0);
|
||||
|
||||
|
||||
// Invalid match configuration (invalid domain)
|
||||
const invalidMatch: IRouteMatch = {
|
||||
ports: 80,
|
||||
@@ -111,7 +86,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
|
||||
expect(invalidResult.valid).toBeFalse();
|
||||
expect(invalidResult.errors.length).toBeGreaterThan(0);
|
||||
expect(invalidResult.errors[0]).toInclude('Invalid domain');
|
||||
|
||||
|
||||
// Invalid match configuration (invalid port)
|
||||
const invalidPortMatch: IRouteMatch = {
|
||||
ports: 0,
|
||||
@@ -121,7 +96,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
|
||||
expect(invalidPortResult.valid).toBeFalse();
|
||||
expect(invalidPortResult.errors.length).toBeGreaterThan(0);
|
||||
expect(invalidPortResult.errors[0]).toInclude('Invalid port');
|
||||
|
||||
|
||||
// Test path validation
|
||||
const invalidPathMatch: IRouteMatch = {
|
||||
ports: 80,
|
||||
@@ -146,7 +121,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
const validForwardResult = validateRouteAction(validForwardAction);
|
||||
expect(validForwardResult.valid).toBeTrue();
|
||||
expect(validForwardResult.errors.length).toEqual(0);
|
||||
|
||||
|
||||
// Valid socket-handler action
|
||||
const validSocketAction: IRouteAction = {
|
||||
type: 'socket-handler',
|
||||
@@ -157,7 +132,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
const validSocketResult = validateRouteAction(validSocketAction);
|
||||
expect(validSocketResult.valid).toBeTrue();
|
||||
expect(validSocketResult.errors.length).toEqual(0);
|
||||
|
||||
|
||||
// Invalid action (missing targets)
|
||||
const invalidAction: IRouteAction = {
|
||||
type: 'forward'
|
||||
@@ -166,7 +141,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
expect(invalidResult.valid).toBeFalse();
|
||||
expect(invalidResult.errors.length).toBeGreaterThan(0);
|
||||
expect(invalidResult.errors[0]).toInclude('Targets array is required');
|
||||
|
||||
|
||||
// Invalid action (missing socket handler)
|
||||
const invalidSocketAction: IRouteAction = {
|
||||
type: 'socket-handler'
|
||||
@@ -179,11 +154,15 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
|
||||
tap.test('Route Validation - validateRouteConfig', async () => {
|
||||
// Valid route config
|
||||
const validRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const validRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
const validResult = validateRouteConfig(validRoute);
|
||||
expect(validResult.valid).toBeTrue();
|
||||
expect(validResult.errors.length).toEqual(0);
|
||||
|
||||
|
||||
// Invalid route config (missing targets)
|
||||
const invalidRoute: IRouteConfig = {
|
||||
match: {
|
||||
@@ -203,7 +182,11 @@ tap.test('Route Validation - validateRouteConfig', async () => {
|
||||
tap.test('Route Validation - validateRoutes', async () => {
|
||||
// Create valid and invalid routes
|
||||
const routes = [
|
||||
createHttpRoute('example.com', { host: 'localhost', port: 3000 }),
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
} as IRouteConfig,
|
||||
{
|
||||
match: {
|
||||
domains: 'invalid..domain',
|
||||
@@ -217,9 +200,13 @@ tap.test('Route Validation - validateRoutes', async () => {
|
||||
}
|
||||
}
|
||||
} as IRouteConfig,
|
||||
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 })
|
||||
{
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'HTTPS Terminate Route for secure.example.com',
|
||||
} as IRouteConfig
|
||||
];
|
||||
|
||||
|
||||
const result = validateRoutes(routes);
|
||||
expect(result.valid).toBeFalse();
|
||||
expect(result.errors.length).toEqual(1);
|
||||
@@ -230,13 +217,13 @@ tap.test('Route Validation - validateRoutes', async () => {
|
||||
|
||||
tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
||||
// Forward action
|
||||
const forwardRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const forwardRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
expect(hasRequiredPropertiesForAction(forwardRoute, 'forward')).toBeTrue();
|
||||
|
||||
// Socket handler action (redirect functionality)
|
||||
const redirectRoute = createHttpToHttpsRedirect('example.com');
|
||||
expect(hasRequiredPropertiesForAction(redirectRoute, 'socket-handler')).toBeTrue();
|
||||
|
||||
|
||||
// Socket handler action
|
||||
const socketRoute: IRouteConfig = {
|
||||
match: {
|
||||
@@ -252,7 +239,7 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
||||
name: 'Socket Handler Route'
|
||||
};
|
||||
expect(hasRequiredPropertiesForAction(socketRoute, 'socket-handler')).toBeTrue();
|
||||
|
||||
|
||||
// Missing required properties
|
||||
const invalidForwardRoute: IRouteConfig = {
|
||||
match: {
|
||||
@@ -269,9 +256,13 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
||||
|
||||
tap.test('Route Validation - assertValidRoute', async () => {
|
||||
// Valid route
|
||||
const validRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const validRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
expect(() => assertValidRoute(validRoute)).not.toThrow();
|
||||
|
||||
|
||||
// Invalid route
|
||||
const invalidRoute: IRouteConfig = {
|
||||
match: {
|
||||
@@ -290,8 +281,12 @@ tap.test('Route Validation - assertValidRoute', async () => {
|
||||
|
||||
tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
// Base route
|
||||
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
const baseRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
|
||||
// Override with different name and port
|
||||
const overrideRoute: Partial<IRouteConfig> = {
|
||||
name: 'Merged Route',
|
||||
@@ -299,16 +294,16 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
ports: 8080
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Merge configs
|
||||
const mergedRoute = mergeRouteConfigs(baseRoute, overrideRoute);
|
||||
|
||||
|
||||
// Check merged properties
|
||||
expect(mergedRoute.name).toEqual('Merged Route');
|
||||
expect(mergedRoute.match.ports).toEqual(8080);
|
||||
expect(mergedRoute.match.domains).toEqual('example.com');
|
||||
expect(mergedRoute.action.type).toEqual('forward');
|
||||
|
||||
|
||||
// Test merging action properties
|
||||
const actionOverride: Partial<IRouteConfig> = {
|
||||
action: {
|
||||
@@ -319,11 +314,11 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride);
|
||||
expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local');
|
||||
expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000);
|
||||
|
||||
|
||||
// Test replacing action with socket handler
|
||||
const typeChangeOverride: Partial<IRouteConfig> = {
|
||||
action: {
|
||||
@@ -336,7 +331,7 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride);
|
||||
expect(typeChangedRoute.action.type).toEqual('socket-handler');
|
||||
expect(typeChangedRoute.action.socketHandler).toBeDefined();
|
||||
@@ -345,37 +340,53 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
|
||||
tap.test('Route Matching - routeMatchesDomain', async () => {
|
||||
// Create route with wildcard domain
|
||||
const wildcardRoute = createHttpRoute('*.example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
const wildcardRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: '*.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for *.example.com',
|
||||
};
|
||||
|
||||
// Create route with exact domain
|
||||
const exactRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
const exactRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
|
||||
// Create route with multiple domains
|
||||
const multiDomainRoute = createHttpRoute(['example.com', 'example.org'], { host: 'localhost', port: 3000 });
|
||||
|
||||
const multiDomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: ['example.com', 'example.org'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com,example.org',
|
||||
};
|
||||
|
||||
// Test wildcard domain matching
|
||||
expect(routeMatchesDomain(wildcardRoute, 'sub.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(wildcardRoute, 'another.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(wildcardRoute, 'example.com')).toBeFalse();
|
||||
expect(routeMatchesDomain(wildcardRoute, 'example.org')).toBeFalse();
|
||||
|
||||
|
||||
// Test exact domain matching
|
||||
expect(routeMatchesDomain(exactRoute, 'example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(exactRoute, 'sub.example.com')).toBeFalse();
|
||||
|
||||
|
||||
// Test multiple domains matching
|
||||
expect(routeMatchesDomain(multiDomainRoute, 'example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(multiDomainRoute, 'example.org')).toBeTrue();
|
||||
expect(routeMatchesDomain(multiDomainRoute, 'example.net')).toBeFalse();
|
||||
|
||||
|
||||
// Test case insensitivity
|
||||
expect(routeMatchesDomain(exactRoute, 'Example.Com')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Matching - routeMatchesPort', async () => {
|
||||
// Create routes with different port configurations
|
||||
const singlePortRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
const singlePortRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
|
||||
const multiPortRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -389,7 +400,7 @@ tap.test('Route Matching - routeMatchesPort', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const portRangeRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -403,16 +414,16 @@ tap.test('Route Matching - routeMatchesPort', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Test single port matching
|
||||
expect(routeMatchesPort(singlePortRoute, 80)).toBeTrue();
|
||||
expect(routeMatchesPort(singlePortRoute, 443)).toBeFalse();
|
||||
|
||||
|
||||
// Test multi-port matching
|
||||
expect(routeMatchesPort(multiPortRoute, 80)).toBeTrue();
|
||||
expect(routeMatchesPort(multiPortRoute, 8080)).toBeTrue();
|
||||
expect(routeMatchesPort(multiPortRoute, 3000)).toBeFalse();
|
||||
|
||||
|
||||
// Test port range matching
|
||||
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
|
||||
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
|
||||
@@ -437,11 +448,11 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Test prefix matching with wildcard (not trailing slash)
|
||||
const prefixPathRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
domains: 'example.com',
|
||||
ports: 80,
|
||||
path: '/api/*'
|
||||
},
|
||||
@@ -453,7 +464,7 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const wildcardPathRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -468,17 +479,17 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Test exact path matching
|
||||
expect(routeMatchesPath(exactPathRoute, '/api')).toBeTrue();
|
||||
expect(routeMatchesPath(exactPathRoute, '/api/users')).toBeFalse();
|
||||
expect(routeMatchesPath(exactPathRoute, '/app')).toBeFalse();
|
||||
|
||||
|
||||
// Test prefix path matching with wildcard
|
||||
expect(routeMatchesPath(prefixPathRoute, '/api/')).toBeFalse(); // Wildcard requires content after /api/
|
||||
expect(routeMatchesPath(prefixPathRoute, '/api/users')).toBeTrue();
|
||||
expect(routeMatchesPath(prefixPathRoute, '/app/')).toBeFalse();
|
||||
|
||||
|
||||
// Test wildcard path matching
|
||||
expect(routeMatchesPath(wildcardPathRoute, '/api/users')).toBeTrue();
|
||||
expect(routeMatchesPath(wildcardPathRoute, '/api/products')).toBeTrue();
|
||||
@@ -504,30 +515,34 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Test header matching
|
||||
expect(routeMatchesHeaders(headerRoute, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'value'
|
||||
})).toBeTrue();
|
||||
|
||||
|
||||
expect(routeMatchesHeaders(headerRoute, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'value',
|
||||
'Extra-Header': 'something'
|
||||
})).toBeTrue();
|
||||
|
||||
|
||||
expect(routeMatchesHeaders(headerRoute, {
|
||||
'Content-Type': 'application/json'
|
||||
})).toBeFalse();
|
||||
|
||||
|
||||
expect(routeMatchesHeaders(headerRoute, {
|
||||
'Content-Type': 'text/html',
|
||||
'X-Custom-Header': 'value'
|
||||
})).toBeFalse();
|
||||
|
||||
|
||||
// Route without header matching should match any headers
|
||||
const noHeaderRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const noHeaderRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
expect(routeMatchesHeaders(noHeaderRoute, {
|
||||
'Content-Type': 'application/json'
|
||||
})).toBeTrue();
|
||||
@@ -536,78 +551,118 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
|
||||
tap.test('Route Finding - findMatchingRoutes', async () => {
|
||||
// Create multiple routes
|
||||
const routes: IRouteConfig[] = [
|
||||
createHttpRoute('example.com', { host: 'localhost', port: 3000 }),
|
||||
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 }),
|
||||
createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3002 }),
|
||||
createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3003 })
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'HTTPS Route for secure.example.com',
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3002 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'API Route for api.example.com',
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3003 }], tls: { mode: 'terminate', certificate: 'auto' }, websocket: { enabled: true } },
|
||||
name: 'WebSocket Route for ws.example.com',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// Set priorities
|
||||
routes[0].priority = 10;
|
||||
routes[1].priority = 20;
|
||||
routes[2].priority = 30;
|
||||
routes[3].priority = 40;
|
||||
|
||||
|
||||
// Find routes for different criteria
|
||||
const httpMatches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||
expect(httpMatches.length).toEqual(1);
|
||||
expect(httpMatches[0].name).toInclude('HTTP Route');
|
||||
|
||||
|
||||
const httpsMatches = findMatchingRoutes(routes, { domain: 'secure.example.com', port: 443 });
|
||||
expect(httpsMatches.length).toEqual(1);
|
||||
expect(httpsMatches[0].name).toInclude('HTTPS Route');
|
||||
|
||||
|
||||
const apiMatches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/v1/users' });
|
||||
expect(apiMatches.length).toEqual(1);
|
||||
expect(apiMatches[0].name).toInclude('API Route');
|
||||
|
||||
|
||||
const wsMatches = findMatchingRoutes(routes, { domain: 'ws.example.com', path: '/socket' });
|
||||
expect(wsMatches.length).toEqual(1);
|
||||
expect(wsMatches[0].name).toInclude('WebSocket Route');
|
||||
|
||||
|
||||
// Test finding multiple routes that match same criteria
|
||||
const route1 = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const route1: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route1.priority = 10;
|
||||
|
||||
const route2 = createHttpRoute('example.com', { host: 'localhost', port: 3001 });
|
||||
|
||||
const route2: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route2.priority = 20;
|
||||
route2.match.path = '/api';
|
||||
|
||||
|
||||
const multiMatchRoutes = [route1, route2];
|
||||
|
||||
|
||||
const multiMatches = findMatchingRoutes(multiMatchRoutes, { domain: 'example.com', port: 80 });
|
||||
expect(multiMatches.length).toEqual(2);
|
||||
expect(multiMatches[0].priority).toEqual(20); // Higher priority should be first
|
||||
expect(multiMatches[1].priority).toEqual(10);
|
||||
|
||||
|
||||
// Test disabled routes
|
||||
const disabledRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const disabledRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
disabledRoute.enabled = false;
|
||||
|
||||
|
||||
const enabledRoutes = findMatchingRoutes([disabledRoute], { domain: 'example.com', port: 80 });
|
||||
expect(enabledRoutes.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Route Finding - findBestMatchingRoute', async () => {
|
||||
// Create multiple routes with different priorities
|
||||
const route1 = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const route1: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route1.priority = 10;
|
||||
|
||||
const route2 = createHttpRoute('example.com', { host: 'localhost', port: 3001 });
|
||||
|
||||
const route2: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route2.priority = 20;
|
||||
route2.match.path = '/api';
|
||||
|
||||
const route3 = createHttpRoute('example.com', { host: 'localhost', port: 3002 });
|
||||
|
||||
const route3: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3002 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route3.priority = 30;
|
||||
route3.match.path = '/api/users';
|
||||
|
||||
|
||||
const routes = [route1, route2, route3];
|
||||
|
||||
|
||||
// Find best route for different criteria
|
||||
const bestGeneral = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||
expect(bestGeneral).not.toBeUndefined();
|
||||
expect(bestGeneral?.priority).toEqual(30);
|
||||
|
||||
|
||||
// Test when no routes match
|
||||
const noMatch = findBestMatchingRoute(routes, { domain: 'unknown.com', port: 80 });
|
||||
expect(noMatch).toBeUndefined();
|
||||
@@ -615,389 +670,54 @@ tap.test('Route Finding - findBestMatchingRoute', async () => {
|
||||
|
||||
tap.test('Route Utilities - generateRouteId', async () => {
|
||||
// Test ID generation for different route types
|
||||
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const httpRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
const httpId = generateRouteId(httpRoute);
|
||||
expect(httpId).toInclude('example-com');
|
||||
expect(httpId).toInclude('80');
|
||||
expect(httpId).toInclude('forward');
|
||||
|
||||
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 });
|
||||
|
||||
const httpsRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'HTTPS Terminate Route for secure.example.com',
|
||||
};
|
||||
const httpsId = generateRouteId(httpsRoute);
|
||||
expect(httpsId).toInclude('secure-example-com');
|
||||
expect(httpsId).toInclude('443');
|
||||
expect(httpsId).toInclude('forward');
|
||||
|
||||
const multiDomainRoute = createHttpRoute(['example.com', 'example.org'], { host: 'localhost', port: 3000 });
|
||||
|
||||
const multiDomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: ['example.com', 'example.org'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com,example.org',
|
||||
};
|
||||
const multiDomainId = generateRouteId(multiDomainRoute);
|
||||
expect(multiDomainId).toInclude('example-com-example-org');
|
||||
});
|
||||
|
||||
tap.test('Route Utilities - cloneRoute', async () => {
|
||||
// Create a route and clone it
|
||||
const originalRoute = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, {
|
||||
certificate: 'auto',
|
||||
name: 'Original Route'
|
||||
});
|
||||
|
||||
const originalRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'Original Route',
|
||||
};
|
||||
|
||||
const clonedRoute = cloneRoute(originalRoute);
|
||||
|
||||
|
||||
// Check that the values are identical
|
||||
expect(clonedRoute.name).toEqual(originalRoute.name);
|
||||
expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains);
|
||||
expect(clonedRoute.action.type).toEqual(originalRoute.action.type);
|
||||
expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port);
|
||||
|
||||
|
||||
// Modify the clone and check that the original is unchanged
|
||||
clonedRoute.name = 'Modified Clone';
|
||||
expect(originalRoute.name).toEqual('Original Route');
|
||||
});
|
||||
|
||||
// --------------------------------- Route Helper Tests ---------------------------------
|
||||
|
||||
tap.test('Route Helpers - createHttpRoute', async () => {
|
||||
const route = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(80);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(route.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createHttpsTerminateRoute', async () => {
|
||||
const route = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, {
|
||||
certificate: 'auto'
|
||||
});
|
||||
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
expect(route.action.tls.certificate).toEqual('auto');
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createHttpToHttpsRedirect', async () => {
|
||||
const route = createHttpToHttpsRedirect('example.com');
|
||||
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(80);
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createHttpsPassthroughRoute', async () => {
|
||||
const route = createHttpsPassthroughRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.tls.mode).toEqual('passthrough');
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createCompleteHttpsServer', async () => {
|
||||
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 3000 }, {
|
||||
certificate: 'auto'
|
||||
});
|
||||
|
||||
expect(routes.length).toEqual(2);
|
||||
|
||||
// HTTPS route
|
||||
expect(routes[0].match.domains).toEqual('example.com');
|
||||
expect(routes[0].match.ports).toEqual(443);
|
||||
expect(routes[0].action.type).toEqual('forward');
|
||||
expect(routes[0].action.tls.mode).toEqual('terminate');
|
||||
|
||||
// HTTP redirect route
|
||||
expect(routes[1].match.domains).toEqual('example.com');
|
||||
expect(routes[1].match.ports).toEqual(80);
|
||||
expect(routes[1].action.type).toEqual('socket-handler');
|
||||
|
||||
const validation1 = validateRouteConfig(routes[0]);
|
||||
const validation2 = validateRouteConfig(routes[1]);
|
||||
expect(validation1.valid).toBeTrue();
|
||||
expect(validation2.valid).toBeTrue();
|
||||
});
|
||||
|
||||
// createStaticFileRoute has been removed - static file serving should be handled by
|
||||
// external servers (nginx/apache) behind the proxy
|
||||
|
||||
tap.test('Route Helpers - createApiRoute', async () => {
|
||||
const route = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
addCorsHeaders: true
|
||||
});
|
||||
|
||||
expect(route.match.domains).toEqual('api.example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.match.path).toEqual('/v1/*');
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
|
||||
// Check CORS headers if they exist
|
||||
if (route.headers && route.headers.response) {
|
||||
expect(route.headers.response['Access-Control-Allow-Origin']).toEqual('*');
|
||||
}
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createWebSocketRoute', async () => {
|
||||
const route = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
pingInterval: 15000
|
||||
});
|
||||
|
||||
expect(route.match.domains).toEqual('ws.example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.match.path).toEqual('/socket');
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
|
||||
// Check websocket configuration if it exists
|
||||
if (route.action.websocket) {
|
||||
expect(route.action.websocket.enabled).toBeTrue();
|
||||
expect(route.action.websocket.pingInterval).toEqual(15000);
|
||||
}
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createLoadBalancerRoute', async () => {
|
||||
const route = createLoadBalancerRoute(
|
||||
'loadbalancer.example.com',
|
||||
['server1.local', 'server2.local', 'server3.local'],
|
||||
8080,
|
||||
{
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(route.match.domains).toEqual('loadbalancer.example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.targets).toBeDefined();
|
||||
if (route.action.targets && Array.isArray(route.action.targets[0]?.host)) {
|
||||
expect((route.action.targets[0].host as string[]).length).toEqual(3);
|
||||
}
|
||||
expect(route.action.targets?.[0]?.port).toEqual(8080);
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
// --------------------------------- Route Pattern Tests ---------------------------------
|
||||
|
||||
tap.test('Route Patterns - createApiGatewayRoute', async () => {
|
||||
// Create API Gateway route
|
||||
const apiGatewayRoute = createApiGatewayRoute(
|
||||
'api.example.com',
|
||||
'/v1',
|
||||
{ host: 'localhost', port: 3000 },
|
||||
{
|
||||
useTls: true,
|
||||
addCorsHeaders: true
|
||||
}
|
||||
);
|
||||
|
||||
// Validate route configuration
|
||||
expect(apiGatewayRoute.match.domains).toEqual('api.example.com');
|
||||
expect(apiGatewayRoute.match.path).toInclude('/v1');
|
||||
expect(apiGatewayRoute.action.type).toEqual('forward');
|
||||
expect(apiGatewayRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check TLS configuration
|
||||
if (apiGatewayRoute.action.tls) {
|
||||
expect(apiGatewayRoute.action.tls.mode).toEqual('terminate');
|
||||
}
|
||||
|
||||
// Check CORS headers
|
||||
if (apiGatewayRoute.headers && apiGatewayRoute.headers.response) {
|
||||
expect(apiGatewayRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
|
||||
}
|
||||
|
||||
const result = validateRouteConfig(apiGatewayRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
// createStaticFileServerRoute has been removed - static file serving should be handled by
|
||||
// external servers (nginx/apache) behind the proxy
|
||||
|
||||
tap.test('Route Patterns - createWebSocketPattern', async () => {
|
||||
// Create WebSocket route pattern
|
||||
const wsRoute = createWebSocketPattern(
|
||||
'ws.example.com',
|
||||
{ host: 'localhost', port: 3000 },
|
||||
{
|
||||
useTls: true,
|
||||
path: '/socket',
|
||||
pingInterval: 10000
|
||||
}
|
||||
);
|
||||
|
||||
// Validate route configuration
|
||||
expect(wsRoute.match.domains).toEqual('ws.example.com');
|
||||
expect(wsRoute.match.path).toEqual('/socket');
|
||||
expect(wsRoute.action.type).toEqual('forward');
|
||||
expect(wsRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check TLS configuration
|
||||
if (wsRoute.action.tls) {
|
||||
expect(wsRoute.action.tls.mode).toEqual('terminate');
|
||||
}
|
||||
|
||||
// Check websocket configuration if it exists
|
||||
if (wsRoute.action.websocket) {
|
||||
expect(wsRoute.action.websocket.enabled).toBeTrue();
|
||||
expect(wsRoute.action.websocket.pingInterval).toEqual(10000);
|
||||
}
|
||||
|
||||
const result = validateRouteConfig(wsRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Patterns - createLoadBalancerRoute pattern', async () => {
|
||||
// Create load balancer route pattern with missing algorithm as it might not be implemented yet
|
||||
try {
|
||||
const lbRoute = createLbPattern(
|
||||
'lb.example.com',
|
||||
[
|
||||
{ host: 'server1.local', port: 8080 },
|
||||
{ host: 'server2.local', port: 8080 },
|
||||
{ host: 'server3.local', port: 8080 }
|
||||
],
|
||||
{
|
||||
useTls: true
|
||||
}
|
||||
);
|
||||
|
||||
// Validate route configuration
|
||||
expect(lbRoute.match.domains).toEqual('lb.example.com');
|
||||
expect(lbRoute.action.type).toEqual('forward');
|
||||
|
||||
// Check target hosts
|
||||
if (lbRoute.action.targets && Array.isArray(lbRoute.action.targets[0]?.host)) {
|
||||
expect((lbRoute.action.targets[0].host as string[]).length).toEqual(3);
|
||||
}
|
||||
|
||||
// Check TLS configuration
|
||||
if (lbRoute.action.tls) {
|
||||
expect(lbRoute.action.tls.mode).toEqual('terminate');
|
||||
}
|
||||
|
||||
const result = validateRouteConfig(lbRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
} catch (error) {
|
||||
// If the pattern is not implemented yet, skip this test
|
||||
console.log('Load balancer pattern might not be fully implemented yet');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Route Security - addRateLimiting', async () => {
|
||||
// Create base route
|
||||
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
// Add rate limiting
|
||||
const secureRoute = addRateLimiting(baseRoute, {
|
||||
maxRequests: 100,
|
||||
window: 60, // 1 minute
|
||||
keyBy: 'ip'
|
||||
});
|
||||
|
||||
// Check if rate limiting is applied
|
||||
if (secureRoute.security) {
|
||||
expect(secureRoute.security.rateLimit?.enabled).toBeTrue();
|
||||
expect(secureRoute.security.rateLimit?.maxRequests).toEqual(100);
|
||||
expect(secureRoute.security.rateLimit?.window).toEqual(60);
|
||||
expect(secureRoute.security.rateLimit?.keyBy).toEqual('ip');
|
||||
} else {
|
||||
// Skip this test if security features are not implemented yet
|
||||
console.log('Security features not implemented yet in route configuration');
|
||||
}
|
||||
|
||||
// Just check that the route itself is valid
|
||||
const result = validateRouteConfig(secureRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Security - addBasicAuth', async () => {
|
||||
// Create base route
|
||||
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
// Add basic authentication
|
||||
const authRoute = addBasicAuth(baseRoute, {
|
||||
users: [
|
||||
{ username: 'admin', password: 'secret' },
|
||||
{ username: 'user', password: 'password' }
|
||||
],
|
||||
realm: 'Protected Area',
|
||||
excludePaths: ['/public']
|
||||
});
|
||||
|
||||
// Check if basic auth is applied
|
||||
if (authRoute.security) {
|
||||
expect(authRoute.security.basicAuth?.enabled).toBeTrue();
|
||||
expect(authRoute.security.basicAuth?.users.length).toEqual(2);
|
||||
expect(authRoute.security.basicAuth?.realm).toEqual('Protected Area');
|
||||
expect(authRoute.security.basicAuth?.excludePaths).toInclude('/public');
|
||||
} else {
|
||||
// Skip this test if security features are not implemented yet
|
||||
console.log('Security features not implemented yet in route configuration');
|
||||
}
|
||||
|
||||
// Check that the route itself is valid
|
||||
const result = validateRouteConfig(authRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Security - addJwtAuth', async () => {
|
||||
// Create base route
|
||||
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
// Add JWT authentication
|
||||
const jwtRoute = addJwtAuth(baseRoute, {
|
||||
secret: 'your-jwt-secret-key',
|
||||
algorithm: 'HS256',
|
||||
issuer: 'auth.example.com',
|
||||
audience: 'api.example.com',
|
||||
expiresIn: 3600
|
||||
});
|
||||
|
||||
// Check if JWT auth is applied
|
||||
if (jwtRoute.security) {
|
||||
expect(jwtRoute.security.jwtAuth?.enabled).toBeTrue();
|
||||
expect(jwtRoute.security.jwtAuth?.secret).toEqual('your-jwt-secret-key');
|
||||
expect(jwtRoute.security.jwtAuth?.algorithm).toEqual('HS256');
|
||||
expect(jwtRoute.security.jwtAuth?.issuer).toEqual('auth.example.com');
|
||||
expect(jwtRoute.security.jwtAuth?.audience).toEqual('api.example.com');
|
||||
expect(jwtRoute.security.jwtAuth?.expiresIn).toEqual(3600);
|
||||
} else {
|
||||
// Skip this test if security features are not implemented yet
|
||||
console.log('Security features not implemented yet in route configuration');
|
||||
}
|
||||
|
||||
// Check that the route itself is valid
|
||||
const result = validateRouteConfig(jwtRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '26.2.3',
|
||||
version: '27.0.0',
|
||||
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ export { tsclass };
|
||||
import * as smartcrypto from '@push.rocks/smartcrypto';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
|
||||
import * as smartnftables from '@push.rocks/smartnftables';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
|
||||
export {
|
||||
smartcrypto,
|
||||
smartlog,
|
||||
smartlogDestinationLocal,
|
||||
smartnftables,
|
||||
smartrust,
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ interface IDatagramRelayMessage {
|
||||
* - TS→Rust: { type: "reply", sourceIp, sourcePort, destPort, payloadBase64 }
|
||||
*/
|
||||
export class DatagramHandlerServer {
|
||||
private static readonly MAX_BUFFER_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
private server: plugins.net.Server | null = null;
|
||||
private connection: plugins.net.Socket | null = null;
|
||||
private socketPath: string;
|
||||
@@ -100,6 +102,11 @@ export class DatagramHandlerServer {
|
||||
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
|
||||
if (this.readBuffer.length > DatagramHandlerServer.MAX_BUFFER_SIZE) {
|
||||
logger.log('error', `DatagramHandlerServer: buffer exceeded ${DatagramHandlerServer.MAX_BUFFER_SIZE} bytes, resetting`);
|
||||
this.readBuffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
this.processFrames();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ type TSmartProxyCommands = {
|
||||
renewCertificate: { params: { routeName: string }; result: void };
|
||||
getCertificateStatus: { params: { routeName: string }; result: any };
|
||||
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
|
||||
getNftablesStatus: { params: Record<string, never>; result: any };
|
||||
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
|
||||
addListeningPort: { params: { port: number }; result: void };
|
||||
removeListeningPort: { params: { port: number }; result: void };
|
||||
@@ -159,10 +158,6 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
||||
return result?.ports ?? [];
|
||||
}
|
||||
|
||||
public async getNftablesStatus(): Promise<any> {
|
||||
return this.bridge.sendCommand('getNftablesStatus', {} as Record<string, never>);
|
||||
}
|
||||
|
||||
public async setSocketHandlerRelay(socketPath: string): Promise<void> {
|
||||
await this.bridge.sendCommand('setSocketHandlerRelay', { socketPath });
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ import type { IMetrics } from './models/metrics-types.js';
|
||||
/**
|
||||
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
|
||||
*
|
||||
* All networking (TCP, TLS, HTTP reverse proxy, connection management, security,
|
||||
* NFTables) is handled by the Rust binary. TypeScript is only:
|
||||
* All networking (TCP, TLS, HTTP reverse proxy, connection management, security)
|
||||
* is handled by the Rust binary. TypeScript is only:
|
||||
* - The npm module interface (types, route helpers)
|
||||
* - The thin IPC wrapper (this class)
|
||||
* - Socket-handler callback relay (for JS-defined handlers)
|
||||
@@ -39,6 +39,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
private socketHandlerServer: SocketHandlerServer | null = null;
|
||||
private datagramHandlerServer: DatagramHandlerServer | null = null;
|
||||
private metricsAdapter: RustMetricsAdapter;
|
||||
private nftablesManager: InstanceType<typeof plugins.smartnftables.SmartNftables> | null = null;
|
||||
private routeUpdateLock: Mutex;
|
||||
private stopping = false;
|
||||
private certProvisionPromise: Promise<void> | null = null;
|
||||
@@ -128,6 +129,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
}
|
||||
|
||||
// Handle unexpected exit (only emits error if not intentionally stopping)
|
||||
this.bridge.removeAllListeners('exit');
|
||||
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
||||
if (this.stopping) return;
|
||||
logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
|
||||
@@ -210,6 +212,9 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply NFTables rules for routes using nftables forwarding engine
|
||||
await this.applyNftablesRules(this.settings.routes);
|
||||
|
||||
// Start metrics polling BEFORE cert provisioning — the Rust engine is already
|
||||
// running and accepting connections, so metrics should be available immediately.
|
||||
// Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must
|
||||
@@ -237,6 +242,12 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
this.certProvisionPromise = null;
|
||||
}
|
||||
|
||||
// Clean up NFTables rules
|
||||
if (this.nftablesManager) {
|
||||
await this.nftablesManager.cleanup();
|
||||
this.nftablesManager = null;
|
||||
}
|
||||
|
||||
// Stop metrics polling
|
||||
this.metricsAdapter.stopPolling();
|
||||
|
||||
@@ -318,6 +329,13 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
this.datagramHandlerServer = null;
|
||||
}
|
||||
|
||||
// Update NFTables rules
|
||||
if (this.nftablesManager) {
|
||||
await this.nftablesManager.cleanup();
|
||||
this.nftablesManager = null;
|
||||
}
|
||||
await this.applyNftablesRules(newRoutes);
|
||||
|
||||
// Update stored routes
|
||||
this.settings.routes = newRoutes;
|
||||
|
||||
@@ -410,14 +428,59 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NFTables status (async - calls Rust).
|
||||
* Get NFTables status.
|
||||
*/
|
||||
public async getNfTablesStatus(): Promise<Record<string, any>> {
|
||||
return this.bridge.getNftablesStatus();
|
||||
public getNfTablesStatus(): plugins.smartnftables.INftStatus | null {
|
||||
return this.nftablesManager?.status() ?? null;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
/**
|
||||
* Apply NFTables rules for routes using the nftables forwarding engine.
|
||||
*/
|
||||
private async applyNftablesRules(routes: IRouteConfig[]): Promise<void> {
|
||||
const nftRoutes = routes.filter(r => r.action.forwardingEngine === 'nftables');
|
||||
if (nftRoutes.length === 0) return;
|
||||
|
||||
const tableName = nftRoutes.find(r => r.action.nftables?.tableName)?.action.nftables?.tableName ?? 'smartproxy';
|
||||
const nft = new plugins.smartnftables.SmartNftables({ tableName });
|
||||
await nft.initialize();
|
||||
|
||||
for (const route of nftRoutes) {
|
||||
const routeId = route.name || 'unnamed';
|
||||
const targets = route.action.targets;
|
||||
if (!targets) continue;
|
||||
|
||||
const nftOpts = route.action.nftables;
|
||||
const protocol: plugins.smartnftables.TNftProtocol = (nftOpts?.protocol as any) ?? 'tcp';
|
||||
const preserveSourceIP = nftOpts?.preserveSourceIP ?? false;
|
||||
|
||||
const ports = Array.isArray(route.match.ports)
|
||||
? route.match.ports.flatMap(p => typeof p === 'number' ? [p] : [])
|
||||
: typeof route.match.ports === 'number' ? [route.match.ports] : [];
|
||||
|
||||
for (const target of targets) {
|
||||
const targetHost = Array.isArray(target.host) ? target.host[0] : target.host;
|
||||
if (typeof targetHost !== 'string') continue;
|
||||
|
||||
for (const sourcePort of ports) {
|
||||
const targetPort = typeof target.port === 'number' ? target.port : sourcePort;
|
||||
await nft.nat.addPortForwarding(`${routeId}-${sourcePort}-${targetPort}`, {
|
||||
sourcePort,
|
||||
targetHost,
|
||||
targetPort,
|
||||
protocol,
|
||||
preserveSourceIP,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.nftablesManager = nft;
|
||||
logger.log('info', `Applied NFTables rules for ${nftRoutes.length} route(s)`, { component: 'smart-proxy' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Rust configuration object from TS settings.
|
||||
*/
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
* SmartProxy Route Utilities
|
||||
*
|
||||
* This file exports all route-related utilities for the SmartProxy module,
|
||||
* including helpers, validators, utilities, and patterns for working with routes.
|
||||
* including validators, utilities, and socket handlers for working with routes.
|
||||
*/
|
||||
|
||||
// Export route helpers for creating route configurations
|
||||
export * from './route-helpers.js';
|
||||
|
||||
// Export route validator (class-based and functional API)
|
||||
export * from './route-validator.js';
|
||||
|
||||
@@ -20,10 +17,5 @@ export { generateDefaultCertificate } from './default-cert-generator.js';
|
||||
// Export concurrency semaphore
|
||||
export { ConcurrencySemaphore } from './concurrency-semaphore.js';
|
||||
|
||||
// Export additional functions from route-helpers that weren't already exported
|
||||
export {
|
||||
createApiGatewayRoute,
|
||||
addRateLimiting,
|
||||
addBasicAuth,
|
||||
addJwtAuth
|
||||
} from './route-helpers.js';
|
||||
// Export socket handlers
|
||||
export { SocketHandlers } from './socket-handlers.js';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Route Helper Functions
|
||||
*
|
||||
* This file re-exports all route helper functions for backwards compatibility.
|
||||
* The actual implementations have been split into focused modules in the route-helpers/ directory.
|
||||
*
|
||||
* @see ./route-helpers/index.ts for the modular exports
|
||||
*/
|
||||
|
||||
// Re-export everything from the modular helpers
|
||||
export * from './route-helpers/index.js';
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* API Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating API route configurations.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||
import { mergeRouteConfigs } from '../route-utils.js';
|
||||
import { createHttpRoute } from './http-helpers.js';
|
||||
import { createHttpsTerminateRoute } from './https-helpers.js';
|
||||
|
||||
/**
|
||||
* Create an API route configuration
|
||||
* @param domains Domain(s) to match
|
||||
* @param apiPath API base path (e.g., "/api")
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createApiRoute(
|
||||
domains: string | string[],
|
||||
apiPath: string,
|
||||
target: { host: string | string[]; port: number },
|
||||
options: {
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
addCorsHeaders?: boolean;
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Normalize API path
|
||||
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
|
||||
const pathWithWildcard = normalizedPath.endsWith('/')
|
||||
? `${normalizedPath}*`
|
||||
: `${normalizedPath}/*`;
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.useTls
|
||||
? (options.httpsPort || 443)
|
||||
: (options.httpPort || 80),
|
||||
domains,
|
||||
path: pathWithWildcard
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Add TLS configuration if using HTTPS
|
||||
if (options.useTls) {
|
||||
action.tls = {
|
||||
mode: 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
// Add CORS headers if requested
|
||||
const headers: Record<string, Record<string, string>> = {};
|
||||
if (options.addCorsHeaders) {
|
||||
headers.response = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
name: options.name || `API Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
priority: options.priority || 100, // Higher priority for specific path matches
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API Gateway route pattern
|
||||
* @param domains Domain(s) to match
|
||||
* @param apiBasePath Base path for API endpoints (e.g., '/api')
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns API route configuration
|
||||
*/
|
||||
export function createApiGatewayRoute(
|
||||
domains: string | string[],
|
||||
apiBasePath: string,
|
||||
target: { host: string | string[]; port: number },
|
||||
options: {
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
addCorsHeaders?: boolean;
|
||||
[key: string]: any;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Normalize apiBasePath to ensure it starts with / and doesn't end with /
|
||||
const normalizedPath = apiBasePath.startsWith('/')
|
||||
? apiBasePath
|
||||
: `/${apiBasePath}`;
|
||||
|
||||
// Add wildcard to path to match all API endpoints
|
||||
const apiPath = normalizedPath.endsWith('/')
|
||||
? `${normalizedPath}*`
|
||||
: `${normalizedPath}/*`;
|
||||
|
||||
// Create base route
|
||||
const baseRoute = options.useTls
|
||||
? createHttpsTerminateRoute(domains, target, {
|
||||
certificate: options.certificate || 'auto'
|
||||
})
|
||||
: createHttpRoute(domains, target);
|
||||
|
||||
// Add API-specific configurations
|
||||
const apiRoute: Partial<IRouteConfig> = {
|
||||
match: {
|
||||
...baseRoute.match,
|
||||
path: apiPath
|
||||
},
|
||||
name: options.name || `API Gateway: ${apiPath} -> ${Array.isArray(target.host) ? target.host.join(', ') : target.host}:${target.port}`,
|
||||
priority: options.priority || 100 // Higher priority for specific path matching
|
||||
};
|
||||
|
||||
// Add CORS headers if requested
|
||||
if (options.addCorsHeaders) {
|
||||
apiRoute.headers = {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return mergeRouteConfigs(baseRoute, apiRoute);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Dynamic Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating dynamic routes
|
||||
* with context-based host and port mapping.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Create a helper function that applies a port offset
|
||||
* @param offset The offset to apply to the matched port
|
||||
* @returns A function that adds the offset to the matched port
|
||||
*/
|
||||
export function createPortOffset(offset: number): (context: IRouteContext) => number {
|
||||
return (context: IRouteContext) => context.port + offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a port mapping route with context-based port function
|
||||
* @param options Port mapping route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createPortMappingRoute(options: {
|
||||
sourcePortRange: TPortRange;
|
||||
targetHost: string | string[] | ((context: IRouteContext) => string | string[]);
|
||||
portMapper: (context: IRouteContext) => number;
|
||||
name?: string;
|
||||
domains?: string | string[];
|
||||
priority?: number;
|
||||
[key: string]: any;
|
||||
}): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.sourcePortRange,
|
||||
domains: options.domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: options.targetHost,
|
||||
port: options.portMapper
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `Port Mapping Route for ${options.domains || 'all domains'}`,
|
||||
priority: options.priority,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple offset port mapping route
|
||||
* @param options Offset port mapping route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createOffsetPortMappingRoute(options: {
|
||||
ports: TPortRange;
|
||||
targetHost: string | string[];
|
||||
offset: number;
|
||||
name?: string;
|
||||
domains?: string | string[];
|
||||
priority?: number;
|
||||
[key: string]: any;
|
||||
}): IRouteConfig {
|
||||
const { ports, targetHost, offset, name, domains, priority, ...rest } = options;
|
||||
return createPortMappingRoute({
|
||||
sourcePortRange: ports,
|
||||
targetHost,
|
||||
portMapper: (context) => context.port + offset,
|
||||
name: name || `Offset Mapping (${offset > 0 ? '+' : ''}${offset}) for ${domains || 'all domains'}`,
|
||||
domains,
|
||||
priority,
|
||||
...rest
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dynamic route with context-based host and port mapping
|
||||
* @param options Dynamic route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createDynamicRoute(options: {
|
||||
ports: TPortRange;
|
||||
targetHost: (context: IRouteContext) => string | string[];
|
||||
portMapper: (context: IRouteContext) => number;
|
||||
name?: string;
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
clientIp?: string[];
|
||||
priority?: number;
|
||||
[key: string]: any;
|
||||
}): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.ports,
|
||||
domains: options.domains,
|
||||
path: options.path,
|
||||
clientIp: options.clientIp
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: options.targetHost,
|
||||
port: options.portMapper
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `Dynamic Route for ${options.domains || 'all domains'}`,
|
||||
priority: options.priority,
|
||||
...options
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* HTTP Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating HTTP route configurations.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Create an HTTP-only route configuration
|
||||
* @param domains Domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createHttpRoute(
|
||||
domains: string | string[],
|
||||
target: { host: string | string[]; port: number },
|
||||
options: Partial<IRouteConfig> = {}
|
||||
): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.match?.ports || 80,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `HTTP Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...options
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* HTTPS Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating HTTPS route configurations
|
||||
* including TLS termination and passthrough routes.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||
import { SocketHandlers } from './socket-handlers.js';
|
||||
|
||||
/**
|
||||
* Create an HTTPS route with TLS termination
|
||||
* @param domains Domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createHttpsTerminateRoute(
|
||||
domains: string | string[],
|
||||
target: { host: string | string[]; port: number },
|
||||
options: {
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
reencrypt?: boolean;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.httpsPort || 443,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target],
|
||||
tls: {
|
||||
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
}
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `HTTPS Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP to HTTPS redirect route
|
||||
* @param domains Domain(s) to match
|
||||
* @param httpsPort HTTPS port to redirect to (default: 443)
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createHttpToHttpsRedirect(
|
||||
domains: string | string[],
|
||||
httpsPort: number = 443,
|
||||
options: Partial<IRouteConfig> = {}
|
||||
): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.match?.ports || 80,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301)
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTPS passthrough route (SNI-based forwarding without TLS termination)
|
||||
* @param domains Domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createHttpsPassthroughRoute(
|
||||
domains: string | string[],
|
||||
target: { host: string | string[]; port: number },
|
||||
options: Partial<IRouteConfig> = {}
|
||||
): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.match?.ports || 443,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target],
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `HTTPS Passthrough for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete HTTPS server with HTTP to HTTPS redirects
|
||||
* @param domains Domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional configuration options
|
||||
* @returns Array of two route configurations (HTTPS and HTTP redirect)
|
||||
*/
|
||||
export function createCompleteHttpsServer(
|
||||
domains: string | string[],
|
||||
target: { host: string | string[]; port: number },
|
||||
options: {
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
reencrypt?: boolean;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
} = {}
|
||||
): IRouteConfig[] {
|
||||
// Create the HTTPS route
|
||||
const httpsRoute = createHttpsTerminateRoute(domains, target, options);
|
||||
|
||||
// Create the HTTP redirect route
|
||||
const httpRedirectRoute = createHttpToHttpsRedirect(
|
||||
domains,
|
||||
// Extract the HTTPS port from the HTTPS route - ensure it's a number
|
||||
typeof options.httpsPort === 'number' ? options.httpsPort :
|
||||
Array.isArray(options.httpsPort) ? options.httpsPort[0] : 443,
|
||||
{
|
||||
// Set the HTTP port
|
||||
match: {
|
||||
ports: options.httpPort || 80,
|
||||
domains
|
||||
},
|
||||
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`
|
||||
}
|
||||
);
|
||||
|
||||
return [httpsRoute, httpRedirectRoute];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating route configurations for common scenarios.
|
||||
* These functions aim to simplify the creation of route configurations for typical use cases.
|
||||
*
|
||||
* This barrel file re-exports all helper functions for backwards compatibility.
|
||||
*/
|
||||
|
||||
// HTTP helpers
|
||||
export { createHttpRoute } from './http-helpers.js';
|
||||
|
||||
// HTTPS helpers
|
||||
export {
|
||||
createHttpsTerminateRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createHttpsPassthroughRoute,
|
||||
createCompleteHttpsServer
|
||||
} from './https-helpers.js';
|
||||
|
||||
// WebSocket helpers
|
||||
export { createWebSocketRoute } from './websocket-helpers.js';
|
||||
|
||||
// Load balancer helpers
|
||||
export {
|
||||
createLoadBalancerRoute,
|
||||
createSmartLoadBalancer
|
||||
} from './load-balancer-helpers.js';
|
||||
|
||||
// NFTables helpers
|
||||
export {
|
||||
createNfTablesRoute,
|
||||
createNfTablesTerminateRoute,
|
||||
createCompleteNfTablesHttpsServer
|
||||
} from './nftables-helpers.js';
|
||||
|
||||
// Dynamic routing helpers
|
||||
export {
|
||||
createPortOffset,
|
||||
createPortMappingRoute,
|
||||
createOffsetPortMappingRoute,
|
||||
createDynamicRoute
|
||||
} from './dynamic-helpers.js';
|
||||
|
||||
// API helpers
|
||||
export {
|
||||
createApiRoute,
|
||||
createApiGatewayRoute
|
||||
} from './api-helpers.js';
|
||||
|
||||
// Security helpers
|
||||
export {
|
||||
addRateLimiting,
|
||||
addBasicAuth,
|
||||
addJwtAuth
|
||||
} from './security-helpers.js';
|
||||
|
||||
// Socket handlers
|
||||
export {
|
||||
SocketHandlers,
|
||||
createSocketHandlerRoute
|
||||
} from './socket-handlers.js';
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Load Balancer Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating load balancer route configurations.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Create a load balancer route (round-robin between multiple backend hosts)
|
||||
* @param domains Domain(s) to match
|
||||
* @param backendsOrHosts Array of backend servers OR array of host strings (legacy)
|
||||
* @param portOrOptions Port number (legacy) OR options object
|
||||
* @param options Additional route options (legacy)
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createLoadBalancerRoute(
|
||||
domains: string | string[],
|
||||
backendsOrHosts: Array<{ host: string; port: number }> | string[],
|
||||
portOrOptions?: number | {
|
||||
tls?: {
|
||||
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
};
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
algorithm?: 'round-robin' | 'least-connections' | 'ip-hash';
|
||||
healthCheck?: {
|
||||
path: string;
|
||||
interval: number;
|
||||
timeout: number;
|
||||
unhealthyThreshold: number;
|
||||
healthyThreshold: number;
|
||||
};
|
||||
[key: string]: any;
|
||||
},
|
||||
options?: {
|
||||
tls?: {
|
||||
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
): IRouteConfig {
|
||||
// Handle legacy signature: (domains, hosts[], port, options)
|
||||
let backends: Array<{ host: string; port: number }>;
|
||||
let finalOptions: any;
|
||||
|
||||
if (Array.isArray(backendsOrHosts) && backendsOrHosts.length > 0 && typeof backendsOrHosts[0] === 'string') {
|
||||
// Legacy signature
|
||||
const hosts = backendsOrHosts as string[];
|
||||
const port = portOrOptions as number;
|
||||
backends = hosts.map(host => ({ host, port }));
|
||||
finalOptions = options || {};
|
||||
} else {
|
||||
// New signature
|
||||
backends = backendsOrHosts as Array<{ host: string; port: number }>;
|
||||
finalOptions = (portOrOptions as any) || {};
|
||||
}
|
||||
|
||||
// Extract hosts and ensure all backends use the same port
|
||||
const port = backends[0].port;
|
||||
const hosts = backends.map(backend => backend.host);
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: finalOptions.match?.ports || (finalOptions.tls || finalOptions.useTls ? 443 : 80),
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route target
|
||||
const target: IRouteTarget = {
|
||||
host: hosts,
|
||||
port
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Add TLS configuration if provided
|
||||
if (finalOptions.tls || finalOptions.useTls) {
|
||||
action.tls = {
|
||||
mode: finalOptions.tls?.mode || 'terminate',
|
||||
certificate: finalOptions.tls?.certificate || finalOptions.certificate || 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
// Add load balancing options
|
||||
if (finalOptions.algorithm || finalOptions.healthCheck) {
|
||||
action.loadBalancing = {
|
||||
algorithm: finalOptions.algorithm || 'round-robin',
|
||||
healthCheck: finalOptions.healthCheck
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: finalOptions.name || `Load Balancer for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...finalOptions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a smart load balancer with dynamic domain-based backend selection
|
||||
* @param options Smart load balancer options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createSmartLoadBalancer(options: {
|
||||
ports: TPortRange;
|
||||
domainTargets: Record<string, string | string[]>;
|
||||
portMapper: (context: IRouteContext) => number;
|
||||
name?: string;
|
||||
defaultTarget?: string | string[];
|
||||
priority?: number;
|
||||
[key: string]: any;
|
||||
}): IRouteConfig {
|
||||
// Extract all domain keys to create the match criteria
|
||||
const domains = Object.keys(options.domainTargets);
|
||||
|
||||
// Create the smart host selector function
|
||||
const hostSelector = (context: IRouteContext) => {
|
||||
const domain = context.domain || '';
|
||||
return options.domainTargets[domain] || options.defaultTarget || 'localhost';
|
||||
};
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.ports,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: hostSelector,
|
||||
port: options.portMapper
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `Smart Load Balancer for ${domains.join(', ')}`,
|
||||
priority: options.priority,
|
||||
...options
|
||||
};
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* NFTables Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating NFTables-based route configurations
|
||||
* for high-performance packet forwarding at the kernel level.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../../models/route-types.js';
|
||||
import { createHttpToHttpsRedirect } from './https-helpers.js';
|
||||
|
||||
/**
|
||||
* Create an NFTables-based route for high-performance packet forwarding
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createNfTablesRoute(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
ports?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
useTls?: boolean;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Determine if this is a name or domain
|
||||
let name: string;
|
||||
let domains: string | string[] | undefined;
|
||||
|
||||
if (Array.isArray(nameOrDomains) || (typeof nameOrDomains === 'string' && nameOrDomains.includes('.'))) {
|
||||
domains = nameOrDomains;
|
||||
name = Array.isArray(nameOrDomains) ? nameOrDomains[0] : nameOrDomains;
|
||||
} else {
|
||||
name = nameOrDomains;
|
||||
domains = undefined; // No domains
|
||||
}
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
domains,
|
||||
ports: options.ports || 80
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: target.host,
|
||||
port: target.port
|
||||
}],
|
||||
forwardingEngine: 'nftables',
|
||||
nftables: {
|
||||
protocol: options.protocol || 'tcp',
|
||||
preserveSourceIP: options.preserveSourceIP,
|
||||
maxRate: options.maxRate,
|
||||
priority: options.priority,
|
||||
tableName: options.tableName,
|
||||
useIPSets: options.useIPSets,
|
||||
useAdvancedNAT: options.useAdvancedNAT
|
||||
}
|
||||
};
|
||||
|
||||
// Add TLS options if needed
|
||||
if (options.useTls) {
|
||||
action.tls = {
|
||||
mode: 'passthrough'
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
const routeConfig: IRouteConfig = {
|
||||
name,
|
||||
match,
|
||||
action
|
||||
};
|
||||
|
||||
// Add security if allowed or blocked IPs are specified
|
||||
if (options.ipAllowList?.length || options.ipBlockList?.length) {
|
||||
routeConfig.security = {
|
||||
ipAllowList: options.ipAllowList,
|
||||
ipBlockList: options.ipBlockList
|
||||
};
|
||||
}
|
||||
|
||||
return routeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NFTables-based TLS termination route
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createNfTablesTerminateRoute(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
ports?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Create basic NFTables route
|
||||
const route = createNfTablesRoute(
|
||||
nameOrDomains,
|
||||
target,
|
||||
{
|
||||
...options,
|
||||
ports: options.ports || 443,
|
||||
useTls: false
|
||||
}
|
||||
);
|
||||
|
||||
// Set TLS termination
|
||||
route.action.tls = {
|
||||
mode: 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
};
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete NFTables-based HTTPS setup with HTTP redirect
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Array of two route configurations (HTTPS and HTTP redirect)
|
||||
*/
|
||||
export function createCompleteNfTablesHttpsServer(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
httpPort?: TPortRange;
|
||||
httpsPort?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
} = {}
|
||||
): IRouteConfig[] {
|
||||
// Create the HTTPS route using NFTables
|
||||
const httpsRoute = createNfTablesTerminateRoute(
|
||||
nameOrDomains,
|
||||
target,
|
||||
{
|
||||
...options,
|
||||
ports: options.httpsPort || 443
|
||||
}
|
||||
);
|
||||
|
||||
// Determine the domain(s) for HTTP redirect
|
||||
const domains = typeof nameOrDomains === 'string' && !nameOrDomains.includes('.')
|
||||
? undefined
|
||||
: nameOrDomains;
|
||||
|
||||
// Extract the HTTPS port for the redirect destination
|
||||
const httpsPort = typeof options.httpsPort === 'number'
|
||||
? options.httpsPort
|
||||
: Array.isArray(options.httpsPort) && typeof options.httpsPort[0] === 'number'
|
||||
? options.httpsPort[0]
|
||||
: 443;
|
||||
|
||||
// Create the HTTP redirect route (this uses standard forwarding, not NFTables)
|
||||
const httpRedirectRoute = createHttpToHttpsRedirect(
|
||||
domains as any, // Type cast needed since domains can be undefined now
|
||||
httpsPort,
|
||||
{
|
||||
match: {
|
||||
ports: options.httpPort || 80,
|
||||
domains: domains as any // Type cast needed since domains can be undefined now
|
||||
},
|
||||
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains || 'all domains'}`
|
||||
}
|
||||
);
|
||||
|
||||
return [httpsRoute, httpRedirectRoute];
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Security Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for adding security features to routes.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig } from '../../models/route-types.js';
|
||||
import { mergeRouteConfigs } from '../route-utils.js';
|
||||
|
||||
/**
|
||||
* Create a rate limiting route pattern
|
||||
* @param baseRoute Base route to add rate limiting to
|
||||
* @param rateLimit Rate limiting configuration
|
||||
* @returns Route with rate limiting
|
||||
*/
|
||||
export function addRateLimiting(
|
||||
baseRoute: IRouteConfig,
|
||||
rateLimit: {
|
||||
maxRequests: number;
|
||||
window: number; // Time window in seconds
|
||||
keyBy?: 'ip' | 'path' | 'header';
|
||||
headerName?: string; // Required if keyBy is 'header'
|
||||
errorMessage?: string;
|
||||
}
|
||||
): IRouteConfig {
|
||||
return mergeRouteConfigs(baseRoute, {
|
||||
security: {
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: rateLimit.maxRequests,
|
||||
window: rateLimit.window,
|
||||
keyBy: rateLimit.keyBy || 'ip',
|
||||
headerName: rateLimit.headerName,
|
||||
errorMessage: rateLimit.errorMessage || 'Rate limit exceeded. Please try again later.'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic authentication route pattern
|
||||
* @param baseRoute Base route to add authentication to
|
||||
* @param auth Authentication configuration
|
||||
* @returns Route with basic authentication
|
||||
*/
|
||||
export function addBasicAuth(
|
||||
baseRoute: IRouteConfig,
|
||||
auth: {
|
||||
users: Array<{ username: string; password: string }>;
|
||||
realm?: string;
|
||||
excludePaths?: string[];
|
||||
}
|
||||
): IRouteConfig {
|
||||
return mergeRouteConfigs(baseRoute, {
|
||||
security: {
|
||||
basicAuth: {
|
||||
enabled: true,
|
||||
users: auth.users,
|
||||
realm: auth.realm || 'Restricted Area',
|
||||
excludePaths: auth.excludePaths || []
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JWT authentication route pattern
|
||||
* @param baseRoute Base route to add JWT authentication to
|
||||
* @param jwt JWT authentication configuration
|
||||
* @returns Route with JWT authentication
|
||||
*/
|
||||
export function addJwtAuth(
|
||||
baseRoute: IRouteConfig,
|
||||
jwt: {
|
||||
secret: string;
|
||||
algorithm?: string;
|
||||
issuer?: string;
|
||||
audience?: string;
|
||||
expiresIn?: number; // Time in seconds
|
||||
excludePaths?: string[];
|
||||
}
|
||||
): IRouteConfig {
|
||||
return mergeRouteConfigs(baseRoute, {
|
||||
security: {
|
||||
jwtAuth: {
|
||||
enabled: true,
|
||||
secret: jwt.secret,
|
||||
algorithm: jwt.algorithm || 'HS256',
|
||||
issuer: jwt.issuer,
|
||||
audience: jwt.audience,
|
||||
expiresIn: jwt.expiresIn,
|
||||
excludePaths: jwt.excludePaths || []
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* WebSocket Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating WebSocket route configurations.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Create a WebSocket route configuration
|
||||
* @param domains Domain(s) to match
|
||||
* @param targetOrPath Target server OR WebSocket path (legacy)
|
||||
* @param targetOrOptions Target server (legacy) OR options
|
||||
* @param options Additional route options (legacy)
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createWebSocketRoute(
|
||||
domains: string | string[],
|
||||
targetOrPath: { host: string | string[]; port: number } | string,
|
||||
targetOrOptions?: { host: string | string[]; port: number } | {
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
path?: string;
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
pingInterval?: number;
|
||||
pingTimeout?: number;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
},
|
||||
options?: {
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
pingInterval?: number;
|
||||
pingTimeout?: number;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
): IRouteConfig {
|
||||
// Handle different signatures
|
||||
let target: { host: string | string[]; port: number };
|
||||
let wsPath: string;
|
||||
let finalOptions: any;
|
||||
|
||||
if (typeof targetOrPath === 'string') {
|
||||
// Legacy signature: (domains, path, target, options)
|
||||
wsPath = targetOrPath;
|
||||
target = targetOrOptions as { host: string | string[]; port: number };
|
||||
finalOptions = options || {};
|
||||
} else {
|
||||
// New signature: (domains, target, options)
|
||||
target = targetOrPath;
|
||||
finalOptions = (targetOrOptions as any) || {};
|
||||
wsPath = finalOptions.path || '/ws';
|
||||
}
|
||||
|
||||
// Normalize WebSocket path
|
||||
const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`;
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: finalOptions.useTls
|
||||
? (finalOptions.httpsPort || 443)
|
||||
: (finalOptions.httpPort || 80),
|
||||
domains,
|
||||
path: normalizedPath
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target],
|
||||
websocket: {
|
||||
enabled: true,
|
||||
pingInterval: finalOptions.pingInterval || 30000, // 30 seconds
|
||||
pingTimeout: finalOptions.pingTimeout || 5000 // 5 seconds
|
||||
}
|
||||
};
|
||||
|
||||
// Add TLS configuration if using HTTPS
|
||||
if (finalOptions.useTls) {
|
||||
action.tls = {
|
||||
mode: 'terminate',
|
||||
certificate: finalOptions.certificate || 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: finalOptions.name || `WebSocket Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
priority: finalOptions.priority || 100, // Higher priority for WebSocket routes
|
||||
...finalOptions
|
||||
};
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
* like echoing, proxying, HTTP responses, and redirects.
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../../plugins.js';
|
||||
import type { IRouteConfig, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||
import { createSocketTracker } from '../../../../core/utils/socket-tracker.js';
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { IRouteContext } from '../models/route-types.js';
|
||||
import { createSocketTracker } from '../../../core/utils/socket-tracker.js';
|
||||
|
||||
/**
|
||||
* Minimal HTTP request parser for socket handlers.
|
||||
@@ -308,31 +308,3 @@ export const SocketHandlers = {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a socket handler route configuration
|
||||
*/
|
||||
export function createSocketHandlerRoute(
|
||||
domains: string | string[],
|
||||
ports: TPortRange,
|
||||
handler: (socket: plugins.net.Socket) => void | Promise<void>,
|
||||
options: {
|
||||
name?: string;
|
||||
priority?: number;
|
||||
path?: string;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
return {
|
||||
name: options.name || 'socket-handler-route',
|
||||
priority: options.priority !== undefined ? options.priority : 50,
|
||||
match: {
|
||||
domains,
|
||||
ports,
|
||||
...(options.path && { path: options.path })
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: handler
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"ignoreDeprecations": "6.0"
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user