Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 729bdcbf54 | |||
| 823edf1ab0 | |||
| fe383ddddd | |||
| e220208c16 | |||
| 8415a82f21 | |||
| 2933ee5257 |
+15
-7
@@ -28,12 +28,17 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
"registries": [
|
"targets": {
|
||||||
"https://verdaccio.lossless.digital",
|
"npm": {
|
||||||
"https://registry.npmjs.org"
|
"registries": [
|
||||||
],
|
"https://verdaccio.lossless.digital",
|
||||||
"accessLevel": "public"
|
"https://registry.npmjs.org"
|
||||||
}
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemaVersion": 2
|
||||||
},
|
},
|
||||||
"@git.zone/tsdoc": {
|
"@git.zone/tsdoc": {
|
||||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**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.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**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.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||||
@@ -42,6 +47,9 @@
|
|||||||
"npmGlobalTools": []
|
"npmGlobalTools": []
|
||||||
},
|
},
|
||||||
"@git.zone/tsrust": {
|
"@git.zone/tsrust": {
|
||||||
"targets": ["linux_amd64", "linux_arm64"]
|
"targets": [
|
||||||
|
"linux_amd64",
|
||||||
|
"linux_arm64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+29
-1
@@ -1,5 +1,33 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-05-12 - 27.10.2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- no code changes to release (repo)
|
||||||
|
|
||||||
|
## 2026-05-12 - 27.10.1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- handle HTTP/3 backend forwarding failures with protocol fallback and pool cleanup (proxy-service)
|
||||||
|
- Retry bodyless requests over HTTP/1.1 when HTTP/3 forwarding fails in auto backend protocol mode
|
||||||
|
- Remove broken HTTP/3 pooled connections and record protocol cache failures to avoid repeated H3 reuse
|
||||||
|
- Add regression coverage for backends that advertise an unavailable Alt-Svc HTTP/3 endpoint
|
||||||
|
- Refresh documentation examples and API notes to reflect target-level load balancing and certificate provisioning event hooks
|
||||||
|
|
||||||
|
## 2026-04-30 - 27.10.0 - feat(exports)
|
||||||
|
export datagram handler types and align tests with updated nftables and route security APIs
|
||||||
|
|
||||||
|
- Exports TDatagramHandler and IDatagramInfo from the public index.
|
||||||
|
- Updates nftables integration tests for the new grouped status shape and null result after shutdown.
|
||||||
|
- Moves route IP allow list configuration to security and awaits the asynchronous listening ports API.
|
||||||
|
- Enables stricter TypeScript checks with noImplicitAny and safer error handling in tests.
|
||||||
|
|
||||||
## 2026-04-26 - 27.9.0 - feat(smart-proxy)
|
## 2026-04-26 - 27.9.0 - feat(smart-proxy)
|
||||||
add hot-reloadable global ingress security policy across Rust and TypeScript proxy layers
|
add hot-reloadable global ingress security policy across Rust and TypeScript proxy layers
|
||||||
|
|
||||||
@@ -1199,4 +1227,4 @@ Fix ACME certificate provisioning timing to ensure ports are listening first
|
|||||||
- Fixed race condition where certificate provisioning would start before ports were listening
|
- Fixed race condition where certificate provisioning would start before ports were listening
|
||||||
- Modified SmartCertManager.initialize() to defer certificate provisioning
|
- Modified SmartCertManager.initialize() to defer certificate provisioning
|
||||||
- Added SmartCertManager.provisionCertificatesAfterPortsReady() for delayed provisioning
|
- Added SmartCertManager.provisionCertificatesAfterPortsReady() for delayed provisioning
|
||||||
- Updated SmartProxy.start() to call certificate provisioning after ports are ready
|
- Updated SmartProxy.start() to call certificate provisioning after ports are ready
|
||||||
|
|||||||
+17
-17
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "27.9.0",
|
"version": "27.10.2",
|
||||||
"private": false,
|
"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.",
|
"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",
|
"main": "dist_ts/index.js",
|
||||||
@@ -11,29 +11,29 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test:before": "(tsrust)",
|
"test:before": "(tsrust)",
|
||||||
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
|
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
|
"build": "(tsbuild tsfolders) && (tsrust)",
|
||||||
"format": "(gitzone format)",
|
"format": "(gitzone format)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.4.0",
|
"@git.zone/tsbuild": "^4.4.1",
|
||||||
"@git.zone/tsrun": "^2.0.2",
|
"@git.zone/tsrun": "^2.0.4",
|
||||||
"@git.zone/tsrust": "^1.3.2",
|
"@git.zone/tsrust": "^1.3.4",
|
||||||
"@git.zone/tstest": "^3.6.0",
|
"@git.zone/tstest": "^3.6.6",
|
||||||
"@push.rocks/smartserve": "^2.0.3",
|
"@push.rocks/smartserve": "^2.0.4",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.7.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"why-is-node-running": "^3.2.2",
|
"why-is-node-running": "^3.2.2",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartcrypto": "^2.0.4",
|
"@push.rocks/smartcrypto": "^2.0.4",
|
||||||
"@push.rocks/smartlog": "^3.2.1",
|
"@push.rocks/smartlog": "^3.2.2",
|
||||||
"@push.rocks/smartnftables": "^1.0.1",
|
"@push.rocks/smartnftables": "^1.2.0",
|
||||||
"@push.rocks/smartrust": "^1.3.2",
|
"@push.rocks/smartrust": "^1.4.0",
|
||||||
"@tsclass/tsclass": "^9.5.0",
|
"@tsclass/tsclass": "^9.5.1",
|
||||||
"minimatch": "^10.2.4"
|
"minimatch": "^10.2.5"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
".smartconfig.json",
|
".smartconfig.json",
|
||||||
|
"license",
|
||||||
"readme.md",
|
"readme.md",
|
||||||
"changelog.md"
|
"changelog.md"
|
||||||
],
|
],
|
||||||
@@ -77,12 +78,11 @@
|
|||||||
"url": "https://code.foss.global/push.rocks/smartproxy/issues"
|
"url": "https://code.foss.global/push.rocks/smartproxy/issues"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {},
|
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"mongodb-memory-server",
|
"mongodb-memory-server",
|
||||||
"puppeteer"
|
"puppeteer"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
"packageManager": "pnpm@10.28.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2343
-2669
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,16 @@
|
|||||||
|
|
||||||
**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).
|
**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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @push.rocks/smartproxy
|
|
||||||
# or
|
|
||||||
pnpm add @push.rocks/smartproxy
|
|
||||||
```
|
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @push.rocks/smartproxy
|
||||||
|
```
|
||||||
|
|
||||||
## 🎯 What is SmartProxy?
|
## 🎯 What is SmartProxy?
|
||||||
|
|
||||||
SmartProxy is a production-ready proxy solution that takes the complexity out of traffic management. Under the hood, all networking — TCP, UDP, TLS, HTTP reverse proxy, QUIC/HTTP3, connection tracking, security enforcement, and NFTables — is handled by a **Rust engine** for maximum performance, while you configure everything through a clean TypeScript API with full type safety.
|
SmartProxy is a production-ready proxy solution that takes the complexity out of traffic management. Under the hood, all networking — TCP, UDP, TLS, HTTP reverse proxy, QUIC/HTTP3, connection tracking, security enforcement, and NFTables — is handled by a **Rust engine** for maximum performance, while you configure everything through a clean TypeScript API with full type safety.
|
||||||
@@ -30,7 +28,7 @@ Whether you're building microservices, deploying edge infrastructure, proxying U
|
|||||||
| 🎯 **Flexible Matching** | Route by port, domain, path, protocol, client IP, TLS version, headers, or custom logic |
|
| 🎯 **Flexible Matching** | Route by port, domain, path, protocol, client IP, TLS version, headers, or custom logic |
|
||||||
| 🚄 **High-Performance** | Choose between user-space or kernel-level (NFTables) forwarding |
|
| 🚄 **High-Performance** | Choose between user-space or kernel-level (NFTables) forwarding |
|
||||||
| 📡 **UDP & QUIC/HTTP3** | First-class UDP transport, datagram handlers, QUIC tunneling, and HTTP/3 support |
|
| 📡 **UDP & QUIC/HTTP3** | First-class UDP transport, datagram handlers, QUIC tunneling, and HTTP/3 support |
|
||||||
| ⚖️ **Load Balancing** | Round-robin, least-connections, IP-hash with health checks |
|
| ⚖️ **Load Balancing** | Round-robin, least-connections, and IP-hash selection across host arrays |
|
||||||
| 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits |
|
| 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits |
|
||||||
| 🔌 **WebSocket Support** | First-class WebSocket proxying with ping/pong keep-alive |
|
| 🔌 **WebSocket Support** | First-class WebSocket proxying with ping/pong keep-alive |
|
||||||
| 🎮 **Custom Protocols** | Socket and datagram handlers for implementing any protocol in TypeScript |
|
| 🎮 **Custom Protocols** | Socket and datagram handlers for implementing any protocol in TypeScript |
|
||||||
@@ -137,7 +135,9 @@ const proxy = new SmartProxy({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### ⚖️ Load Balancer with Health Checks
|
### ⚖️ Load Balancer
|
||||||
|
|
||||||
|
For equivalent backends, put the backend hosts into one target's `host` array and choose a target-level load-balancing algorithm. Multiple `targets` are for sub-routing with `target.match` and `priority`.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||||
@@ -148,22 +148,12 @@ const proxy = new SmartProxy({
|
|||||||
match: { ports: 443, domains: 'app.example.com' },
|
match: { ports: 443, domains: 'app.example.com' },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
targets: [
|
targets: [{
|
||||||
{ host: 'server1.internal', port: 8080 },
|
host: ['server1.internal', 'server2.internal', 'server3.internal'],
|
||||||
{ host: 'server2.internal', port: 8080 },
|
port: 8080,
|
||||||
{ host: 'server3.internal', port: 8080 }
|
loadBalancing: { algorithm: 'round-robin' }
|
||||||
],
|
}],
|
||||||
tls: { mode: 'terminate', certificate: 'auto' },
|
tls: { mode: 'terminate', certificate: 'auto' }
|
||||||
loadBalancing: {
|
|
||||||
algorithm: 'round-robin',
|
|
||||||
healthCheck: {
|
|
||||||
path: '/health',
|
|
||||||
interval: 30000,
|
|
||||||
timeout: 5000,
|
|
||||||
unhealthyThreshold: 3,
|
|
||||||
healthyThreshold: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -649,7 +639,9 @@ Supply your own certificates or integrate with external certificate providers:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
certProvisionFunction: async (domain: string) => {
|
certProvisionFunction: async (domain, eventComms) => {
|
||||||
|
eventComms.setSource('custom-acme-provider');
|
||||||
|
|
||||||
// Return 'http01' to let the built-in ACME handle it
|
// Return 'http01' to let the built-in ACME handle it
|
||||||
if (domain.endsWith('.example.com')) return 'http01';
|
if (domain.endsWith('.example.com')) return 'http01';
|
||||||
|
|
||||||
@@ -672,7 +664,11 @@ SmartProxy **never writes certificates to disk**. Instead, you own all persisten
|
|||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
routes: [...],
|
routes: [...],
|
||||||
|
|
||||||
certProvisionFunction: async (domain) => myAcme.provision(domain),
|
certProvisionFunction: async (domain, eventComms) => {
|
||||||
|
const cert = await myAcme.provision(domain);
|
||||||
|
eventComms.setExpiryDate(new Date(cert.validUntil));
|
||||||
|
return cert;
|
||||||
|
},
|
||||||
|
|
||||||
// Your persistence layer — SmartProxy calls these hooks
|
// Your persistence layer — SmartProxy calls these hooks
|
||||||
certStore: {
|
certStore: {
|
||||||
@@ -776,6 +772,8 @@ type TPortRange = number | Array<number | { from: number; to: number }>;
|
|||||||
| `forward` | Proxy to one or more backend targets (with optional TLS, WebSocket, load balancing, UDP/QUIC) |
|
| `forward` | Proxy to one or more backend targets (with optional TLS, WebSocket, load balancing, UDP/QUIC) |
|
||||||
| `socket-handler` | Custom socket/datagram handling function in TypeScript |
|
| `socket-handler` | Custom socket/datagram handling function in TypeScript |
|
||||||
|
|
||||||
|
`targets` are evaluated as route-internal sub-routes by `target.match` and `target.priority`. For load balancing across equivalent upstreams, use a single target with `host: ['a', 'b', 'c']` and target-level `loadBalancing`.
|
||||||
|
|
||||||
### Target Options
|
### Target Options
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -847,6 +845,8 @@ interface IRouteLoadBalancing {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use this on an `IRouteTarget` with `host` as a string array. The `healthCheck` shape is accepted by the type layer, but active backend health polling is not currently performed by the Rust selector.
|
||||||
|
|
||||||
### Backend Protocol Options
|
### Backend Protocol Options
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -924,6 +924,7 @@ class SmartProxy extends EventEmitter {
|
|||||||
|
|
||||||
// Route Management (atomic, mutex-locked)
|
// Route Management (atomic, mutex-locked)
|
||||||
updateRoutes(routes: IRouteConfig[]): Promise<void>;
|
updateRoutes(routes: IRouteConfig[]): Promise<void>;
|
||||||
|
updateSecurityPolicy(policy: ISmartProxySecurityPolicy): Promise<void>;
|
||||||
|
|
||||||
// Port Management
|
// Port Management
|
||||||
addListeningPort(port: number): Promise<void>;
|
addListeningPort(port: number): Promise<void>;
|
||||||
@@ -932,7 +933,7 @@ class SmartProxy extends EventEmitter {
|
|||||||
|
|
||||||
// Monitoring & Metrics
|
// Monitoring & Metrics
|
||||||
getMetrics(): IMetrics; // Sync — returns cached metrics adapter
|
getMetrics(): IMetrics; // Sync — returns cached metrics adapter
|
||||||
getStatistics(): Promise<any>; // Async — queries Rust engine
|
getStatistics(): Promise<IRustStatistics>; // Async — queries Rust engine
|
||||||
|
|
||||||
// Certificate Management
|
// Certificate Management
|
||||||
provisionCertificate(routeName: string): Promise<void>;
|
provisionCertificate(routeName: string): Promise<void>;
|
||||||
@@ -967,7 +968,10 @@ interface ISmartProxyOptions {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Custom certificate provisioning
|
// Custom certificate provisioning
|
||||||
certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>;
|
certProvisionFunction?: (
|
||||||
|
domain: string,
|
||||||
|
eventComms: ICertProvisionEventComms
|
||||||
|
) => Promise<TSmartProxyCertProvisionObject>;
|
||||||
certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true)
|
certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true)
|
||||||
certProvisionTimeout?: number; // Timeout per provision call (ms)
|
certProvisionTimeout?: number; // Timeout per provision call (ms)
|
||||||
certProvisionConcurrency?: number; // Max concurrent provisions
|
certProvisionConcurrency?: number; // Max concurrent provisions
|
||||||
@@ -1003,6 +1007,10 @@ interface ISmartProxyOptions {
|
|||||||
// Connection limits
|
// Connection limits
|
||||||
maxConnectionsPerIP?: number; // Per-IP connection limit (default: 100)
|
maxConnectionsPerIP?: number; // Per-IP connection limit (default: 100)
|
||||||
connectionRateLimitPerMinute?: number; // Per-IP rate limit (default: 300/min)
|
connectionRateLimitPerMinute?: number; // Per-IP rate limit (default: 300/min)
|
||||||
|
securityPolicy?: {
|
||||||
|
blockedIps?: string[];
|
||||||
|
blockedCidrs?: string[];
|
||||||
|
}; // Global ingress block policy
|
||||||
|
|
||||||
// Keep-alive
|
// Keep-alive
|
||||||
keepAliveTreatment?: 'standard' | 'extended' | 'immortal';
|
keepAliveTreatment?: 'standard' | 'extended' | 'immortal';
|
||||||
@@ -1055,17 +1063,25 @@ metrics.connections.total(); // Total connections since start
|
|||||||
metrics.connections.byRoute(); // Map<routeName, activeCount>
|
metrics.connections.byRoute(); // Map<routeName, activeCount>
|
||||||
metrics.connections.byIP(); // Map<ip, activeCount>
|
metrics.connections.byIP(); // Map<ip, activeCount>
|
||||||
metrics.connections.topIPs(10); // Top N IPs by connection count
|
metrics.connections.topIPs(10); // Top N IPs by connection count
|
||||||
|
metrics.connections.domainRequestsByIP(); // Map<ip, Map<domain, requestCount>>
|
||||||
|
metrics.connections.topDomainRequests(20); // Top IP/domain pairs by request count
|
||||||
|
metrics.connections.frontendProtocols(); // H1/H2/H3/WS frontend distribution
|
||||||
|
metrics.connections.backendProtocols(); // H1/H2/H3/WS backend distribution
|
||||||
|
|
||||||
// Throughput (bytes/sec)
|
// Throughput (bytes/sec)
|
||||||
metrics.throughput.instant(); // { in: number, out: number }
|
metrics.throughput.instant(); // { in: number, out: number }
|
||||||
metrics.throughput.recent(); // Recent average
|
metrics.throughput.recent(); // Recent average
|
||||||
metrics.throughput.average(); // Overall average
|
metrics.throughput.average(); // Overall average
|
||||||
|
metrics.throughput.custom(30); // Custom window, if provided by Rust cache
|
||||||
|
metrics.throughput.history(60); // Recent throughput samples
|
||||||
metrics.throughput.byRoute(); // Map<routeName, { in, out }>
|
metrics.throughput.byRoute(); // Map<routeName, { in, out }>
|
||||||
|
metrics.throughput.byIP(); // Map<ip, { in, out }>
|
||||||
|
|
||||||
// Request rates
|
// Request rates
|
||||||
metrics.requests.perSecond(); // Requests per second
|
metrics.requests.perSecond(); // Requests per second
|
||||||
metrics.requests.perMinute(); // Requests per minute
|
metrics.requests.perMinute(); // Requests per minute
|
||||||
metrics.requests.total(); // Total requests
|
metrics.requests.total(); // Total requests
|
||||||
|
metrics.requests.byDomain(); // Map<domain, { perSecond, lastMinute }>
|
||||||
|
|
||||||
// UDP metrics
|
// UDP metrics
|
||||||
metrics.udp.activeSessions(); // Current active UDP sessions
|
metrics.udp.activeSessions(); // Current active UDP sessions
|
||||||
@@ -1082,12 +1098,15 @@ metrics.totals.connections(); // Total connections
|
|||||||
metrics.backends.byBackend(); // Map<backend, IBackendMetrics>
|
metrics.backends.byBackend(); // Map<backend, IBackendMetrics>
|
||||||
metrics.backends.protocols(); // Map<backend, protocol>
|
metrics.backends.protocols(); // Map<backend, protocol>
|
||||||
metrics.backends.topByErrors(10); // Top N error-prone backends
|
metrics.backends.topByErrors(10); // Top N error-prone backends
|
||||||
|
metrics.backends.detectedProtocols(); // Backend protocol discovery cache
|
||||||
|
|
||||||
// Percentiles
|
// Percentiles
|
||||||
metrics.percentiles.connectionDuration(); // { p50, p95, p99 }
|
metrics.percentiles.connectionDuration(); // { p50, p95, p99 }
|
||||||
metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } }
|
metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The percentile methods are part of the public metrics shape. In the current Rust adapter they return zeroed values until percentile collection is implemented in the Rust metrics snapshot.
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Certificate Issues
|
### Certificate Issues
|
||||||
|
|||||||
@@ -280,6 +280,11 @@ impl ConnectionPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a QUIC/HTTP/3 connection from the pool unconditionally.
|
||||||
|
pub fn remove_h3(&self, key: &PoolKey) {
|
||||||
|
self.h3_pool.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
/// Background eviction loop — runs every EVICTION_INTERVAL to remove stale connections.
|
/// Background eviction loop — runs every EVICTION_INTERVAL to remove stale connections.
|
||||||
async fn eviction_loop(
|
async fn eviction_loop(
|
||||||
h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>,
|
h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>,
|
||||||
|
|||||||
@@ -92,6 +92,23 @@ enum ProtocolDecision {
|
|||||||
AlpnProbe,
|
AlpnProbe,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct H3ForwardError {
|
||||||
|
status: StatusCode,
|
||||||
|
message: &'static str,
|
||||||
|
retryable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl H3ForwardError {
|
||||||
|
fn new(status: StatusCode, message: &'static str, retryable: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
retryable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// RAII guard that decrements the active request counter on drop.
|
/// RAII guard that decrements the active request counter on drop.
|
||||||
/// Ensures the counter is correct even if the request handler panics.
|
/// Ensures the counter is correct even if the request handler panics.
|
||||||
struct ActiveRequestGuard {
|
struct ActiveRequestGuard {
|
||||||
@@ -972,6 +989,11 @@ impl HttpProxyService {
|
|||||||
use_tls: true,
|
use_tls: true,
|
||||||
protocol: crate::connection_pool::PoolProtocol::H3,
|
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||||
};
|
};
|
||||||
|
let h3_retry_state = if body.is_end_stream() {
|
||||||
|
Some((parts.method.clone(), upstream_headers.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Try H3 pool checkout first
|
// Try H3 pool checkout first
|
||||||
if let Some((pooled_sr, quic_conn, _age)) =
|
if let Some((pooled_sr, quic_conn, _age)) =
|
||||||
@@ -990,13 +1012,53 @@ impl HttpProxyService {
|
|||||||
route_id,
|
route_id,
|
||||||
&ip_str,
|
&ip_str,
|
||||||
&h3_pool_key,
|
&h3_pool_key,
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
Some(protocol_cache_key.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
domain_str,
|
domain_str,
|
||||||
&conn_activity,
|
&conn_activity,
|
||||||
&upstream_key,
|
&upstream_key,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
match result {
|
||||||
return result;
|
Ok(response) => {
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
self.protocol_cache.record_failure(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H3,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if is_auto_detect_mode && error.retryable {
|
||||||
|
if let Some((method, headers)) = h3_retry_state {
|
||||||
|
let fallback = self
|
||||||
|
.retry_h3_failure_as_h1(
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
&upstream_path,
|
||||||
|
&upstream,
|
||||||
|
route_match.route,
|
||||||
|
route_id,
|
||||||
|
&ip_str,
|
||||||
|
&protocol_cache_key,
|
||||||
|
domain_str,
|
||||||
|
&conn_activity,
|
||||||
|
&upstream_key,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return Ok(error_response(error.status, error.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try fresh QUIC connection
|
// Try fresh QUIC connection
|
||||||
@@ -1019,13 +1081,54 @@ impl HttpProxyService {
|
|||||||
route_id,
|
route_id,
|
||||||
&ip_str,
|
&ip_str,
|
||||||
&h3_pool_key,
|
&h3_pool_key,
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
Some(protocol_cache_key.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
domain_str,
|
domain_str,
|
||||||
&conn_activity,
|
&conn_activity,
|
||||||
&upstream_key,
|
&upstream_key,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
match result {
|
||||||
return result;
|
Ok(response) => {
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.metrics.backend_connection_closed(&upstream_key);
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
self.protocol_cache.record_failure(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H3,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if is_auto_detect_mode && error.retryable {
|
||||||
|
if let Some((method, headers)) = h3_retry_state {
|
||||||
|
let fallback = self
|
||||||
|
.retry_h3_failure_as_h1(
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
&upstream_path,
|
||||||
|
&upstream,
|
||||||
|
route_match.route,
|
||||||
|
route_id,
|
||||||
|
&ip_str,
|
||||||
|
&protocol_cache_key,
|
||||||
|
domain_str,
|
||||||
|
&conn_activity,
|
||||||
|
&upstream_key,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return Ok(error_response(error.status, error.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(backend = %upstream_key, domain = %domain_str, error = %e,
|
warn!(backend = %upstream_key, domain = %domain_str, error = %e,
|
||||||
@@ -1236,13 +1339,23 @@ impl HttpProxyService {
|
|||||||
route_id,
|
route_id,
|
||||||
&ip_str,
|
&ip_str,
|
||||||
&h3_pool_key,
|
&h3_pool_key,
|
||||||
|
Some(protocol_cache_key.clone()),
|
||||||
domain_str,
|
domain_str,
|
||||||
&conn_activity,
|
&conn_activity,
|
||||||
&upstream_key,
|
&upstream_key,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
return result;
|
return match result {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
|
Err(error) => {
|
||||||
|
self.protocol_cache.record_failure(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H3,
|
||||||
|
);
|
||||||
|
Ok(error_response(error.status, error.message))
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Err(e3) => {
|
Err(e3) => {
|
||||||
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
||||||
@@ -1313,13 +1426,23 @@ impl HttpProxyService {
|
|||||||
route_id,
|
route_id,
|
||||||
&ip_str,
|
&ip_str,
|
||||||
&h3_pool_key,
|
&h3_pool_key,
|
||||||
|
Some(protocol_cache_key.clone()),
|
||||||
domain_str,
|
domain_str,
|
||||||
&conn_activity,
|
&conn_activity,
|
||||||
&upstream_key,
|
&upstream_key,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
return result;
|
return match result {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
|
Err(error) => {
|
||||||
|
self.protocol_cache.record_failure(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H3,
|
||||||
|
);
|
||||||
|
Ok(error_response(error.status, error.message))
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Err(e3) => {
|
Err(e3) => {
|
||||||
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
||||||
@@ -1410,13 +1533,23 @@ impl HttpProxyService {
|
|||||||
route_id,
|
route_id,
|
||||||
&ip_str,
|
&ip_str,
|
||||||
&h3_pool_key,
|
&h3_pool_key,
|
||||||
|
Some(protocol_cache_key.clone()),
|
||||||
domain_str,
|
domain_str,
|
||||||
&conn_activity,
|
&conn_activity,
|
||||||
&upstream_key,
|
&upstream_key,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
return result;
|
return match result {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
|
Err(error) => {
|
||||||
|
self.protocol_cache.record_failure(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H3,
|
||||||
|
);
|
||||||
|
Ok(error_response(error.status, error.message))
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Err(e3) => {
|
Err(e3) => {
|
||||||
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
||||||
@@ -1487,13 +1620,23 @@ impl HttpProxyService {
|
|||||||
route_id,
|
route_id,
|
||||||
&ip_str,
|
&ip_str,
|
||||||
&h3_pool_key,
|
&h3_pool_key,
|
||||||
|
Some(protocol_cache_key.clone()),
|
||||||
domain_str,
|
domain_str,
|
||||||
&conn_activity,
|
&conn_activity,
|
||||||
&upstream_key,
|
&upstream_key,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
return result;
|
return match result {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
|
Err(error) => {
|
||||||
|
self.protocol_cache.record_failure(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H3,
|
||||||
|
);
|
||||||
|
Ok(error_response(error.status, error.message))
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Err(e3) => {
|
Err(e3) => {
|
||||||
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
||||||
@@ -2402,6 +2545,58 @@ impl HttpProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retry a bodyless request over HTTP/1.1 after H3 failed post-connect.
|
||||||
|
/// The original body has already been handed to the H3 path, so only empty
|
||||||
|
/// requests can be retried safely without duplicating client data.
|
||||||
|
async fn retry_h3_failure_as_h1(
|
||||||
|
&self,
|
||||||
|
method: hyper::Method,
|
||||||
|
upstream_headers: hyper::HeaderMap,
|
||||||
|
upstream_path: &str,
|
||||||
|
upstream: &crate::upstream_selector::UpstreamSelection,
|
||||||
|
route: &rustproxy_config::RouteConfig,
|
||||||
|
route_id: Option<&str>,
|
||||||
|
source_ip: &str,
|
||||||
|
protocol_cache_key: &crate::protocol_cache::ProtocolCacheKey,
|
||||||
|
domain: &str,
|
||||||
|
conn_activity: &ConnActivity,
|
||||||
|
backend_key: &str,
|
||||||
|
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||||
|
warn!(backend = %backend_key, domain = %domain,
|
||||||
|
"H3 forwarding failed, retrying bodyless request as HTTP/1.1");
|
||||||
|
self.protocol_cache.insert(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H1,
|
||||||
|
"H3 forwarding failure — downgrade to H1",
|
||||||
|
);
|
||||||
|
|
||||||
|
match self.reconnect_backend(upstream, domain, backend_key).await {
|
||||||
|
Some(fallback_backend) => {
|
||||||
|
let fallback_io = TokioIo::new(fallback_backend);
|
||||||
|
let result = self
|
||||||
|
.forward_h1_empty_body(
|
||||||
|
fallback_io,
|
||||||
|
method,
|
||||||
|
upstream_headers,
|
||||||
|
upstream_path,
|
||||||
|
route,
|
||||||
|
route_id,
|
||||||
|
source_ip,
|
||||||
|
domain,
|
||||||
|
conn_activity,
|
||||||
|
backend_key,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.metrics.backend_connection_closed(backend_key);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
None => Ok(error_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"Backend unavailable after H3 fallback",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Forward a request with an empty body via HTTP/1.1.
|
/// Forward a request with an empty body via HTTP/1.1.
|
||||||
/// Used when retrying after a failed H2 attempt where the original body was consumed.
|
/// Used when retrying after a failed H2 attempt where the original body was consumed.
|
||||||
async fn forward_h1_empty_body(
|
async fn forward_h1_empty_body(
|
||||||
@@ -3552,10 +3747,11 @@ impl HttpProxyService {
|
|||||||
route_id: Option<&str>,
|
route_id: Option<&str>,
|
||||||
source_ip: &str,
|
source_ip: &str,
|
||||||
pool_key: &crate::connection_pool::PoolKey,
|
pool_key: &crate::connection_pool::PoolKey,
|
||||||
|
protocol_cache_key: Option<crate::protocol_cache::ProtocolCacheKey>,
|
||||||
domain: &str,
|
domain: &str,
|
||||||
conn_activity: &ConnActivity,
|
conn_activity: &ConnActivity,
|
||||||
backend_key: &str,
|
backend_key: &str,
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, H3ForwardError> {
|
||||||
// Obtain the h3 SendRequest handle: skip handshake + driver on pool hit.
|
// Obtain the h3 SendRequest handle: skip handshake + driver on pool hit.
|
||||||
let (mut send_request, gen_holder) = if let Some(sr) = pooled_sender {
|
let (mut send_request, gen_holder) = if let Some(sr) = pooled_sender {
|
||||||
// Pool hit — reuse existing h3 session, no SETTINGS round-trip
|
// Pool hit — reuse existing h3 session, no SETTINGS round-trip
|
||||||
@@ -3572,9 +3768,11 @@ impl HttpProxyService {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
|
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
|
||||||
self.metrics.backend_handshake_error(backend_key);
|
self.metrics.backend_handshake_error(backend_key);
|
||||||
return Ok(error_response(
|
self.connection_pool.remove_h3(pool_key);
|
||||||
|
return Err(H3ForwardError::new(
|
||||||
StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
"H3 handshake failed",
|
"H3 handshake failed",
|
||||||
|
true,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3623,7 +3821,12 @@ impl HttpProxyService {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(backend = %backend_key, domain = %domain, error = %e, "H3 send_request failed");
|
error!(backend = %backend_key, domain = %domain, error = %e, "H3 send_request failed");
|
||||||
self.metrics.backend_request_error(backend_key);
|
self.metrics.backend_request_error(backend_key);
|
||||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 request failed"));
|
self.connection_pool.remove_h3(pool_key);
|
||||||
|
return Err(H3ForwardError::new(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"H3 request failed",
|
||||||
|
true,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3646,9 +3849,11 @@ impl HttpProxyService {
|
|||||||
);
|
);
|
||||||
if let Err(e) = stream.send_data(data).await {
|
if let Err(e) = stream.send_data(data).await {
|
||||||
error!(backend = %backend_key, error = %e, "H3 send_data failed");
|
error!(backend = %backend_key, error = %e, "H3 send_data failed");
|
||||||
return Ok(error_response(
|
self.connection_pool.remove_h3(pool_key);
|
||||||
|
return Err(H3ForwardError::new(
|
||||||
StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
"H3 body send failed",
|
"H3 body send failed",
|
||||||
|
false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3669,9 +3874,11 @@ impl HttpProxyService {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(backend = %backend_key, domain = %domain, error = %e, "H3 recv_response failed");
|
error!(backend = %backend_key, domain = %domain, error = %e, "H3 recv_response failed");
|
||||||
self.metrics.backend_request_error(backend_key);
|
self.metrics.backend_request_error(backend_key);
|
||||||
return Ok(error_response(
|
self.connection_pool.remove_h3(pool_key);
|
||||||
|
return Err(H3ForwardError::new(
|
||||||
StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
"H3 response failed",
|
"H3 response failed",
|
||||||
|
true,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3693,17 +3900,34 @@ impl HttpProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stream response body back via unfold — correctly preserves waker across polls
|
// Stream response body back via unfold — correctly preserves waker across polls
|
||||||
let body_stream = futures::stream::unfold(stream, |mut s| async move {
|
let h3_failure_cache = Arc::clone(&self.protocol_cache);
|
||||||
match s.recv_data().await {
|
let h3_failure_cache_key = protocol_cache_key.clone();
|
||||||
Ok(Some(mut buf)) => {
|
let h3_failure_pool = Arc::clone(&self.connection_pool);
|
||||||
use bytes::Buf;
|
let h3_failure_pool_key = pool_key.clone();
|
||||||
let data = buf.copy_to_bytes(buf.remaining());
|
let body_stream = futures::stream::unfold(stream, move |mut s| {
|
||||||
Some((Ok::<_, hyper::Error>(http_body::Frame::data(data)), s))
|
let h3_failure_cache = Arc::clone(&h3_failure_cache);
|
||||||
}
|
let h3_failure_cache_key = h3_failure_cache_key.clone();
|
||||||
Ok(None) => None,
|
let h3_failure_pool = Arc::clone(&h3_failure_pool);
|
||||||
Err(e) => {
|
let h3_failure_pool_key = h3_failure_pool_key.clone();
|
||||||
warn!("H3 response body recv error: {}", e);
|
async move {
|
||||||
None
|
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);
|
||||||
|
if let Some(cache_key) = h3_failure_cache_key {
|
||||||
|
h3_failure_cache.record_failure(
|
||||||
|
cache_key,
|
||||||
|
crate::protocol_cache::DetectedProtocol::H3,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
h3_failure_pool.remove_h3(&h3_failure_pool_key);
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { IpUtils } from '../../../ts/core/utils/ip-utils.js';
|
import { IpMatcher } from '../../../ts/core/routing/matchers/ip.js';
|
||||||
|
|
||||||
|
const isGlobIPMatch = (ip: string, patterns: string[]): boolean =>
|
||||||
|
patterns.some((pattern) => IpMatcher.match(pattern, ip));
|
||||||
|
|
||||||
|
const isIPAuthorized = (ip: string, allowedIPs: string[], blockedIPs: string[]): boolean =>
|
||||||
|
IpMatcher.isAuthorized(ip, allowedIPs, blockedIPs);
|
||||||
|
|
||||||
// Test the overlap case
|
// Test the overlap case
|
||||||
const result = IpUtils.isIPAuthorized('127.0.0.1', ['127.0.0.1'], ['127.0.0.1']);
|
const result = isIPAuthorized('127.0.0.1', ['127.0.0.1'], ['127.0.0.1']);
|
||||||
console.log('Result of IP that is both allowed and blocked:', result);
|
console.log('Result of IP that is both allowed and blocked:', result);
|
||||||
|
|
||||||
// Trace through the code logic
|
// Trace through the code logic
|
||||||
@@ -13,10 +19,10 @@ console.log('Step 1 check:', (!ip || (allowedIPs.length === 0 && blockedIPs.leng
|
|||||||
|
|
||||||
// Check if IP is blocked - blocked IPs take precedence
|
// Check if IP is blocked - blocked IPs take precedence
|
||||||
console.log('blockedIPs length > 0:', blockedIPs.length > 0);
|
console.log('blockedIPs length > 0:', blockedIPs.length > 0);
|
||||||
console.log('isGlobIPMatch result:', IpUtils.isGlobIPMatch(ip, blockedIPs));
|
console.log('isGlobIPMatch result:', isGlobIPMatch(ip, blockedIPs));
|
||||||
console.log('Step 2 check (is blocked):', (blockedIPs.length > 0 && IpUtils.isGlobIPMatch(ip, blockedIPs)));
|
console.log('Step 2 check (is blocked):', (blockedIPs.length > 0 && isGlobIPMatch(ip, blockedIPs)));
|
||||||
|
|
||||||
// Check if IP is allowed
|
// Check if IP is allowed
|
||||||
console.log('allowedIPs length === 0:', allowedIPs.length === 0);
|
console.log('allowedIPs length === 0:', allowedIPs.length === 0);
|
||||||
console.log('isGlobIPMatch for allowed:', IpUtils.isGlobIPMatch(ip, allowedIPs));
|
console.log('isGlobIPMatch for allowed:', isGlobIPMatch(ip, allowedIPs));
|
||||||
console.log('Step 3 (is allowed):', allowedIPs.length === 0 || IpUtils.isGlobIPMatch(ip, allowedIPs));
|
console.log('Step 3 (is allowed):', allowedIPs.length === 0 || isGlobIPMatch(ip, allowedIPs));
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export function loadTestCertificates(): TestCertificates {
|
|||||||
key: privateKey
|
key: privateKey
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Invalid certificates: ${error.message}`);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Invalid certificates: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { SmartProxy } from '../ts/index.js';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as http2 from 'node:http2';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { findFreePorts } from './helpers/port-allocator.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const CERT_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8');
|
||||||
|
const KEY_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8');
|
||||||
|
const TEST_DOMAIN = 'verdaccio.test';
|
||||||
|
|
||||||
|
let backendPort: number;
|
||||||
|
let proxyPort: number;
|
||||||
|
let unavailableH3Port: number;
|
||||||
|
let backendServer: https.Server;
|
||||||
|
let proxy: SmartProxy;
|
||||||
|
|
||||||
|
function httpsRequest(requestPath: string): Promise<{ status: number; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: proxyPort,
|
||||||
|
path: requestPath,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Host: TEST_DOMAIN,
|
||||||
|
},
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
servername: TEST_DOMAIN,
|
||||||
|
agent: new https.Agent({ keepAlive: false, rejectUnauthorized: false }),
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(5000, () => req.destroy(new Error('https request timeout')));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function http2Request(requestPath: string): Promise<{ status: number; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const session = http2.connect(`https://localhost:${proxyPort}`, {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
servername: TEST_DOMAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (!session.closed && !session.destroyed) {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
session.once('error', (error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
session.once('connect', () => {
|
||||||
|
const req = session.request({
|
||||||
|
':method': 'GET',
|
||||||
|
':path': requestPath,
|
||||||
|
':authority': TEST_DOMAIN,
|
||||||
|
});
|
||||||
|
let status = 0;
|
||||||
|
let body = '';
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
req.on('response', (headers) => {
|
||||||
|
status = Number(headers[':status'] ?? 0);
|
||||||
|
});
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
cleanup();
|
||||||
|
resolve({ status, body });
|
||||||
|
});
|
||||||
|
req.on('error', (error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('http2 request timeout'));
|
||||||
|
}, 5000).unref();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('setup - backend with Alt-Svc H3 hint and TLS proxy', async () => {
|
||||||
|
[backendPort, proxyPort, unavailableH3Port] = await findFreePorts(3);
|
||||||
|
|
||||||
|
backendServer = https.createServer({ key: KEY_PEM, cert: CERT_PEM }, (req, res) => {
|
||||||
|
const body = JSON.stringify({ ok: true, url: req.url, host: req.headers.host });
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': Buffer.byteLength(body),
|
||||||
|
'alt-svc': `h3=":${unavailableH3Port}"; ma=86400`,
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
backendServer.once('error', reject);
|
||||||
|
backendServer.listen(backendPort, () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
proxy = new SmartProxy({
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
id: 'backend-protocol-fallback',
|
||||||
|
name: 'backend-protocol-fallback',
|
||||||
|
match: { ports: proxyPort, domains: TEST_DOMAIN },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
tls: {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: {
|
||||||
|
key: KEY_PEM,
|
||||||
|
cert: CERT_PEM,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
host: 'localhost',
|
||||||
|
port: backendPort,
|
||||||
|
tls: { mode: 'passthrough' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: { backendProtocol: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connectionTimeout: 500,
|
||||||
|
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 30 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await proxy.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('backend protocol auto: fresh HTTP/1.1 survives unavailable H3 hint', async (tools) => {
|
||||||
|
tools.timeout(10000);
|
||||||
|
|
||||||
|
const first = await httpsRequest('/@consent.software%2Fcatalog');
|
||||||
|
expect(first.status).toEqual(200);
|
||||||
|
expect(JSON.parse(first.body).ok).toEqual(true);
|
||||||
|
|
||||||
|
const second = await httpsRequest('/@consent.software%2Fcatalog?retry=1');
|
||||||
|
expect(second.status).toEqual(200);
|
||||||
|
expect(JSON.parse(second.body).url).toEqual('/@consent.software%2Fcatalog?retry=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('backend protocol auto: fresh HTTP/2 survives suppressed H3 hint', async (tools) => {
|
||||||
|
tools.timeout(10000);
|
||||||
|
|
||||||
|
const result = await http2Request('/@consent.software%2Fcatalog?frontend=h2');
|
||||||
|
expect(result.status).toEqual(200);
|
||||||
|
expect(JSON.parse(result.body).url).toEqual('/@consent.software%2Fcatalog?frontend=h2');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - backend protocol fallback', async () => {
|
||||||
|
await proxy.stop();
|
||||||
|
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -79,18 +79,23 @@ testFn('NFTables integration tests', async () => {
|
|||||||
const status = await smartProxy.getNfTablesStatus();
|
const status = await smartProxy.getNfTablesStatus();
|
||||||
console.log('NFTables status:', JSON.stringify(status, null, 2));
|
console.log('NFTables status:', JSON.stringify(status, null, 2));
|
||||||
|
|
||||||
expect(Object.keys(status).length).toEqual(routes.length);
|
if (!status) {
|
||||||
|
throw new Error('Expected NFTables status after SmartProxy start');
|
||||||
|
}
|
||||||
|
|
||||||
for (const routeStatus of Object.values(status)) {
|
expect(status.activeGroups).toEqual(routes.length);
|
||||||
expect(routeStatus.active).toBeTrue();
|
expect(Object.keys(status.groups).length).toEqual(routes.length);
|
||||||
expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
|
|
||||||
|
for (const routeStatus of Object.values(status.groups)) {
|
||||||
|
expect(routeStatus.ruleCount).toBeGreaterThan(0);
|
||||||
|
expect(routeStatus.createdAt).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
await smartProxy.stop();
|
await smartProxy.stop();
|
||||||
console.log('SmartProxy stopped');
|
console.log('SmartProxy stopped');
|
||||||
|
|
||||||
const finalStatus = await smartProxy.getNfTablesStatus();
|
const finalStatus = await smartProxy.getNfTablesStatus();
|
||||||
expect(Object.keys(finalStatus).length).toEqual(0);
|
expect(finalStatus).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -150,8 +150,9 @@ tap.skip.test('setup NFTables integration test environment', async () => {
|
|||||||
type: 'forward',
|
type: 'forward',
|
||||||
forwardingEngine: 'nftables',
|
forwardingEngine: 'nftables',
|
||||||
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||||
nftables: { protocol: 'tcp', ipAllowList: ['127.0.0.1', '::1'] }
|
nftables: { protocol: 'tcp' }
|
||||||
},
|
},
|
||||||
|
security: { ipAllowList: ['127.0.0.1', '::1'] },
|
||||||
name: 'secure-tcp'
|
name: 'secure-tcp'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ tap.skip.test('setup NFTables integration test environment', async () => {
|
|||||||
await smartProxy.start();
|
await smartProxy.start();
|
||||||
console.log('SmartProxy started successfully');
|
console.log('SmartProxy started successfully');
|
||||||
|
|
||||||
const listeningPorts = smartProxy.getListeningPorts();
|
const listeningPorts = await smartProxy.getListeningPorts();
|
||||||
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
|
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start SmartProxy:', err);
|
console.error('Failed to start SmartProxy:', err);
|
||||||
@@ -301,14 +302,19 @@ tap.skip.test('should respect IP allow lists in NFTables', async () => {
|
|||||||
tap.skip.test('should get NFTables status', async () => {
|
tap.skip.test('should get NFTables status', async () => {
|
||||||
const status = await smartProxy.getNfTablesStatus();
|
const status = await smartProxy.getNfTablesStatus();
|
||||||
|
|
||||||
const statusKeys = Object.keys(status);
|
if (!status) {
|
||||||
|
throw new Error('Expected NFTables status after SmartProxy start');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusKeys = Object.keys(status.groups);
|
||||||
expect(statusKeys.length).toBeGreaterThan(0);
|
expect(statusKeys.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const firstStatus = status[statusKeys[0]];
|
const firstStatus = Object.values(status.groups)[0];
|
||||||
expect(firstStatus).toHaveProperty('active');
|
if (!firstStatus) {
|
||||||
expect(firstStatus).toHaveProperty('ruleCount');
|
throw new Error('Expected at least one NFTables rule group');
|
||||||
expect(firstStatus.ruleCount).toHaveProperty('total');
|
}
|
||||||
expect(firstStatus.ruleCount).toHaveProperty('added');
|
expect(firstStatus.ruleCount).toBeGreaterThan(0);
|
||||||
|
expect(firstStatus.createdAt).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.skip.test('cleanup NFTables integration test environment', async () => {
|
tap.skip.test('cleanup NFTables integration test environment', async () => {
|
||||||
|
|||||||
@@ -327,12 +327,12 @@ tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
|
|||||||
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
|
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
|
||||||
expect(bestMatch).not.toBeUndefined();
|
expect(bestMatch).not.toBeUndefined();
|
||||||
if (bestMatch) {
|
if (bestMatch) {
|
||||||
expect(bestMatch.action.targets[0].port).toEqual(3001);
|
expect(bestMatch.action.targets?.[0]?.port).toEqual(3001);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
|
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
|
||||||
expect(otherMatches.length).toEqual(1);
|
expect(otherMatches.length).toEqual(1);
|
||||||
expect(otherMatches[0].action.targets[0].port).toEqual(3000);
|
expect(otherMatches[0]?.action.targets?.[0]?.port).toEqual(3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Edge Case - Disabled Routes', async () => {
|
tap.test('Edge Case - Disabled Routes', async () => {
|
||||||
@@ -353,7 +353,7 @@ tap.test('Edge Case - Disabled Routes', async () => {
|
|||||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||||
|
|
||||||
expect(matches.length).toEqual(1);
|
expect(matches.length).toEqual(1);
|
||||||
expect(matches[0].action.targets[0].port).toEqual(3000);
|
expect(matches[0]?.action.targets?.[0]?.port).toEqual(3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||||
@@ -487,7 +487,7 @@ tap.test('Wildcard Domain Handling', async () => {
|
|||||||
const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest);
|
const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest);
|
||||||
expect(bestSpecificMatch).not.toBeUndefined();
|
expect(bestSpecificMatch).not.toBeUndefined();
|
||||||
if (bestSpecificMatch) {
|
if (bestSpecificMatch) {
|
||||||
const matchedPort = bestSpecificMatch.action.targets[0].port;
|
const matchedPort = bestSpecificMatch.action.targets?.[0]?.port;
|
||||||
console.log(`Matched route with port: ${matchedPort}`);
|
console.log(`Matched route with port: ${matchedPort}`);
|
||||||
|
|
||||||
expect(bestSpecificMatch.priority).toEqual(200);
|
expect(bestSpecificMatch.priority).toEqual(200);
|
||||||
@@ -497,7 +497,7 @@ tap.test('Wildcard Domain Handling', async () => {
|
|||||||
const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest);
|
const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest);
|
||||||
expect(bestWildcardMatch).not.toBeUndefined();
|
expect(bestWildcardMatch).not.toBeUndefined();
|
||||||
if (bestWildcardMatch) {
|
if (bestWildcardMatch) {
|
||||||
const matchedPort = bestWildcardMatch.action.targets[0].port;
|
const matchedPort = bestWildcardMatch.action.targets?.[0]?.port;
|
||||||
console.log(`Matched route with port: ${matchedPort}`);
|
console.log(`Matched route with port: ${matchedPort}`);
|
||||||
|
|
||||||
expect(bestWildcardMatch.priority).toEqual(100);
|
expect(bestWildcardMatch.priority).toEqual(100);
|
||||||
@@ -573,7 +573,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
|||||||
expect(webServerMatch).not.toBeUndefined();
|
expect(webServerMatch).not.toBeUndefined();
|
||||||
if (webServerMatch) {
|
if (webServerMatch) {
|
||||||
expect(webServerMatch.action.type).toEqual('forward');
|
expect(webServerMatch.action.type).toEqual('forward');
|
||||||
expect(webServerMatch.action.targets[0].host).toEqual('web-server');
|
expect(webServerMatch.action.targets?.[0]?.host).toEqual('web-server');
|
||||||
}
|
}
|
||||||
|
|
||||||
const webRedirectMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
const webRedirectMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||||
@@ -590,7 +590,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
|||||||
expect(apiMatch).not.toBeUndefined();
|
expect(apiMatch).not.toBeUndefined();
|
||||||
if (apiMatch) {
|
if (apiMatch) {
|
||||||
expect(apiMatch.action.type).toEqual('forward');
|
expect(apiMatch.action.type).toEqual('forward');
|
||||||
expect(apiMatch.action.targets[0].host).toEqual('api-server');
|
expect(apiMatch.action.targets?.[0]?.host).toEqual('api-server');
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsMatch = findBestMatchingRoute(routes, {
|
const wsMatch = findBestMatchingRoute(routes, {
|
||||||
@@ -601,7 +601,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
|||||||
expect(wsMatch).not.toBeUndefined();
|
expect(wsMatch).not.toBeUndefined();
|
||||||
if (wsMatch) {
|
if (wsMatch) {
|
||||||
expect(wsMatch.action.type).toEqual('forward');
|
expect(wsMatch.action.type).toEqual('forward');
|
||||||
expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
|
expect(wsMatch.action.targets?.[0]?.host).toEqual('websocket-server');
|
||||||
expect(wsMatch.action.websocket?.enabled).toBeTrue();
|
expect(wsMatch.action.websocket?.enabled).toBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ tap.test('route-specific IP block list should be enforced', async () => {
|
|||||||
try {
|
try {
|
||||||
client.write('test data');
|
client.write('test data');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Write failed:', e.message);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
console.log('Write failed:', message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,4 +273,4 @@ tap.test('routes without security should allow all connections', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '27.9.0',
|
version: '27.10.2',
|
||||||
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.'
|
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ export { SmartProxy } from './proxies/smart-proxy/index.js';
|
|||||||
export { SharedRouteManager as RouteManager } from './core/routing/route-manager.js';
|
export { SharedRouteManager as RouteManager } from './core/routing/route-manager.js';
|
||||||
|
|
||||||
// Export smart-proxy models
|
// Export smart-proxy models
|
||||||
export type { ISmartProxyOptions, ISmartProxySecurityPolicy, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js';
|
export type { ISmartProxyOptions, ISmartProxySecurityPolicy, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext, TDatagramHandler, IDatagramInfo } from './proxies/smart-proxy/models/index.js';
|
||||||
export type { TSmartProxyCertProvisionObject, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './proxies/smart-proxy/models/interfaces.js';
|
export type { TSmartProxyCertProvisionObject, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './proxies/smart-proxy/models/interfaces.js';
|
||||||
export * from './proxies/smart-proxy/utils/index.js';
|
export * from './proxies/smart-proxy/utils/index.js';
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -4,9 +4,13 @@
|
|||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist_*/**/*.d.ts"
|
"dist_*/**/*.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user