From 2e3cf515a42dfb1e75b201befededfa191d9d71d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 16 Feb 2026 12:11:49 +0000 Subject: [PATCH] feat(routes): add protocol-based route matching and ensure terminate-and-reencrypt routes HTTP through the full HTTP proxy; update docs and tests --- changelog.md | 9 + readme.md | 57 ++++-- .../rustproxy/tests/integration_http_proxy.rs | 163 +++++++++++++++++ test/test.route-config.ts | 164 ++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- 5 files changed, 384 insertions(+), 11 deletions(-) diff --git a/changelog.md b/changelog.md index e7ffacf..b77dcfa 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-16 - 25.7.0 - feat(routes) +add protocol-based route matching and ensure terminate-and-reencrypt routes HTTP through the full HTTP proxy; update docs and tests + +- Introduce a new 'protocol' match field for routes (supports 'http' and 'tcp') and preserve it through cloning/merging. +- Add Rust integration test verifying terminate-and-reencrypt decrypts TLS and routes HTTP traffic via the HTTP proxy (per-request Host/path routing) instead of raw tunneling. +- Add TypeScript unit tests covering protocol field validation, preservation, interaction with terminate-and-reencrypt, cloning, merging, and matching behavior. +- Update README with a Protocol-Specific Routing section and clarify terminate-and-reencrypt behavior (HTTP routed via HTTP proxy; non-HTTP uses raw TLS-to-TLS tunnel). +- Example config: include health check thresholds (unhealthyThreshold and healthyThreshold) in the sample healthCheck settings. + ## 2026-02-16 - 25.6.0 - feat(rustproxy) add protocol-based routing and backend TLS re-encryption support diff --git a/readme.md b/readme.md index d16190e..e18a6dc 100644 --- a/readme.md +++ b/readme.md @@ -27,7 +27,7 @@ Whether you're building microservices, deploying edge infrastructure, or need a | 🦀 **Rust-Powered Engine** | All networking handled by a high-performance Rust binary via IPC | | 🔀 **Unified Route-Based Config** | Clean match/action patterns for intuitive traffic routing | | 🔒 **Automatic SSL/TLS** | Zero-config HTTPS with Let's Encrypt ACME integration | -| 🎯 **Flexible Matching** | Route by port, domain, path, 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 | | ⚖️ **Load Balancing** | Round-robin, least-connections, IP-hash with health checks | | 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits | @@ -89,7 +89,7 @@ SmartProxy uses a powerful **match/action** pattern that makes routing predictab ``` Every route consists of: -- **Match** — What traffic to capture (ports, domains, paths, IPs, headers) +- **Match** — What traffic to capture (ports, domains, paths, protocol, IPs, headers) - **Action** — What to do with it (`forward` or `socket-handler`) - **Security** (optional) — IP allow/block lists, rate limits, authentication - **Headers** (optional) — Request/response header manipulation with template variables @@ -103,7 +103,7 @@ SmartProxy supports three TLS handling modes: |------|-------------|----------| | `passthrough` | Forward encrypted traffic as-is (SNI-based routing) | Backend handles TLS | | `terminate` | Decrypt at proxy, forward plain HTTP to backend | Standard reverse proxy | -| `terminate-and-reencrypt` | Decrypt, then re-encrypt to backend | Zero-trust environments | +| `terminate-and-reencrypt` | Decrypt at proxy, re-encrypt to backend. HTTP traffic gets full per-request routing (Host header, path matching) via the HTTP proxy; non-HTTP traffic uses a raw TLS-to-TLS tunnel | Zero-trust / defense-in-depth environments | ## 💡 Common Use Cases @@ -135,13 +135,13 @@ const proxy = new SmartProxy({ ], { tls: { mode: 'terminate', certificate: 'auto' }, - loadBalancing: { - algorithm: 'round-robin', - healthCheck: { - path: '/health', - interval: 30000, - timeout: 5000 - } + algorithm: 'round-robin', + healthCheck: { + path: '/health', + interval: 30000, + timeout: 5000, + unhealthyThreshold: 3, + healthyThreshold: 2 } } ) @@ -318,6 +318,42 @@ const proxy = new SmartProxy({ > **Note:** Routes with dynamic functions (host/port callbacks) are automatically relayed through the TypeScript socket handler server, since JavaScript functions can't be serialized to Rust. +### 🔀 Protocol-Specific Routing + +Restrict routes to specific application-layer protocols. When `protocol` is set, the Rust engine detects the protocol after connection (or after TLS termination) and only matches routes that accept that protocol: + +```typescript +// HTTP-only route (rejects raw TCP connections) +const httpOnlyRoute: IRouteConfig = { + name: 'http-api', + match: { + ports: 443, + domains: 'api.example.com', + protocol: 'http', // Only match HTTP/1.1, HTTP/2, and WebSocket upgrades + }, + action: { + type: 'forward', + targets: [{ host: 'api-backend', port: 8080 }], + tls: { mode: 'terminate', certificate: 'auto' } + } +}; + +// Raw TCP route (rejects HTTP traffic) +const tcpOnlyRoute: IRouteConfig = { + name: 'database-proxy', + match: { + ports: 5432, + protocol: 'tcp', // Only match non-HTTP TCP streams + }, + action: { + type: 'forward', + targets: [{ host: 'db-server', port: 5432 }] + } +}; +``` + +> **Note:** Omitting `protocol` (the default) matches any protocol. For TLS routes, protocol detection happens *after* TLS termination — during the initial SNI-based route match, `protocol` is not yet known and the route is allowed to match. The protocol restriction is enforced after the proxy peeks at the decrypted data. + ### 🔒 Security Controls Comprehensive per-route security options: @@ -549,6 +585,7 @@ interface IRouteMatch { clientIp?: string[]; // ['10.0.0.0/8', '192.168.*'] tlsVersion?: string[]; // ['TLSv1.2', 'TLSv1.3'] headers?: Record; // Match by HTTP headers + protocol?: 'http' | 'tcp'; // Match specific protocol ('http' includes h2 + WebSocket upgrades) } ``` diff --git a/rust/crates/rustproxy/tests/integration_http_proxy.rs b/rust/crates/rustproxy/tests/integration_http_proxy.rs index 6651f54..03c2966 100644 --- a/rust/crates/rustproxy/tests/integration_http_proxy.rs +++ b/rust/crates/rustproxy/tests/integration_http_proxy.rs @@ -407,6 +407,169 @@ async fn test_websocket_through_proxy() { proxy.stop().await.unwrap(); } +/// Test that terminate-and-reencrypt mode routes HTTP traffic through the +/// full HTTP proxy with per-request Host-based routing. +/// +/// This verifies the new behavior: after TLS termination, HTTP data is detected +/// and routed through HttpProxyService (like nginx) instead of being blindly tunneled. +#[tokio::test] +async fn test_terminate_and_reencrypt_http_routing() { + let backend1_port = next_port(); + let backend2_port = next_port(); + let proxy_port = next_port(); + + // Start plain HTTP echo backends (proxy terminates client TLS, connects plain to backend) + let _b1 = start_http_echo_backend(backend1_port, "alpha").await; + let _b2 = start_http_echo_backend(backend2_port, "beta").await; + + let (cert1, key1) = generate_self_signed_cert("alpha.example.com"); + let (cert2, key2) = generate_self_signed_cert("beta.example.com"); + + // Create terminate-and-reencrypt routes + let mut route1 = make_tls_terminate_route( + proxy_port, "alpha.example.com", "127.0.0.1", backend1_port, &cert1, &key1, + ); + route1.action.tls.as_mut().unwrap().mode = rustproxy_config::TlsMode::TerminateAndReencrypt; + + let mut route2 = make_tls_terminate_route( + proxy_port, "beta.example.com", "127.0.0.1", backend2_port, &cert2, &key2, + ); + route2.action.tls.as_mut().unwrap().mode = rustproxy_config::TlsMode::TerminateAndReencrypt; + + let options = RustProxyOptions { + routes: vec![route1, route2], + ..Default::default() + }; + + let mut proxy = RustProxy::new(options).unwrap(); + proxy.start().await.unwrap(); + assert!(wait_for_port(proxy_port, 2000).await); + + // Test alpha domain - HTTP request through TLS terminate-and-reencrypt + let alpha_result = with_timeout(async { + let _ = rustls::crypto::ring::default_provider().install_default(); + let tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier)) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config)); + + let stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", proxy_port)) + .await + .unwrap(); + let server_name = rustls::pki_types::ServerName::try_from("alpha.example.com".to_string()).unwrap(); + let mut tls_stream = connector.connect(server_name, stream).await.unwrap(); + + let request = "GET /api/data HTTP/1.1\r\nHost: alpha.example.com\r\nConnection: close\r\n\r\n"; + tls_stream.write_all(request.as_bytes()).await.unwrap(); + + let mut response = Vec::new(); + tls_stream.read_to_end(&mut response).await.unwrap(); + String::from_utf8_lossy(&response).to_string() + }, 10) + .await + .unwrap(); + + let alpha_body = extract_body(&alpha_result); + assert!( + alpha_body.contains(r#""backend":"alpha"#), + "Expected alpha backend, got: {}", + alpha_body + ); + assert!( + alpha_body.contains(r#""method":"GET"#), + "Expected GET method, got: {}", + alpha_body + ); + assert!( + alpha_body.contains(r#""path":"/api/data"#), + "Expected /api/data path, got: {}", + alpha_body + ); + + // Test beta domain - different host goes to different backend + let beta_result = with_timeout(async { + let _ = rustls::crypto::ring::default_provider().install_default(); + let tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier)) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config)); + + let stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", proxy_port)) + .await + .unwrap(); + let server_name = rustls::pki_types::ServerName::try_from("beta.example.com".to_string()).unwrap(); + let mut tls_stream = connector.connect(server_name, stream).await.unwrap(); + + let request = "GET /other HTTP/1.1\r\nHost: beta.example.com\r\nConnection: close\r\n\r\n"; + tls_stream.write_all(request.as_bytes()).await.unwrap(); + + let mut response = Vec::new(); + tls_stream.read_to_end(&mut response).await.unwrap(); + String::from_utf8_lossy(&response).to_string() + }, 10) + .await + .unwrap(); + + let beta_body = extract_body(&beta_result); + assert!( + beta_body.contains(r#""backend":"beta"#), + "Expected beta backend, got: {}", + beta_body + ); + assert!( + beta_body.contains(r#""path":"/other"#), + "Expected /other path, got: {}", + beta_body + ); + + proxy.stop().await.unwrap(); +} + +/// Test that the protocol field on route config is accepted and processed. +#[tokio::test] +async fn test_protocol_field_in_route_config() { + let backend_port = next_port(); + let proxy_port = next_port(); + + let _backend = start_http_echo_backend(backend_port, "main").await; + + // Create a route with protocol: "http" - should only match HTTP traffic + let mut route = make_test_route(proxy_port, None, "127.0.0.1", backend_port); + route.route_match.protocol = Some("http".to_string()); + + let options = RustProxyOptions { + routes: vec![route], + ..Default::default() + }; + + let mut proxy = RustProxy::new(options).unwrap(); + proxy.start().await.unwrap(); + assert!(wait_for_port(proxy_port, 2000).await); + + // HTTP request should match the route and get proxied + let result = with_timeout(async { + let response = send_http_request(proxy_port, "example.com", "GET", "/test").await; + extract_body(&response).to_string() + }, 10) + .await + .unwrap(); + + assert!( + result.contains(r#""backend":"main"#), + "Expected main backend, got: {}", + result + ); + assert!( + result.contains(r#""path":"/test"#), + "Expected /test path, got: {}", + result + ); + + proxy.stop().await.unwrap(); +} + /// InsecureVerifier for test TLS client connections. #[derive(Debug)] struct InsecureVerifier; diff --git a/test/test.route-config.ts b/test/test.route-config.ts index cf98e6f..c35ac0f 100644 --- a/test/test.route-config.ts +++ b/test/test.route-config.ts @@ -562,4 +562,168 @@ 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, + domains: 'api.example.com', + protocol: 'http', + }, + action: { + type: 'forward', + targets: [{ host: 'backend', port: 8080 }], + tls: { + mode: 'terminate', + certificate: 'auto', + }, + }, + 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, + domains: 'db.example.com', + protocol: 'tcp', + }, + action: { + type: 'forward', + targets: [{ host: 'db-server', port: 5432 }], + tls: { + mode: 'passthrough', + }, + }, + name: 'TCP-only Route', + }; + + const validation = validateRouteConfig(tcpOnlyRoute); + expect(validation.valid).toBeTrue(); + + expect(tcpOnlyRoute.match.protocol).toEqual('tcp'); +}); + +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' } + ); + + // 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, + domains: 'example.com', + protocol: 'http', + }, + action: { + type: 'forward', + targets: [{ host: 'backend', port: 8080 }], + tls: { mode: 'terminate', certificate: 'auto' }, + }, + name: 'With Protocol', + priority: 10, + }; + + const routeWithoutProtocol: IRouteConfig = { + match: { + ports: 443, + domains: 'example.com', + }, + action: { + type: 'forward', + targets: [{ host: 'fallback', port: 8081 }], + tls: { mode: 'terminate', certificate: 'auto' }, + }, + name: 'Without Protocol', + priority: 5, + }; + + 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'); +}); + +tap.test('Routes: Protocol field preserved through route cloning', async () => { + const original: IRouteConfig = { + match: { + ports: 8443, + domains: 'clone-test.example.com', + protocol: 'http', + }, + action: { + type: 'forward', + targets: [{ host: 'backend', port: 3000 }], + tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }, + }, + name: 'Clone Test', + }; + + 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'); +}); + +tap.test('Routes: Protocol field preserved through route merging', async () => { + const base: IRouteConfig = { + match: { + ports: 443, + domains: 'merge-test.example.com', + protocol: 'http', + }, + action: { + type: 'forward', + targets: [{ host: 'backend', port: 3000 }], + tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }, + }, + 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(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4146f9d..19a02d1 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '25.6.0', + version: '25.7.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.' }