Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee7237357 | |||
| b5b4c608f0 | |||
| af132f40fc | |||
| 781634446a |
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-13 - 27.7.0 - feat(smart-proxy)
|
||||||
|
add typed Rust config serialization and regex header contract coverage
|
||||||
|
|
||||||
|
- serialize SmartProxy routes and top-level options into explicit Rust-safe types, including header regex literals, UDP field normalization, ACME, defaults, and proxy settings
|
||||||
|
- support JS-style regex header literals with flags in Rust header matching and add cross-contract tests for route preprocessing and config deserialization
|
||||||
|
- improve TypeScript safety for Rust bridge and metrics integration by replacing loose any-based payloads with dedicated Rust type definitions
|
||||||
|
|
||||||
|
## 2026-04-13 - 27.6.0 - feat(metrics)
|
||||||
|
track per-IP domain request metrics across HTTP and TCP passthrough traffic
|
||||||
|
|
||||||
|
- records domain request counts per frontend IP from HTTP Host headers and TCP SNI
|
||||||
|
- exposes per-IP domain maps and top IP-domain request pairs through the TypeScript metrics adapter
|
||||||
|
- bounds per-IP domain tracking and prunes stale entries to limit memory growth
|
||||||
|
- adds metrics system documentation covering architecture, collected data, and known gaps
|
||||||
|
|
||||||
## 2026-04-06 - 27.5.0 - feat(security)
|
## 2026-04-06 - 27.5.0 - feat(security)
|
||||||
add domain-scoped IP allow list support across HTTP and passthrough filtering
|
add domain-scoped IP allow list support across HTTP and passthrough filtering
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "27.5.0",
|
"version": "27.7.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
# SmartProxy Metrics System
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two-tier design separating the data plane from the observation plane:
|
||||||
|
|
||||||
|
**Hot path (per-chunk, lock-free):** All recording in the proxy data plane touches only `AtomicU64` counters. No `Mutex` is ever acquired on the forwarding path. `CountingBody` batches flushes every 64KB to reduce DashMap shard contention.
|
||||||
|
|
||||||
|
**Cold path (1Hz sampling):** A background tokio task drains pending atomics into `ThroughputTracker` circular buffers (Mutex-guarded), producing per-second throughput history. Same task prunes orphaned entries and cleans up rate limiter state.
|
||||||
|
|
||||||
|
**Read path (on-demand):** `snapshot()` reads all atomics and locks ThroughputTrackers to build a serializable `Metrics` struct. TypeScript polls this at 1s intervals via IPC.
|
||||||
|
|
||||||
|
```
|
||||||
|
Data Plane (lock-free) Background (1Hz) Read Path
|
||||||
|
───────────────────── ────────────────── ─────────
|
||||||
|
record_bytes() ──> AtomicU64 ──┐
|
||||||
|
record_http_request() ──> AtomicU64 ──┤
|
||||||
|
connection_opened/closed() ──> AtomicU64 ──┤ sample_all() snapshot()
|
||||||
|
backend_*() ──> DashMap<AtomicU64> ──┤────> drain atomics ──────> Metrics struct
|
||||||
|
protocol_*() ──> AtomicU64 ──┤ feed ThroughputTrackers ──> JSON
|
||||||
|
datagram_*() ──> AtomicU64 ──┘ prune orphans ──> IPC stdout
|
||||||
|
──> TS cache
|
||||||
|
──> IMetrics API
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Types
|
||||||
|
|
||||||
|
| Type | Crate | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `MetricsCollector` | `rustproxy-metrics` | Central store. All DashMaps, atomics, and throughput trackers |
|
||||||
|
| `ThroughputTracker` | `rustproxy-metrics` | Circular buffer of 1Hz samples. Default 3600 capacity (1 hour) |
|
||||||
|
| `ForwardMetricsCtx` | `rustproxy-passthrough` | Carries `Arc<MetricsCollector>` + route_id + source_ip through TCP forwarding |
|
||||||
|
| `CountingBody` | `rustproxy-http` | Wraps HTTP bodies, batches byte recording per 64KB, flushes on drop |
|
||||||
|
| `ProtocolGuard` | `rustproxy-http` | RAII guard for frontend/backend protocol active/total counters |
|
||||||
|
| `ConnectionGuard` | `rustproxy-passthrough` | RAII guard calling `connection_closed()` on drop |
|
||||||
|
| `RustMetricsAdapter` | TypeScript | Polls Rust via IPC, implements `IMetrics` interface over cached JSON |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Collected
|
||||||
|
|
||||||
|
### Global Counters
|
||||||
|
|
||||||
|
| Metric | Type | Updated by |
|
||||||
|
|---|---|---|
|
||||||
|
| Active connections | AtomicU64 | `connection_opened/closed` |
|
||||||
|
| Total connections (lifetime) | AtomicU64 | `connection_opened` |
|
||||||
|
| Total bytes in | AtomicU64 | `record_bytes` |
|
||||||
|
| Total bytes out | AtomicU64 | `record_bytes` |
|
||||||
|
| Total HTTP requests | AtomicU64 | `record_http_request` |
|
||||||
|
| Active UDP sessions | AtomicU64 | `udp_session_opened/closed` |
|
||||||
|
| Total UDP sessions | AtomicU64 | `udp_session_opened` |
|
||||||
|
| Total datagrams in | AtomicU64 | `record_datagram_in` |
|
||||||
|
| Total datagrams out | AtomicU64 | `record_datagram_out` |
|
||||||
|
|
||||||
|
### Per-Route Metrics (keyed by route ID string)
|
||||||
|
|
||||||
|
| Metric | Storage |
|
||||||
|
|---|---|
|
||||||
|
| Active connections | `DashMap<String, AtomicU64>` |
|
||||||
|
| Total connections | `DashMap<String, AtomicU64>` |
|
||||||
|
| Bytes in / out | `DashMap<String, AtomicU64>` |
|
||||||
|
| Pending throughput (in, out) | `DashMap<String, (AtomicU64, AtomicU64)>` |
|
||||||
|
| Throughput history | `DashMap<String, Mutex<ThroughputTracker>>` |
|
||||||
|
|
||||||
|
Entries are pruned via `retain_routes()` when routes are removed.
|
||||||
|
|
||||||
|
### Per-IP Metrics (keyed by IP string)
|
||||||
|
|
||||||
|
| Metric | Storage |
|
||||||
|
|---|---|
|
||||||
|
| Active connections | `DashMap<String, AtomicU64>` |
|
||||||
|
| Total connections | `DashMap<String, AtomicU64>` |
|
||||||
|
| Bytes in / out | `DashMap<String, AtomicU64>` |
|
||||||
|
| Pending throughput (in, out) | `DashMap<String, (AtomicU64, AtomicU64)>` |
|
||||||
|
| Throughput history | `DashMap<String, Mutex<ThroughputTracker>>` |
|
||||||
|
| Domain requests | `DashMap<String, DashMap<String, AtomicU64>>` (IP → domain → count) |
|
||||||
|
|
||||||
|
All seven maps for an IP are evicted when its active connection count drops to 0. Safety-net pruning in `sample_all()` catches entries orphaned by races. Snapshots cap at 100 IPs, sorted by active connections descending.
|
||||||
|
|
||||||
|
**Domain request tracking:** Records which domains each frontend IP has requested. Populated from HTTP Host headers (for HTTP/1.1, HTTP/2, HTTP/3 requests) and from SNI (for TLS passthrough connections). Capped at 256 domains per IP (`MAX_DOMAINS_PER_IP`) to prevent subdomain-spray abuse. Inner DashMap uses 2 shards to minimise base memory per IP (~200 bytes). Common case (IP + domain both known) is two DashMap reads + one atomic increment with zero allocation.
|
||||||
|
|
||||||
|
### Per-Backend Metrics (keyed by "host:port")
|
||||||
|
|
||||||
|
| Metric | Storage |
|
||||||
|
|---|---|
|
||||||
|
| Active connections | `DashMap<String, AtomicU64>` |
|
||||||
|
| Total connections | `DashMap<String, AtomicU64>` |
|
||||||
|
| Detected protocol (h1/h2/h3) | `DashMap<String, String>` |
|
||||||
|
| Connect errors | `DashMap<String, AtomicU64>` |
|
||||||
|
| Handshake errors | `DashMap<String, AtomicU64>` |
|
||||||
|
| Request errors | `DashMap<String, AtomicU64>` |
|
||||||
|
| Total connect time (microseconds) | `DashMap<String, AtomicU64>` |
|
||||||
|
| Connect count | `DashMap<String, AtomicU64>` |
|
||||||
|
| Pool hits | `DashMap<String, AtomicU64>` |
|
||||||
|
| Pool misses | `DashMap<String, AtomicU64>` |
|
||||||
|
| H2 failures (fallback to H1) | `DashMap<String, AtomicU64>` |
|
||||||
|
|
||||||
|
All per-backend maps are evicted when active count reaches 0. Pruned via `retain_backends()`.
|
||||||
|
|
||||||
|
### Frontend Protocol Distribution
|
||||||
|
|
||||||
|
Tracked via `ProtocolGuard` RAII guards and `FrontendProtocolTracker`. Five protocol categories, each with active + total counters (AtomicU64):
|
||||||
|
|
||||||
|
| Protocol | Where detected |
|
||||||
|
|---|---|
|
||||||
|
| h1 | `FrontendProtocolTracker` on first HTTP/1.x request |
|
||||||
|
| h2 | `FrontendProtocolTracker` on first HTTP/2 request |
|
||||||
|
| h3 | `ProtocolGuard::frontend("h3")` in H3ProxyService |
|
||||||
|
| ws | `ProtocolGuard::frontend("ws")` on WebSocket upgrade |
|
||||||
|
| other | Fallback (TCP passthrough without HTTP) |
|
||||||
|
|
||||||
|
Uses `fetch_update` for saturating decrements to prevent underflow races.
|
||||||
|
|
||||||
|
### Backend Protocol Distribution
|
||||||
|
|
||||||
|
Same five categories (h1/h2/h3/ws/other), tracked via `ProtocolGuard::backend()` at connection establishment. Backend h2 failures (fallback to h1) are separately counted.
|
||||||
|
|
||||||
|
### Throughput History
|
||||||
|
|
||||||
|
`ThroughputTracker` is a circular buffer storing `ThroughputSample { timestamp_ms, bytes_in, bytes_out }` at 1Hz.
|
||||||
|
|
||||||
|
- Global tracker: 1 instance, default 3600 capacity
|
||||||
|
- Per-route trackers: 1 per active route
|
||||||
|
- Per-IP trackers: 1 per connected IP (evicted with the IP)
|
||||||
|
- HTTP request tracker: reuses ThroughputTracker with bytes_in = request count, bytes_out = 0
|
||||||
|
|
||||||
|
Query methods:
|
||||||
|
- `instant()` — last 1 second average
|
||||||
|
- `recent()` — last 10 seconds average
|
||||||
|
- `throughput(N)` — last N seconds average
|
||||||
|
- `history(N)` — last N raw samples in chronological order
|
||||||
|
|
||||||
|
Snapshots return 60 samples of global throughput history.
|
||||||
|
|
||||||
|
### Protocol Detection Cache
|
||||||
|
|
||||||
|
Not part of MetricsCollector. Maintained by `HttpProxyService`'s protocol detection system. Injected into the metrics snapshot at read time by `get_metrics()`.
|
||||||
|
|
||||||
|
Each entry records: host, port, domain, detected protocol (h1/h2/h3), H3 port, age, last accessed, last probed, suppression flags, cooldown timers, consecutive failure counts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instrumentation Points
|
||||||
|
|
||||||
|
### TCP Passthrough (`rustproxy-passthrough`)
|
||||||
|
|
||||||
|
**Connection lifecycle** — `tcp_listener.rs`:
|
||||||
|
- Accept: `conn_tracker.connection_opened(&ip)` (rate limiter) + `ConnectionTrackerGuard` RAII
|
||||||
|
- Route match: `metrics.connection_opened(route_id, source_ip)` + `ConnectionGuard` RAII
|
||||||
|
- Close: Both guards call their respective `_closed()` methods on drop
|
||||||
|
|
||||||
|
**Byte recording** — `forwarder.rs` (`forward_bidirectional_with_timeouts`):
|
||||||
|
- Initial peeked data recorded immediately
|
||||||
|
- Per-chunk in both directions: `record_bytes(n, 0, ...)` / `record_bytes(0, n, ...)`
|
||||||
|
- Same pattern in `forward_bidirectional_split_with_timeouts` (tcp_listener.rs) for TLS-terminated paths
|
||||||
|
|
||||||
|
### HTTP Proxy (`rustproxy-http`)
|
||||||
|
|
||||||
|
**Request counting** — `proxy_service.rs`:
|
||||||
|
- `record_http_request()` called once per request after route matching succeeds
|
||||||
|
|
||||||
|
**Body byte counting** — `counting_body.rs` wrapping:
|
||||||
|
- Request bodies: `CountingBody::new(body, ..., Direction::In)` — counts client-to-upstream bytes
|
||||||
|
- Response bodies: `CountingBody::new(body, ..., Direction::Out)` — counts upstream-to-client bytes
|
||||||
|
- Batched flush every 64KB (`BYTE_FLUSH_THRESHOLD = 65_536`), remainder flushed on drop
|
||||||
|
- Also updates `connection_activity` atomic (idle watchdog) and `active_requests` counter (streaming detection)
|
||||||
|
|
||||||
|
**Backend metrics** — `proxy_service.rs`:
|
||||||
|
- `backend_connection_opened(key, connect_time)` — after TCP/TLS connect succeeds
|
||||||
|
- `backend_connection_closed(key)` — on teardown
|
||||||
|
- `backend_connect_error(key)` — TCP/TLS connect failure or timeout
|
||||||
|
- `backend_handshake_error(key)` — H1/H2 protocol handshake failure
|
||||||
|
- `backend_request_error(key)` — send_request failure
|
||||||
|
- `backend_h2_failure(key)` — H2 attempted, fell back to H1
|
||||||
|
- `backend_pool_hit(key)` / `backend_pool_miss(key)` — connection pool reuse
|
||||||
|
- `set_backend_protocol(key, proto)` — records detected protocol
|
||||||
|
|
||||||
|
**WebSocket** — `proxy_service.rs`:
|
||||||
|
- Does NOT use CountingBody; records bytes directly per-chunk in both directions of the bidirectional copy loop
|
||||||
|
|
||||||
|
### QUIC (`rustproxy-passthrough`)
|
||||||
|
|
||||||
|
**Connection level** — `quic_handler.rs`:
|
||||||
|
- `connection_opened` / `connection_closed` via `QuicConnGuard` RAII
|
||||||
|
- `conn_tracker.connection_opened/closed` for rate limiting
|
||||||
|
|
||||||
|
**Stream level**:
|
||||||
|
- For QUIC-to-TCP stream forwarding: `record_bytes(bytes_in, bytes_out, ...)` called once per stream at completion (post-hoc, not per-chunk)
|
||||||
|
- For HTTP/3: delegates to `HttpProxyService.handle_request()`, so all HTTP proxy metrics apply
|
||||||
|
|
||||||
|
**H3 specifics** — `h3_service.rs`:
|
||||||
|
- `ProtocolGuard::frontend("h3")` tracks the H3 connection
|
||||||
|
- H3 request bodies: `record_bytes(data.len(), 0, ...)` called directly (not CountingBody) since H3 uses `stream.send_data()`
|
||||||
|
- H3 response bodies: wrapped in CountingBody like HTTP/1 and HTTP/2
|
||||||
|
|
||||||
|
### UDP (`rustproxy-passthrough`)
|
||||||
|
|
||||||
|
**Session lifecycle** — `udp_listener.rs` / `udp_session.rs`:
|
||||||
|
- `udp_session_opened()` + `connection_opened(route_id, source_ip)` on new session
|
||||||
|
- `udp_session_closed()` + `connection_closed(route_id, source_ip)` on idle reap or port drain
|
||||||
|
|
||||||
|
**Datagram counting** — `udp_listener.rs`:
|
||||||
|
- Inbound: `record_bytes(len, 0, ...)` + `record_datagram_in()`
|
||||||
|
- Outbound (backend reply): `record_bytes(0, len, ...)` + `record_datagram_out()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Loop
|
||||||
|
|
||||||
|
`lib.rs` spawns a tokio task at configurable interval (default 1000ms):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel => break,
|
||||||
|
_ = interval.tick() => {
|
||||||
|
metrics.sample_all();
|
||||||
|
conn_tracker.cleanup_stale_timestamps();
|
||||||
|
http_proxy.cleanup_all_rate_limiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sample_all()` performs in one pass:
|
||||||
|
1. Drains `global_pending_tp_in/out` into global ThroughputTracker, samples
|
||||||
|
2. Drains per-route pending counters into per-route trackers, samples each
|
||||||
|
3. Samples idle route trackers (no new data) to advance their window
|
||||||
|
4. Drains per-IP pending counters into per-IP trackers, samples each
|
||||||
|
5. Drains `pending_http_requests` into HTTP request throughput tracker
|
||||||
|
6. Prunes orphaned per-IP entries (bytes/throughput maps with no matching ip_connections key)
|
||||||
|
7. Prunes orphaned per-backend entries (error/stats maps with no matching active/total key)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow: Rust to TypeScript
|
||||||
|
|
||||||
|
```
|
||||||
|
MetricsCollector.snapshot()
|
||||||
|
├── reads all AtomicU64 counters
|
||||||
|
├── iterates DashMaps (routes, IPs, backends)
|
||||||
|
├── locks ThroughputTrackers for instant/recent rates + history
|
||||||
|
└── produces Metrics struct
|
||||||
|
|
||||||
|
RustProxy::get_metrics()
|
||||||
|
├── calls snapshot()
|
||||||
|
├── enriches with detectedProtocols from HTTP proxy protocol cache
|
||||||
|
└── returns Metrics
|
||||||
|
|
||||||
|
management.rs "getMetrics" IPC command
|
||||||
|
├── calls get_metrics()
|
||||||
|
├── serde_json::to_value (camelCase)
|
||||||
|
└── writes JSON to stdout
|
||||||
|
|
||||||
|
RustProxyBridge (TypeScript)
|
||||||
|
├── reads JSON from Rust process stdout
|
||||||
|
└── returns parsed object
|
||||||
|
|
||||||
|
RustMetricsAdapter
|
||||||
|
├── setInterval polls bridge.getMetrics() every 1s
|
||||||
|
├── stores raw JSON in this.cache
|
||||||
|
└── IMetrics methods read synchronously from cache
|
||||||
|
|
||||||
|
SmartProxy.getMetrics()
|
||||||
|
└── returns the RustMetricsAdapter instance
|
||||||
|
```
|
||||||
|
|
||||||
|
### IPC JSON Shape (Metrics)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activeConnections": 42,
|
||||||
|
"totalConnections": 1000,
|
||||||
|
"bytesIn": 123456789,
|
||||||
|
"bytesOut": 987654321,
|
||||||
|
"throughputInBytesPerSec": 50000,
|
||||||
|
"throughputOutBytesPerSec": 80000,
|
||||||
|
"throughputRecentInBytesPerSec": 45000,
|
||||||
|
"throughputRecentOutBytesPerSec": 75000,
|
||||||
|
"routes": {
|
||||||
|
"<route-id>": {
|
||||||
|
"activeConnections": 5,
|
||||||
|
"totalConnections": 100,
|
||||||
|
"bytesIn": 0, "bytesOut": 0,
|
||||||
|
"throughputInBytesPerSec": 0, "throughputOutBytesPerSec": 0,
|
||||||
|
"throughputRecentInBytesPerSec": 0, "throughputRecentOutBytesPerSec": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ips": {
|
||||||
|
"<ip>": {
|
||||||
|
"activeConnections": 2, "totalConnections": 10,
|
||||||
|
"bytesIn": 0, "bytesOut": 0,
|
||||||
|
"throughputInBytesPerSec": 0, "throughputOutBytesPerSec": 0,
|
||||||
|
"domainRequests": {
|
||||||
|
"example.com": 4821,
|
||||||
|
"api.example.com": 312
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backends": {
|
||||||
|
"<host:port>": {
|
||||||
|
"activeConnections": 3, "totalConnections": 50,
|
||||||
|
"protocol": "h2",
|
||||||
|
"connectErrors": 0, "handshakeErrors": 0, "requestErrors": 0,
|
||||||
|
"totalConnectTimeUs": 150000, "connectCount": 50,
|
||||||
|
"poolHits": 40, "poolMisses": 10, "h2Failures": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"throughputHistory": [
|
||||||
|
{ "timestampMs": 1713000000000, "bytesIn": 50000, "bytesOut": 80000 }
|
||||||
|
],
|
||||||
|
"totalHttpRequests": 5000,
|
||||||
|
"httpRequestsPerSec": 100,
|
||||||
|
"httpRequestsPerSecRecent": 95,
|
||||||
|
"activeUdpSessions": 0, "totalUdpSessions": 5,
|
||||||
|
"totalDatagramsIn": 1000, "totalDatagramsOut": 1000,
|
||||||
|
"frontendProtocols": {
|
||||||
|
"h1Active": 10, "h1Total": 500,
|
||||||
|
"h2Active": 5, "h2Total": 200,
|
||||||
|
"h3Active": 1, "h3Total": 50,
|
||||||
|
"wsActive": 2, "wsTotal": 30,
|
||||||
|
"otherActive": 0, "otherTotal": 0
|
||||||
|
},
|
||||||
|
"backendProtocols": { "...same shape..." },
|
||||||
|
"detectedProtocols": [
|
||||||
|
{
|
||||||
|
"host": "backend", "port": 443, "domain": "example.com",
|
||||||
|
"protocol": "h2", "h3Port": 443,
|
||||||
|
"ageSecs": 120, "lastAccessedSecs": 5, "lastProbedSecs": 120,
|
||||||
|
"h2Suppressed": false, "h3Suppressed": false,
|
||||||
|
"h2CooldownRemainingSecs": null, "h3CooldownRemainingSecs": null,
|
||||||
|
"h2ConsecutiveFailures": null, "h3ConsecutiveFailures": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IPC JSON Shape (Statistics)
|
||||||
|
|
||||||
|
Lightweight administrative summary, fetched on-demand (not polled):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activeConnections": 42,
|
||||||
|
"totalConnections": 1000,
|
||||||
|
"routesCount": 5,
|
||||||
|
"listeningPorts": [80, 443, 8443],
|
||||||
|
"uptimeSeconds": 86400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Consumer API
|
||||||
|
|
||||||
|
`SmartProxy.getMetrics()` returns an `IMetrics` object. All members are synchronous methods reading from the polled cache.
|
||||||
|
|
||||||
|
### connections
|
||||||
|
|
||||||
|
| Method | Return | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `active()` | `number` | `cache.activeConnections` |
|
||||||
|
| `total()` | `number` | `cache.totalConnections` |
|
||||||
|
| `byRoute()` | `Map<string, number>` | `cache.routes[name].activeConnections` |
|
||||||
|
| `byIP()` | `Map<string, number>` | `cache.ips[ip].activeConnections` |
|
||||||
|
| `topIPs(limit?)` | `Array<{ip, count}>` | `cache.ips` sorted by active desc, default 10 |
|
||||||
|
| `domainRequestsByIP()` | `Map<string, Map<string, number>>` | `cache.ips[ip].domainRequests` |
|
||||||
|
| `topDomainRequests(limit?)` | `Array<{ip, domain, count}>` | Flattened from all IPs, sorted by count desc, default 20 |
|
||||||
|
| `frontendProtocols()` | `IProtocolDistribution` | `cache.frontendProtocols.*` |
|
||||||
|
| `backendProtocols()` | `IProtocolDistribution` | `cache.backendProtocols.*` |
|
||||||
|
|
||||||
|
### throughput
|
||||||
|
|
||||||
|
| Method | Return | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `instant()` | `{in, out}` | `cache.throughputInBytesPerSec/Out` |
|
||||||
|
| `recent()` | `{in, out}` | `cache.throughputRecentInBytesPerSec/Out` |
|
||||||
|
| `average()` | `{in, out}` | Falls back to `instant()` (not wired to windowed average) |
|
||||||
|
| `custom(seconds)` | `{in, out}` | Falls back to `instant()` (not wired) |
|
||||||
|
| `history(seconds)` | `IThroughputHistoryPoint[]` | `cache.throughputHistory` sliced to last N entries |
|
||||||
|
| `byRoute(windowSeconds?)` | `Map<string, {in, out}>` | `cache.routes[name].throughputIn/OutBytesPerSec` |
|
||||||
|
| `byIP(windowSeconds?)` | `Map<string, {in, out}>` | `cache.ips[ip].throughputIn/OutBytesPerSec` |
|
||||||
|
|
||||||
|
### requests
|
||||||
|
|
||||||
|
| Method | Return | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `perSecond()` | `number` | `cache.httpRequestsPerSec` |
|
||||||
|
| `perMinute()` | `number` | `cache.httpRequestsPerSecRecent * 60` |
|
||||||
|
| `total()` | `number` | `cache.totalHttpRequests` (fallback: totalConnections) |
|
||||||
|
|
||||||
|
### totals
|
||||||
|
|
||||||
|
| Method | Return | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `bytesIn()` | `number` | `cache.bytesIn` |
|
||||||
|
| `bytesOut()` | `number` | `cache.bytesOut` |
|
||||||
|
| `connections()` | `number` | `cache.totalConnections` |
|
||||||
|
|
||||||
|
### backends
|
||||||
|
|
||||||
|
| Method | Return | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `byBackend()` | `Map<string, IBackendMetrics>` | `cache.backends[key].*` with computed `avgConnectTimeMs` and `poolHitRate` |
|
||||||
|
| `protocols()` | `Map<string, string>` | `cache.backends[key].protocol` |
|
||||||
|
| `topByErrors(limit?)` | `IBackendMetrics[]` | Sorted by total errors desc |
|
||||||
|
| `detectedProtocols()` | `IProtocolCacheEntry[]` | `cache.detectedProtocols` passthrough |
|
||||||
|
|
||||||
|
`IBackendMetrics`: `{ protocol, activeConnections, totalConnections, connectErrors, handshakeErrors, requestErrors, avgConnectTimeMs, poolHitRate, h2Failures }`
|
||||||
|
|
||||||
|
### udp
|
||||||
|
|
||||||
|
| Method | Return | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `activeSessions()` | `number` | `cache.activeUdpSessions` |
|
||||||
|
| `totalSessions()` | `number` | `cache.totalUdpSessions` |
|
||||||
|
| `datagramsIn()` | `number` | `cache.totalDatagramsIn` |
|
||||||
|
| `datagramsOut()` | `number` | `cache.totalDatagramsOut` |
|
||||||
|
|
||||||
|
### percentiles (stub)
|
||||||
|
|
||||||
|
`connectionDuration()` and `bytesTransferred()` always return zeros. Not implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IMetricsConfig {
|
||||||
|
enabled: boolean; // default true
|
||||||
|
sampleIntervalMs: number; // default 1000 (1Hz sampling + TS polling)
|
||||||
|
retentionSeconds: number; // default 3600 (ThroughputTracker capacity)
|
||||||
|
enableDetailedTracking: boolean;
|
||||||
|
enablePercentiles: boolean;
|
||||||
|
cacheResultsMs: number;
|
||||||
|
prometheusEnabled: boolean; // not wired
|
||||||
|
prometheusPath: string; // not wired
|
||||||
|
prometheusPrefix: string; // not wired
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust-side config (`MetricsConfig` in `rustproxy-config`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MetricsConfig {
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub sample_interval_ms: Option<u64>, // default 1000
|
||||||
|
pub retention_seconds: Option<u64>, // default 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
**Lock-free hot path.** `record_bytes()` is the most frequently called method (per-chunk in TCP, per-64KB in HTTP). It only touches `AtomicU64` with `Relaxed` ordering and short-circuits zero-byte directions to skip DashMap lookups entirely.
|
||||||
|
|
||||||
|
**CountingBody batching.** HTTP body frames are typically 16KB. Flushing to MetricsCollector every 64KB reduces DashMap shard contention by ~4x compared to per-frame recording.
|
||||||
|
|
||||||
|
**RAII guards everywhere.** `ConnectionGuard`, `ConnectionTrackerGuard`, `QuicConnGuard`, `ProtocolGuard`, `FrontendProtocolTracker` all use Drop to guarantee counter cleanup on all exit paths including panics.
|
||||||
|
|
||||||
|
**Saturating decrements.** Protocol counters use `fetch_update` loops instead of `fetch_sub` to prevent underflow to `u64::MAX` from concurrent close races.
|
||||||
|
|
||||||
|
**Bounded memory.** Per-IP entries evicted on last connection close. Per-backend entries evicted on last connection close. Snapshot caps IPs and backends at 100 each. `sample_all()` prunes orphaned entries every second.
|
||||||
|
|
||||||
|
**Two-phase throughput.** Pending bytes accumulate in lock-free atomics. The 1Hz cold path drains them into Mutex-guarded ThroughputTrackers. This means the hot path never contends on a Mutex, while the cold path does minimal work (one drain + one sample per tracker).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Gaps
|
||||||
|
|
||||||
|
| Gap | Status |
|
||||||
|
|---|---|
|
||||||
|
| `throughput.average()` / `throughput.custom(seconds)` | Fall back to `instant()`. Not wired to Rust windowed queries. |
|
||||||
|
| `percentiles.connectionDuration()` / `percentiles.bytesTransferred()` | Stub returning zeros. No histogram in Rust. |
|
||||||
|
| Prometheus export | Config fields exist but not wired to any exporter. |
|
||||||
|
| `LogDeduplicator` | Implemented in `rustproxy-metrics` but not connected to any call site. |
|
||||||
|
| Rate limit hit counters | Rate-limited requests return 429 but no counter is recorded in MetricsCollector. |
|
||||||
|
| QUIC stream byte counting | Post-hoc (per-stream totals after close), not per-chunk like TCP. |
|
||||||
|
| Throughput history in snapshot | Capped at 60 samples. TS `history(seconds)` cannot return more than 60 points regardless of `retentionSeconds`. |
|
||||||
|
| Per-route total connections / bytes | Available in Rust JSON but `IMetrics.connections.byRoute()` only exposes active connections. |
|
||||||
|
| Per-IP total connections / bytes | Available in Rust JSON but `IMetrics.connections.byIP()` only exposes active connections. |
|
||||||
|
| IPC response typing | `RustProxyBridge` declares `result: any` for both metrics commands. No type-safe response. |
|
||||||
@@ -129,7 +129,6 @@ pub struct RustProxyOptions {
|
|||||||
pub defaults: Option<DefaultConfig>,
|
pub defaults: Option<DefaultConfig>,
|
||||||
|
|
||||||
// ─── Timeout Settings ────────────────────────────────────────────
|
// ─── Timeout Settings ────────────────────────────────────────────
|
||||||
|
|
||||||
/// Timeout for establishing connection to backend (ms), default: 30000
|
/// Timeout for establishing connection to backend (ms), default: 30000
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub connection_timeout: Option<u64>,
|
pub connection_timeout: Option<u64>,
|
||||||
@@ -159,7 +158,6 @@ pub struct RustProxyOptions {
|
|||||||
pub graceful_shutdown_timeout: Option<u64>,
|
pub graceful_shutdown_timeout: Option<u64>,
|
||||||
|
|
||||||
// ─── Socket Optimization ─────────────────────────────────────────
|
// ─── Socket Optimization ─────────────────────────────────────────
|
||||||
|
|
||||||
/// Disable Nagle's algorithm (default: true)
|
/// Disable Nagle's algorithm (default: true)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub no_delay: Option<bool>,
|
pub no_delay: Option<bool>,
|
||||||
@@ -177,7 +175,6 @@ pub struct RustProxyOptions {
|
|||||||
pub max_pending_data_size: Option<u64>,
|
pub max_pending_data_size: Option<u64>,
|
||||||
|
|
||||||
// ─── Enhanced Features ───────────────────────────────────────────
|
// ─── Enhanced Features ───────────────────────────────────────────
|
||||||
|
|
||||||
/// Disable inactivity checking entirely
|
/// Disable inactivity checking entirely
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub disable_inactivity_check: Option<bool>,
|
pub disable_inactivity_check: Option<bool>,
|
||||||
@@ -199,7 +196,6 @@ pub struct RustProxyOptions {
|
|||||||
pub enable_randomized_timeouts: Option<bool>,
|
pub enable_randomized_timeouts: Option<bool>,
|
||||||
|
|
||||||
// ─── Rate Limiting ───────────────────────────────────────────────
|
// ─── Rate Limiting ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// Maximum simultaneous connections from a single IP
|
/// Maximum simultaneous connections from a single IP
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub max_connections_per_ip: Option<u64>,
|
pub max_connections_per_ip: Option<u64>,
|
||||||
@@ -213,7 +209,6 @@ pub struct RustProxyOptions {
|
|||||||
pub max_connections: Option<u64>,
|
pub max_connections: Option<u64>,
|
||||||
|
|
||||||
// ─── Keep-Alive Settings ─────────────────────────────────────────
|
// ─── Keep-Alive Settings ─────────────────────────────────────────
|
||||||
|
|
||||||
/// How to treat keep-alive connections
|
/// How to treat keep-alive connections
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub keep_alive_treatment: Option<KeepAliveTreatment>,
|
pub keep_alive_treatment: Option<KeepAliveTreatment>,
|
||||||
@@ -227,7 +222,6 @@ pub struct RustProxyOptions {
|
|||||||
pub extended_keep_alive_lifetime: Option<u64>,
|
pub extended_keep_alive_lifetime: Option<u64>,
|
||||||
|
|
||||||
// ─── HttpProxy Integration ───────────────────────────────────────
|
// ─── HttpProxy Integration ───────────────────────────────────────
|
||||||
|
|
||||||
/// Array of ports to forward to HttpProxy
|
/// Array of ports to forward to HttpProxy
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub use_http_proxy: Option<Vec<u16>>,
|
pub use_http_proxy: Option<Vec<u16>>,
|
||||||
@@ -237,13 +231,11 @@ pub struct RustProxyOptions {
|
|||||||
pub http_proxy_port: Option<u16>,
|
pub http_proxy_port: Option<u16>,
|
||||||
|
|
||||||
// ─── Metrics ─────────────────────────────────────────────────────
|
// ─── Metrics ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Metrics configuration
|
/// Metrics configuration
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub metrics: Option<MetricsConfig>,
|
pub metrics: Option<MetricsConfig>,
|
||||||
|
|
||||||
// ─── ACME ────────────────────────────────────────────────────────
|
// ─── ACME ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Global ACME configuration
|
/// Global ACME configuration
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub acme: Option<AcmeOptions>,
|
pub acme: Option<AcmeOptions>,
|
||||||
@@ -318,7 +310,8 @@ impl RustProxyOptions {
|
|||||||
|
|
||||||
/// Get all unique ports that routes listen on.
|
/// Get all unique ports that routes listen on.
|
||||||
pub fn all_listening_ports(&self) -> Vec<u16> {
|
pub fn all_listening_ports(&self) -> Vec<u16> {
|
||||||
let mut ports: Vec<u16> = self.routes
|
let mut ports: Vec<u16> = self
|
||||||
|
.routes
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|r| r.listening_ports())
|
.flat_map(|r| r.listening_ports())
|
||||||
.collect();
|
.collect();
|
||||||
@@ -340,7 +333,12 @@ mod tests {
|
|||||||
route_match: RouteMatch {
|
route_match: RouteMatch {
|
||||||
ports: PortRange::Single(listen_port),
|
ports: PortRange::Single(listen_port),
|
||||||
domains: Some(DomainSpec::Single(domain.to_string())),
|
domains: Some(DomainSpec::Single(domain.to_string())),
|
||||||
path: None, client_ip: None, transport: None, tls_version: None, headers: None, protocol: None,
|
path: None,
|
||||||
|
client_ip: None,
|
||||||
|
transport: None,
|
||||||
|
tls_version: None,
|
||||||
|
headers: None,
|
||||||
|
protocol: None,
|
||||||
},
|
},
|
||||||
action: RouteAction {
|
action: RouteAction {
|
||||||
action_type: RouteActionType::Forward,
|
action_type: RouteActionType::Forward,
|
||||||
@@ -348,14 +346,30 @@ mod tests {
|
|||||||
target_match: None,
|
target_match: None,
|
||||||
host: HostSpec::Single(host.to_string()),
|
host: HostSpec::Single(host.to_string()),
|
||||||
port: PortSpec::Fixed(port),
|
port: PortSpec::Fixed(port),
|
||||||
tls: None, websocket: None, load_balancing: None, send_proxy_protocol: None,
|
tls: None,
|
||||||
headers: None, advanced: None, backend_transport: None, priority: 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,
|
tls: None,
|
||||||
options: None, send_proxy_protocol: None, udp: None,
|
websocket: None,
|
||||||
|
load_balancing: None,
|
||||||
|
advanced: None,
|
||||||
|
options: None,
|
||||||
|
send_proxy_protocol: None,
|
||||||
|
udp: None,
|
||||||
},
|
},
|
||||||
headers: None, security: None, name: None, description: None,
|
headers: None,
|
||||||
priority: None, tags: None, enabled: None,
|
security: None,
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
priority: None,
|
||||||
|
tags: None,
|
||||||
|
enabled: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,8 +377,12 @@ mod tests {
|
|||||||
let mut route = make_route(domain, host, port, 443);
|
let mut route = make_route(domain, host, port, 443);
|
||||||
route.action.tls = Some(RouteTls {
|
route.action.tls = Some(RouteTls {
|
||||||
mode: TlsMode::Passthrough,
|
mode: TlsMode::Passthrough,
|
||||||
certificate: None, acme: None, versions: None, ciphers: None,
|
certificate: None,
|
||||||
honor_cipher_order: None, session_timeout: None,
|
acme: None,
|
||||||
|
versions: None,
|
||||||
|
ciphers: None,
|
||||||
|
honor_cipher_order: None,
|
||||||
|
session_timeout: None,
|
||||||
});
|
});
|
||||||
route
|
route
|
||||||
}
|
}
|
||||||
@@ -410,6 +428,209 @@ mod tests {
|
|||||||
assert_eq!(parsed.connection_timeout, Some(5000));
|
assert_eq!(parsed.connection_timeout, Some(5000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_ts_contract_route_shapes() {
|
||||||
|
let value = serde_json::json!({
|
||||||
|
"routes": [{
|
||||||
|
"name": "contract-route",
|
||||||
|
"match": {
|
||||||
|
"ports": [443, { "from": 8443, "to": 8444 }],
|
||||||
|
"domains": ["api.example.com", "*.example.com"],
|
||||||
|
"transport": "udp",
|
||||||
|
"protocol": "http3",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "/^application\\/json$/i"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "forward",
|
||||||
|
"targets": [{
|
||||||
|
"match": {
|
||||||
|
"ports": [443],
|
||||||
|
"path": "/api/*",
|
||||||
|
"method": ["GET"],
|
||||||
|
"headers": {
|
||||||
|
"x-env": "/^(prod|stage)$/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": ["backend-a", "backend-b"],
|
||||||
|
"port": "preserve",
|
||||||
|
"sendProxyProtocol": true,
|
||||||
|
"backendTransport": "tcp"
|
||||||
|
}],
|
||||||
|
"tls": {
|
||||||
|
"mode": "terminate",
|
||||||
|
"certificate": "auto"
|
||||||
|
},
|
||||||
|
"sendProxyProtocol": true,
|
||||||
|
"udp": {
|
||||||
|
"maxSessionsPerIp": 321,
|
||||||
|
"quic": {
|
||||||
|
"enableHttp3": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"ipAllowList": [{
|
||||||
|
"ip": "10.0.0.0/8",
|
||||||
|
"domains": ["api.example.com"]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"preserveSourceIp": true,
|
||||||
|
"proxyIps": ["10.0.0.1"],
|
||||||
|
"acceptProxyProtocol": true,
|
||||||
|
"sendProxyProtocol": true,
|
||||||
|
"noDelay": true,
|
||||||
|
"keepAlive": true,
|
||||||
|
"keepAliveInitialDelay": 1500,
|
||||||
|
"maxPendingDataSize": 4096,
|
||||||
|
"disableInactivityCheck": true,
|
||||||
|
"enableKeepAliveProbes": true,
|
||||||
|
"enableDetailedLogging": true,
|
||||||
|
"enableTlsDebugLogging": true,
|
||||||
|
"enableRandomizedTimeouts": true,
|
||||||
|
"connectionTimeout": 5000,
|
||||||
|
"initialDataTimeout": 7000,
|
||||||
|
"socketTimeout": 9000,
|
||||||
|
"inactivityCheckInterval": 1100,
|
||||||
|
"maxConnectionLifetime": 13000,
|
||||||
|
"inactivityTimeout": 15000,
|
||||||
|
"gracefulShutdownTimeout": 17000,
|
||||||
|
"maxConnectionsPerIp": 20,
|
||||||
|
"connectionRateLimitPerMinute": 30,
|
||||||
|
"keepAliveTreatment": "extended",
|
||||||
|
"keepAliveInactivityMultiplier": 2.0,
|
||||||
|
"extendedKeepAliveLifetime": 19000,
|
||||||
|
"metrics": {
|
||||||
|
"enabled": true,
|
||||||
|
"sampleIntervalMs": 250,
|
||||||
|
"retentionSeconds": 60
|
||||||
|
},
|
||||||
|
"acme": {
|
||||||
|
"enabled": true,
|
||||||
|
"email": "ops@example.com",
|
||||||
|
"environment": "staging",
|
||||||
|
"useProduction": false,
|
||||||
|
"skipConfiguredCerts": true,
|
||||||
|
"renewThresholdDays": 14,
|
||||||
|
"renewCheckIntervalHours": 12,
|
||||||
|
"autoRenew": true,
|
||||||
|
"port": 80
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let options: RustProxyOptions = serde_json::from_value(value).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(options.routes.len(), 1);
|
||||||
|
assert_eq!(options.preserve_source_ip, Some(true));
|
||||||
|
assert_eq!(options.proxy_ips, Some(vec!["10.0.0.1".to_string()]));
|
||||||
|
assert_eq!(options.accept_proxy_protocol, Some(true));
|
||||||
|
assert_eq!(options.send_proxy_protocol, Some(true));
|
||||||
|
assert_eq!(options.no_delay, Some(true));
|
||||||
|
assert_eq!(options.keep_alive, Some(true));
|
||||||
|
assert_eq!(options.keep_alive_initial_delay, Some(1500));
|
||||||
|
assert_eq!(options.max_pending_data_size, Some(4096));
|
||||||
|
assert_eq!(options.disable_inactivity_check, Some(true));
|
||||||
|
assert_eq!(options.enable_keep_alive_probes, Some(true));
|
||||||
|
assert_eq!(options.enable_detailed_logging, Some(true));
|
||||||
|
assert_eq!(options.enable_tls_debug_logging, Some(true));
|
||||||
|
assert_eq!(options.enable_randomized_timeouts, Some(true));
|
||||||
|
assert_eq!(options.connection_timeout, Some(5000));
|
||||||
|
assert_eq!(options.initial_data_timeout, Some(7000));
|
||||||
|
assert_eq!(options.socket_timeout, Some(9000));
|
||||||
|
assert_eq!(options.inactivity_check_interval, Some(1100));
|
||||||
|
assert_eq!(options.max_connection_lifetime, Some(13000));
|
||||||
|
assert_eq!(options.inactivity_timeout, Some(15000));
|
||||||
|
assert_eq!(options.graceful_shutdown_timeout, Some(17000));
|
||||||
|
assert_eq!(options.max_connections_per_ip, Some(20));
|
||||||
|
assert_eq!(options.connection_rate_limit_per_minute, Some(30));
|
||||||
|
assert_eq!(
|
||||||
|
options.keep_alive_treatment,
|
||||||
|
Some(KeepAliveTreatment::Extended)
|
||||||
|
);
|
||||||
|
assert_eq!(options.keep_alive_inactivity_multiplier, Some(2.0));
|
||||||
|
assert_eq!(options.extended_keep_alive_lifetime, Some(19000));
|
||||||
|
|
||||||
|
let route = &options.routes[0];
|
||||||
|
assert_eq!(route.route_match.transport, Some(TransportProtocol::Udp));
|
||||||
|
assert_eq!(route.route_match.protocol.as_deref(), Some("http3"));
|
||||||
|
assert_eq!(
|
||||||
|
route
|
||||||
|
.route_match
|
||||||
|
.headers
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.get("content-type")
|
||||||
|
.unwrap(),
|
||||||
|
"/^application\\/json$/i"
|
||||||
|
);
|
||||||
|
|
||||||
|
let target = &route.action.targets.as_ref().unwrap()[0];
|
||||||
|
assert!(matches!(target.host, HostSpec::List(_)));
|
||||||
|
assert!(matches!(target.port, PortSpec::Special(ref p) if p == "preserve"));
|
||||||
|
assert_eq!(target.backend_transport, Some(TransportProtocol::Tcp));
|
||||||
|
assert_eq!(target.send_proxy_protocol, Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
target
|
||||||
|
.target_match
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.headers
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.get("x-env")
|
||||||
|
.unwrap(),
|
||||||
|
"/^(prod|stage)$/"
|
||||||
|
);
|
||||||
|
assert_eq!(route.action.send_proxy_protocol, Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
route.action.udp.as_ref().unwrap().max_sessions_per_ip,
|
||||||
|
Some(321)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
route
|
||||||
|
.action
|
||||||
|
.udp
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.quic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.enable_http3,
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
let allow_list = route
|
||||||
|
.security
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.ip_allow_list
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
&allow_list[0],
|
||||||
|
crate::security_types::IpAllowEntry::DomainScoped { ip, domains }
|
||||||
|
if ip == "10.0.0.0/8" && domains == &vec!["api.example.com".to_string()]
|
||||||
|
));
|
||||||
|
|
||||||
|
let metrics = options.metrics.as_ref().unwrap();
|
||||||
|
assert_eq!(metrics.enabled, Some(true));
|
||||||
|
assert_eq!(metrics.sample_interval_ms, Some(250));
|
||||||
|
assert_eq!(metrics.retention_seconds, Some(60));
|
||||||
|
|
||||||
|
let acme = options.acme.as_ref().unwrap();
|
||||||
|
assert_eq!(acme.enabled, Some(true));
|
||||||
|
assert_eq!(acme.email.as_deref(), Some("ops@example.com"));
|
||||||
|
assert_eq!(acme.environment, Some(AcmeEnvironment::Staging));
|
||||||
|
assert_eq!(acme.use_production, Some(false));
|
||||||
|
assert_eq!(acme.skip_configured_certs, Some(true));
|
||||||
|
assert_eq!(acme.renew_threshold_days, Some(14));
|
||||||
|
assert_eq!(acme.renew_check_interval_hours, Some(12));
|
||||||
|
assert_eq!(acme.auto_renew, Some(true));
|
||||||
|
assert_eq!(acme.port, Some(80));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_timeouts() {
|
fn test_default_timeouts() {
|
||||||
let options = RustProxyOptions::default();
|
let options = RustProxyOptions::default();
|
||||||
@@ -438,9 +659,9 @@ mod tests {
|
|||||||
fn test_all_listening_ports() {
|
fn test_all_listening_ports() {
|
||||||
let options = RustProxyOptions {
|
let options = RustProxyOptions {
|
||||||
routes: vec![
|
routes: vec![
|
||||||
make_route("a.com", "backend", 8080, 80), // port 80
|
make_route("a.com", "backend", 8080, 80), // port 80
|
||||||
make_passthrough_route("b.com", "backend", 443), // port 443
|
make_passthrough_route("b.com", "backend", 443), // port 443
|
||||||
make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
|
make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -464,9 +685,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_deserialize_example_json() {
|
fn test_deserialize_example_json() {
|
||||||
let content = std::fs::read_to_string(
|
let content = std::fs::read_to_string(concat!(
|
||||||
concat!(env!("CARGO_MANIFEST_DIR"), "/../../config/example.json")
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
).unwrap();
|
"/../../config/example.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let options: RustProxyOptions = serde_json::from_str(&content).unwrap();
|
let options: RustProxyOptions = serde_json::from_str(&content).unwrap();
|
||||||
assert_eq!(options.routes.len(), 4);
|
assert_eq!(options.routes.len(), 4);
|
||||||
let ports = options.all_listening_ports();
|
let ports = options.all_listening_ports();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::tls_types::RouteTls;
|
|
||||||
use crate::security_types::RouteSecurity;
|
use crate::security_types::RouteSecurity;
|
||||||
|
use crate::tls_types::RouteTls;
|
||||||
|
|
||||||
// ─── Port Range ──────────────────────────────────────────────────────
|
// ─── Port Range ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -32,12 +32,13 @@ impl PortRange {
|
|||||||
pub fn to_ports(&self) -> Vec<u16> {
|
pub fn to_ports(&self) -> Vec<u16> {
|
||||||
match self {
|
match self {
|
||||||
PortRange::Single(p) => vec![*p],
|
PortRange::Single(p) => vec![*p],
|
||||||
PortRange::List(items) => {
|
PortRange::List(items) => items
|
||||||
items.iter().flat_map(|item| match item {
|
.iter()
|
||||||
|
.flat_map(|item| match item {
|
||||||
PortRangeItem::Port(p) => vec![*p],
|
PortRangeItem::Port(p) => vec![*p],
|
||||||
PortRangeItem::Range(r) => (r.from..=r.to).collect(),
|
PortRangeItem::Range(r) => (r.from..=r.to).collect(),
|
||||||
}).collect()
|
})
|
||||||
}
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,8 @@ impl From<Vec<&str>> for DomainSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Header match value: either exact string or regex pattern.
|
/// Header match value: either exact string or regex pattern.
|
||||||
/// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`.
|
/// In JSON, all values come as strings. Regex patterns use JS-style literal syntax,
|
||||||
|
/// e.g. `/^application\/json$/` or `/^application\/json$/i`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum HeaderMatchValue {
|
pub enum HeaderMatchValue {
|
||||||
|
|||||||
@@ -626,6 +626,9 @@ impl HttpProxyService {
|
|||||||
let route_id = route_match.route.id.as_deref();
|
let route_id = route_match.route.id.as_deref();
|
||||||
let ip_str = ip_string; // reuse from above (avoid redundant to_string())
|
let ip_str = ip_string; // reuse from above (avoid redundant to_string())
|
||||||
self.metrics.record_http_request();
|
self.metrics.record_http_request();
|
||||||
|
if let Some(ref h) = host {
|
||||||
|
self.metrics.record_ip_domain_request(&ip_str, h);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply request filters (IP check, rate limiting, auth)
|
// Apply request filters (IP check, rate limiting, auth)
|
||||||
if let Some(ref security) = route_match.route.security {
|
if let Some(ref security) = route_match.route.security {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -62,6 +62,8 @@ pub struct IpMetrics {
|
|||||||
pub bytes_out: u64,
|
pub bytes_out: u64,
|
||||||
pub throughput_in_bytes_per_sec: u64,
|
pub throughput_in_bytes_per_sec: u64,
|
||||||
pub throughput_out_bytes_per_sec: u64,
|
pub throughput_out_bytes_per_sec: u64,
|
||||||
|
/// Per-domain request/connection counts for this IP.
|
||||||
|
pub domain_requests: HashMap<String, u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-backend metrics (keyed by "host:port").
|
/// Per-backend metrics (keyed by "host:port").
|
||||||
@@ -139,6 +141,9 @@ const MAX_IPS_IN_SNAPSHOT: usize = 100;
|
|||||||
/// Maximum number of backends to include in a snapshot (top by total connections).
|
/// Maximum number of backends to include in a snapshot (top by total connections).
|
||||||
const MAX_BACKENDS_IN_SNAPSHOT: usize = 100;
|
const MAX_BACKENDS_IN_SNAPSHOT: usize = 100;
|
||||||
|
|
||||||
|
/// Maximum number of distinct domains tracked per IP (prevents subdomain-spray abuse).
|
||||||
|
const MAX_DOMAINS_PER_IP: usize = 256;
|
||||||
|
|
||||||
/// Metrics collector tracking connections and throughput.
|
/// Metrics collector tracking connections and throughput.
|
||||||
///
|
///
|
||||||
/// Design: The hot path (`record_bytes`) is entirely lock-free — it only touches
|
/// Design: The hot path (`record_bytes`) is entirely lock-free — it only touches
|
||||||
@@ -165,6 +170,10 @@ pub struct MetricsCollector {
|
|||||||
ip_bytes_out: DashMap<String, AtomicU64>,
|
ip_bytes_out: DashMap<String, AtomicU64>,
|
||||||
ip_pending_tp: DashMap<String, (AtomicU64, AtomicU64)>,
|
ip_pending_tp: DashMap<String, (AtomicU64, AtomicU64)>,
|
||||||
ip_throughput: DashMap<String, Mutex<ThroughputTracker>>,
|
ip_throughput: DashMap<String, Mutex<ThroughputTracker>>,
|
||||||
|
/// Per-IP domain request counts: IP → { domain → count }.
|
||||||
|
/// Tracks which domains each frontend IP has requested (via HTTP Host/SNI).
|
||||||
|
/// Inner DashMap uses 2 shards to minimise base memory per IP.
|
||||||
|
ip_domain_requests: DashMap<String, DashMap<String, AtomicU64>>,
|
||||||
|
|
||||||
// ── Per-backend tracking (keyed by "host:port") ──
|
// ── Per-backend tracking (keyed by "host:port") ──
|
||||||
backend_active: DashMap<String, AtomicU64>,
|
backend_active: DashMap<String, AtomicU64>,
|
||||||
@@ -247,6 +256,7 @@ impl MetricsCollector {
|
|||||||
ip_bytes_out: DashMap::new(),
|
ip_bytes_out: DashMap::new(),
|
||||||
ip_pending_tp: DashMap::new(),
|
ip_pending_tp: DashMap::new(),
|
||||||
ip_throughput: DashMap::new(),
|
ip_throughput: DashMap::new(),
|
||||||
|
ip_domain_requests: DashMap::new(),
|
||||||
backend_active: DashMap::new(),
|
backend_active: DashMap::new(),
|
||||||
backend_total: DashMap::new(),
|
backend_total: DashMap::new(),
|
||||||
backend_protocol: DashMap::new(),
|
backend_protocol: DashMap::new(),
|
||||||
@@ -351,6 +361,7 @@ impl MetricsCollector {
|
|||||||
self.ip_bytes_out.remove(ip);
|
self.ip_bytes_out.remove(ip);
|
||||||
self.ip_pending_tp.remove(ip);
|
self.ip_pending_tp.remove(ip);
|
||||||
self.ip_throughput.remove(ip);
|
self.ip_throughput.remove(ip);
|
||||||
|
self.ip_domain_requests.remove(ip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,6 +463,37 @@ impl MetricsCollector {
|
|||||||
self.pending_http_requests.fetch_add(1, Ordering::Relaxed);
|
self.pending_http_requests.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a domain request/connection for a frontend IP.
|
||||||
|
///
|
||||||
|
/// Called per HTTP request (with Host header) and per TCP passthrough
|
||||||
|
/// connection (with SNI domain). The common case (IP + domain both already
|
||||||
|
/// tracked) is two DashMap reads + one atomic increment — zero allocation.
|
||||||
|
pub fn record_ip_domain_request(&self, ip: &str, domain: &str) {
|
||||||
|
// Fast path: IP already tracked, domain already tracked
|
||||||
|
if let Some(domains) = self.ip_domain_requests.get(ip) {
|
||||||
|
if let Some(counter) = domains.get(domain) {
|
||||||
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// New domain for this IP — enforce cap
|
||||||
|
if domains.len() >= MAX_DOMAINS_PER_IP {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
domains
|
||||||
|
.entry(domain.to_string())
|
||||||
|
.or_insert_with(|| AtomicU64::new(0))
|
||||||
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// New IP — only create if the IP has active connections
|
||||||
|
if !self.ip_connections.contains_key(ip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let inner = DashMap::with_capacity_and_shard_amount(4, 2);
|
||||||
|
inner.insert(domain.to_string(), AtomicU64::new(1));
|
||||||
|
self.ip_domain_requests.insert(ip.to_string(), inner);
|
||||||
|
}
|
||||||
|
|
||||||
// ── UDP session recording methods ──
|
// ── UDP session recording methods ──
|
||||||
|
|
||||||
/// Record a new UDP session opened.
|
/// Record a new UDP session opened.
|
||||||
@@ -745,6 +787,7 @@ impl MetricsCollector {
|
|||||||
self.ip_pending_tp.retain(|k, _| self.ip_connections.contains_key(k));
|
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_throughput.retain(|k, _| self.ip_connections.contains_key(k));
|
||||||
self.ip_total_connections.retain(|k, _| self.ip_connections.contains_key(k));
|
self.ip_total_connections.retain(|k, _| self.ip_connections.contains_key(k));
|
||||||
|
self.ip_domain_requests.retain(|k, _| self.ip_connections.contains_key(k));
|
||||||
|
|
||||||
// Safety-net: prune orphaned backend error/stats entries for backends
|
// Safety-net: prune orphaned backend error/stats entries for backends
|
||||||
// that have no active or total connections (error-only backends).
|
// that have no active or total connections (error-only backends).
|
||||||
@@ -852,7 +895,7 @@ impl MetricsCollector {
|
|||||||
|
|
||||||
// Collect per-IP metrics — only IPs with active connections or total > 0,
|
// Collect per-IP metrics — only IPs with active connections or total > 0,
|
||||||
// capped at top MAX_IPS_IN_SNAPSHOT sorted by active count
|
// capped at top MAX_IPS_IN_SNAPSHOT sorted by active count
|
||||||
let mut ip_entries: Vec<(String, u64, u64, u64, u64, u64, u64)> = Vec::new();
|
let mut ip_entries: Vec<(String, u64, u64, u64, u64, u64, u64, HashMap<String, u64>)> = Vec::new();
|
||||||
for entry in self.ip_total_connections.iter() {
|
for entry in self.ip_total_connections.iter() {
|
||||||
let ip = entry.key().clone();
|
let ip = entry.key().clone();
|
||||||
let total = entry.value().load(Ordering::Relaxed);
|
let total = entry.value().load(Ordering::Relaxed);
|
||||||
@@ -872,14 +915,23 @@ impl MetricsCollector {
|
|||||||
.get(&ip)
|
.get(&ip)
|
||||||
.and_then(|entry| entry.value().lock().ok().map(|t| t.instant()))
|
.and_then(|entry| entry.value().lock().ok().map(|t| t.instant()))
|
||||||
.unwrap_or((0, 0));
|
.unwrap_or((0, 0));
|
||||||
ip_entries.push((ip, active, total, bytes_in, bytes_out, tp_in, tp_out));
|
// Collect per-domain request counts for this IP
|
||||||
|
let domain_requests = self.ip_domain_requests
|
||||||
|
.get(&ip)
|
||||||
|
.map(|domains| {
|
||||||
|
domains.iter()
|
||||||
|
.map(|e| (e.key().clone(), e.value().load(Ordering::Relaxed)))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
ip_entries.push((ip, active, total, bytes_in, bytes_out, tp_in, tp_out, domain_requests));
|
||||||
}
|
}
|
||||||
// Sort by active connections descending, then cap
|
// Sort by active connections descending, then cap
|
||||||
ip_entries.sort_by(|a, b| b.1.cmp(&a.1));
|
ip_entries.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
ip_entries.truncate(MAX_IPS_IN_SNAPSHOT);
|
ip_entries.truncate(MAX_IPS_IN_SNAPSHOT);
|
||||||
|
|
||||||
let mut ips = std::collections::HashMap::new();
|
let mut ips = std::collections::HashMap::new();
|
||||||
for (ip, active, total, bytes_in, bytes_out, tp_in, tp_out) in ip_entries {
|
for (ip, active, total, bytes_in, bytes_out, tp_in, tp_out, domain_requests) in ip_entries {
|
||||||
ips.insert(ip, IpMetrics {
|
ips.insert(ip, IpMetrics {
|
||||||
active_connections: active,
|
active_connections: active,
|
||||||
total_connections: total,
|
total_connections: total,
|
||||||
@@ -887,6 +939,7 @@ impl MetricsCollector {
|
|||||||
bytes_out,
|
bytes_out,
|
||||||
throughput_in_bytes_per_sec: tp_in,
|
throughput_in_bytes_per_sec: tp_in,
|
||||||
throughput_out_bytes_per_sec: tp_out,
|
throughput_out_bytes_per_sec: tp_out,
|
||||||
|
domain_requests,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -943,6 +943,9 @@ impl TcpListenerManager {
|
|||||||
|
|
||||||
// Track connection in metrics — guard ensures connection_closed on all exit paths
|
// Track connection in metrics — guard ensures connection_closed on all exit paths
|
||||||
metrics.connection_opened(route_id, Some(&ip_str));
|
metrics.connection_opened(route_id, Some(&ip_str));
|
||||||
|
if let Some(d) = effective_domain {
|
||||||
|
metrics.record_ip_domain_request(&ip_str, d);
|
||||||
|
}
|
||||||
let _conn_guard = ConnectionGuard::new(Arc::clone(&metrics), route_id, Some(&ip_str));
|
let _conn_guard = ConnectionGuard::new(Arc::clone(&metrics), route_id, Some(&ip_str));
|
||||||
|
|
||||||
// Check if this is a socket-handler route that should be relayed to TypeScript
|
// Check if this is a socket-handler route that should be relayed to TypeScript
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn compile_regex_pattern(pattern: &str) -> Option<Regex> {
|
||||||
|
if !pattern.starts_with('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_slash = pattern.rfind('/')?;
|
||||||
|
if last_slash == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let regex_body = &pattern[1..last_slash];
|
||||||
|
let flags = &pattern[last_slash + 1..];
|
||||||
|
|
||||||
|
let mut inline_flags = String::new();
|
||||||
|
for flag in flags.chars() {
|
||||||
|
match flag {
|
||||||
|
'i' | 'm' | 's' | 'u' => {
|
||||||
|
if !inline_flags.contains(flag) {
|
||||||
|
inline_flags.push(flag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'g' => {
|
||||||
|
// Global has no effect for single header matching.
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let compiled = if inline_flags.is_empty() {
|
||||||
|
regex_body.to_string()
|
||||||
|
} else {
|
||||||
|
format!("(?{}){}", inline_flags, regex_body)
|
||||||
|
};
|
||||||
|
|
||||||
|
Regex::new(&compiled).ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Match HTTP headers against a set of patterns.
|
/// Match HTTP headers against a set of patterns.
|
||||||
///
|
///
|
||||||
@@ -24,16 +61,15 @@ pub fn headers_match(
|
|||||||
None => return false, // Required header not present
|
None => return false, // Required header not present
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if pattern is a regex (surrounded by /)
|
// Check if pattern is a regex literal (/pattern/ or /pattern/flags)
|
||||||
if pattern.starts_with('/') && pattern.ends_with('/') && pattern.len() > 2 {
|
if pattern.starts_with('/') && pattern.len() > 2 {
|
||||||
let regex_str = &pattern[1..pattern.len() - 1];
|
match compile_regex_pattern(pattern) {
|
||||||
match Regex::new(regex_str) {
|
Some(re) => {
|
||||||
Ok(re) => {
|
|
||||||
if !re.is_match(header_value) {
|
if !re.is_match(header_value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
None => {
|
||||||
// Invalid regex, fall back to exact match
|
// Invalid regex, fall back to exact match
|
||||||
if header_value != pattern {
|
if header_value != pattern {
|
||||||
return false;
|
return false;
|
||||||
@@ -85,6 +121,24 @@ mod tests {
|
|||||||
assert!(headers_match(&patterns, &headers));
|
assert!(headers_match(&patterns, &headers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex_header_match_with_flags() {
|
||||||
|
let patterns: HashMap<String, String> = {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert(
|
||||||
|
"Content-Type".to_string(),
|
||||||
|
"/^application\\/json$/i".to_string(),
|
||||||
|
);
|
||||||
|
m
|
||||||
|
};
|
||||||
|
let headers: HashMap<String, String> = {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert("content-type".to_string(), "Application/JSON".to_string());
|
||||||
|
m
|
||||||
|
};
|
||||||
|
assert!(headers_match(&patterns, &headers));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_missing_header() {
|
fn test_missing_header() {
|
||||||
let patterns: HashMap<String, String> = {
|
let patterns: HashMap<String, String> = {
|
||||||
|
|||||||
@@ -537,6 +537,31 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
|
|||||||
'X-Custom-Header': 'value'
|
'X-Custom-Header': 'value'
|
||||||
})).toBeFalse();
|
})).toBeFalse();
|
||||||
|
|
||||||
|
const regexHeaderRoute: IRouteConfig = {
|
||||||
|
match: {
|
||||||
|
domains: 'example.com',
|
||||||
|
ports: 80,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': /^application\/(json|problem\+json)$/i,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{
|
||||||
|
host: 'localhost',
|
||||||
|
port: 3000
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(routeMatchesHeaders(regexHeaderRoute, {
|
||||||
|
'Content-Type': 'Application/Problem+Json',
|
||||||
|
})).toBeTrue();
|
||||||
|
|
||||||
|
expect(routeMatchesHeaders(regexHeaderRoute, {
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
})).toBeFalse();
|
||||||
|
|
||||||
// Route without header matching should match any headers
|
// Route without header matching should match any headers
|
||||||
const noHeaderRoute: IRouteConfig = {
|
const noHeaderRoute: IRouteConfig = {
|
||||||
match: { ports: 80, domains: 'example.com' },
|
match: { ports: 80, domains: 'example.com' },
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import type { ISmartProxyOptions } from '../ts/proxies/smart-proxy/models/interfaces.js';
|
||||||
|
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||||
|
import { RoutePreprocessor } from '../ts/proxies/smart-proxy/route-preprocessor.js';
|
||||||
|
import { buildRustProxyOptions } from '../ts/proxies/smart-proxy/utils/rust-config.js';
|
||||||
|
|
||||||
|
tap.test('Rust contract - preprocessor serializes regex headers for Rust', async () => {
|
||||||
|
const route: IRouteConfig = {
|
||||||
|
name: 'contract-route',
|
||||||
|
match: {
|
||||||
|
ports: [443, { from: 8443, to: 8444 }],
|
||||||
|
domains: ['api.example.com', '*.example.com'],
|
||||||
|
transport: 'udp',
|
||||||
|
protocol: 'http3',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': /^application\/json$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
path: '/api/*',
|
||||||
|
method: ['GET'],
|
||||||
|
headers: {
|
||||||
|
'X-Env': /^(prod|stage)$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
host: ['backend-a', 'backend-b'],
|
||||||
|
port: 'preserve',
|
||||||
|
sendProxyProtocol: true,
|
||||||
|
backendTransport: 'tcp',
|
||||||
|
}],
|
||||||
|
tls: {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: 'auto',
|
||||||
|
},
|
||||||
|
sendProxyProtocol: true,
|
||||||
|
udp: {
|
||||||
|
maxSessionsPerIP: 321,
|
||||||
|
quic: {
|
||||||
|
enableHttp3: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
ipAllowList: [{
|
||||||
|
ip: '10.0.0.0/8',
|
||||||
|
domains: ['api.example.com'],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const preprocessor = new RoutePreprocessor();
|
||||||
|
const [rustRoute] = preprocessor.preprocessForRust([route]);
|
||||||
|
|
||||||
|
expect(rustRoute.match.headers?.['Content-Type']).toEqual('/^application\\/json$/i');
|
||||||
|
expect(rustRoute.match.transport).toEqual('udp');
|
||||||
|
expect(rustRoute.match.protocol).toEqual('http3');
|
||||||
|
expect(rustRoute.action.targets?.[0].match?.headers?.['X-Env']).toEqual('/^(prod|stage)$/');
|
||||||
|
expect(rustRoute.action.targets?.[0].port).toEqual('preserve');
|
||||||
|
expect(rustRoute.action.targets?.[0].backendTransport).toEqual('tcp');
|
||||||
|
expect(rustRoute.action.sendProxyProtocol).toBeTrue();
|
||||||
|
expect(rustRoute.action.udp?.maxSessionsPerIp).toEqual(321);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Rust contract - preprocessor converts dynamic targets to relay-safe payloads', async () => {
|
||||||
|
const route: IRouteConfig = {
|
||||||
|
name: 'dynamic-contract-route',
|
||||||
|
match: {
|
||||||
|
ports: 8080,
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{
|
||||||
|
host: () => 'dynamic-backend.internal',
|
||||||
|
port: () => 9443,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const preprocessor = new RoutePreprocessor();
|
||||||
|
const [rustRoute] = preprocessor.preprocessForRust([route]);
|
||||||
|
|
||||||
|
expect(rustRoute.action.type).toEqual('socket-handler');
|
||||||
|
expect(rustRoute.action.targets?.[0].host).toEqual('localhost');
|
||||||
|
expect(rustRoute.action.targets?.[0].port).toEqual(0);
|
||||||
|
expect(preprocessor.getOriginalRoute('dynamic-contract-route')).toEqual(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Rust contract - top-level config keeps shared SmartProxy settings', async () => {
|
||||||
|
const settings: ISmartProxyOptions = {
|
||||||
|
routes: [{
|
||||||
|
name: 'top-level-contract-route',
|
||||||
|
match: {
|
||||||
|
ports: 443,
|
||||||
|
domains: 'api.example.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{
|
||||||
|
host: 'backend.internal',
|
||||||
|
port: 8443,
|
||||||
|
}],
|
||||||
|
tls: {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
preserveSourceIP: true,
|
||||||
|
proxyIPs: ['10.0.0.1'],
|
||||||
|
acceptProxyProtocol: true,
|
||||||
|
sendProxyProtocol: true,
|
||||||
|
noDelay: true,
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveInitialDelay: 1500,
|
||||||
|
maxPendingDataSize: 4096,
|
||||||
|
disableInactivityCheck: true,
|
||||||
|
enableKeepAliveProbes: true,
|
||||||
|
enableDetailedLogging: true,
|
||||||
|
enableTlsDebugLogging: true,
|
||||||
|
enableRandomizedTimeouts: true,
|
||||||
|
connectionTimeout: 5000,
|
||||||
|
initialDataTimeout: 7000,
|
||||||
|
socketTimeout: 9000,
|
||||||
|
inactivityCheckInterval: 1100,
|
||||||
|
maxConnectionLifetime: 13000,
|
||||||
|
inactivityTimeout: 15000,
|
||||||
|
gracefulShutdownTimeout: 17000,
|
||||||
|
maxConnectionsPerIP: 20,
|
||||||
|
connectionRateLimitPerMinute: 30,
|
||||||
|
keepAliveTreatment: 'extended',
|
||||||
|
keepAliveInactivityMultiplier: 2,
|
||||||
|
extendedKeepAliveLifetime: 19000,
|
||||||
|
metrics: {
|
||||||
|
enabled: true,
|
||||||
|
sampleIntervalMs: 250,
|
||||||
|
retentionSeconds: 60,
|
||||||
|
},
|
||||||
|
acme: {
|
||||||
|
enabled: true,
|
||||||
|
email: 'ops@example.com',
|
||||||
|
environment: 'staging',
|
||||||
|
useProduction: false,
|
||||||
|
skipConfiguredCerts: true,
|
||||||
|
renewThresholdDays: 14,
|
||||||
|
renewCheckIntervalHours: 12,
|
||||||
|
autoRenew: true,
|
||||||
|
port: 80,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const preprocessor = new RoutePreprocessor();
|
||||||
|
const routes = preprocessor.preprocessForRust(settings.routes);
|
||||||
|
const config = buildRustProxyOptions(settings, routes);
|
||||||
|
|
||||||
|
expect(config.preserveSourceIp).toBeTrue();
|
||||||
|
expect(config.proxyIps).toEqual(['10.0.0.1']);
|
||||||
|
expect(config.acceptProxyProtocol).toBeTrue();
|
||||||
|
expect(config.sendProxyProtocol).toBeTrue();
|
||||||
|
expect(config.noDelay).toBeTrue();
|
||||||
|
expect(config.keepAlive).toBeTrue();
|
||||||
|
expect(config.keepAliveInitialDelay).toEqual(1500);
|
||||||
|
expect(config.maxPendingDataSize).toEqual(4096);
|
||||||
|
expect(config.disableInactivityCheck).toBeTrue();
|
||||||
|
expect(config.enableKeepAliveProbes).toBeTrue();
|
||||||
|
expect(config.enableDetailedLogging).toBeTrue();
|
||||||
|
expect(config.enableTlsDebugLogging).toBeTrue();
|
||||||
|
expect(config.enableRandomizedTimeouts).toBeTrue();
|
||||||
|
expect(config.connectionTimeout).toEqual(5000);
|
||||||
|
expect(config.initialDataTimeout).toEqual(7000);
|
||||||
|
expect(config.socketTimeout).toEqual(9000);
|
||||||
|
expect(config.inactivityCheckInterval).toEqual(1100);
|
||||||
|
expect(config.maxConnectionLifetime).toEqual(13000);
|
||||||
|
expect(config.inactivityTimeout).toEqual(15000);
|
||||||
|
expect(config.gracefulShutdownTimeout).toEqual(17000);
|
||||||
|
expect(config.maxConnectionsPerIp).toEqual(20);
|
||||||
|
expect(config.connectionRateLimitPerMinute).toEqual(30);
|
||||||
|
expect(config.keepAliveTreatment).toEqual('extended');
|
||||||
|
expect(config.keepAliveInactivityMultiplier).toEqual(2);
|
||||||
|
expect(config.extendedKeepAliveLifetime).toEqual(19000);
|
||||||
|
expect(config.metrics?.sampleIntervalMs).toEqual(250);
|
||||||
|
expect(config.acme?.email).toEqual('ops@example.com');
|
||||||
|
expect(config.acme?.environment).toEqual('staging');
|
||||||
|
expect(config.acme?.skipConfiguredCerts).toBeTrue();
|
||||||
|
expect(config.acme?.renewThresholdDays).toEqual(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '27.5.0',
|
version: '27.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.'
|
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export interface IMetrics {
|
|||||||
byRoute(): Map<string, number>;
|
byRoute(): Map<string, number>;
|
||||||
byIP(): Map<string, number>;
|
byIP(): Map<string, number>;
|
||||||
topIPs(limit?: number): Array<{ ip: string; count: number }>;
|
topIPs(limit?: number): Array<{ ip: string; count: number }>;
|
||||||
|
/** Per-IP domain request counts: IP -> { domain -> count }. */
|
||||||
|
domainRequestsByIP(): Map<string, Map<string, number>>;
|
||||||
|
/** Top IP-domain pairs sorted by request count descending. */
|
||||||
|
topDomainRequests(limit?: number): Array<{ ip: string; domain: string; count: number }>;
|
||||||
frontendProtocols(): IProtocolDistribution;
|
frontendProtocols(): IProtocolDistribution;
|
||||||
backendProtocols(): IProtocolDistribution;
|
backendProtocols(): IProtocolDistribution;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import type { IProtocolCacheEntry, IProtocolDistribution } from './metrics-types.js';
|
||||||
|
import type { IAcmeOptions, ISmartProxyOptions } from './interfaces.js';
|
||||||
|
import type {
|
||||||
|
IRouteAction,
|
||||||
|
IRouteConfig,
|
||||||
|
IRouteMatch,
|
||||||
|
IRouteTarget,
|
||||||
|
ITargetMatch,
|
||||||
|
IRouteUdp,
|
||||||
|
} from './route-types.js';
|
||||||
|
|
||||||
|
export type TRustHeaderMatchers = Record<string, string>;
|
||||||
|
|
||||||
|
export interface IRustRouteMatch extends Omit<IRouteMatch, 'headers'> {
|
||||||
|
headers?: TRustHeaderMatchers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustTargetMatch extends Omit<ITargetMatch, 'headers'> {
|
||||||
|
headers?: TRustHeaderMatchers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustRouteTarget extends Omit<IRouteTarget, 'host' | 'port' | 'match'> {
|
||||||
|
host: string | string[];
|
||||||
|
port: number | 'preserve';
|
||||||
|
match?: IRustTargetMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustRouteUdp extends Omit<IRouteUdp, 'maxSessionsPerIP'> {
|
||||||
|
maxSessionsPerIp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustDefaultConfig extends Omit<NonNullable<ISmartProxyOptions['defaults']>, 'preserveSourceIP'> {
|
||||||
|
preserveSourceIp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustRouteAction
|
||||||
|
extends Omit<IRouteAction, 'targets' | 'socketHandler' | 'datagramHandler' | 'forwardingEngine' | 'nftables' | 'udp'> {
|
||||||
|
targets?: IRustRouteTarget[];
|
||||||
|
udp?: IRustRouteUdp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustRouteConfig extends Omit<IRouteConfig, 'match' | 'action'> {
|
||||||
|
match: IRustRouteMatch;
|
||||||
|
action: IRustRouteAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustAcmeOptions extends Omit<IAcmeOptions, 'routeForwards'> {}
|
||||||
|
|
||||||
|
export interface IRustProxyOptions {
|
||||||
|
routes: IRustRouteConfig[];
|
||||||
|
preserveSourceIp?: boolean;
|
||||||
|
proxyIps?: string[];
|
||||||
|
acceptProxyProtocol?: boolean;
|
||||||
|
sendProxyProtocol?: boolean;
|
||||||
|
defaults?: IRustDefaultConfig;
|
||||||
|
connectionTimeout?: number;
|
||||||
|
initialDataTimeout?: number;
|
||||||
|
socketTimeout?: number;
|
||||||
|
inactivityCheckInterval?: number;
|
||||||
|
maxConnectionLifetime?: number;
|
||||||
|
inactivityTimeout?: number;
|
||||||
|
gracefulShutdownTimeout?: number;
|
||||||
|
noDelay?: boolean;
|
||||||
|
keepAlive?: boolean;
|
||||||
|
keepAliveInitialDelay?: number;
|
||||||
|
maxPendingDataSize?: number;
|
||||||
|
disableInactivityCheck?: boolean;
|
||||||
|
enableKeepAliveProbes?: boolean;
|
||||||
|
enableDetailedLogging?: boolean;
|
||||||
|
enableTlsDebugLogging?: boolean;
|
||||||
|
enableRandomizedTimeouts?: boolean;
|
||||||
|
maxConnectionsPerIp?: number;
|
||||||
|
connectionRateLimitPerMinute?: number;
|
||||||
|
keepAliveTreatment?: ISmartProxyOptions['keepAliveTreatment'];
|
||||||
|
keepAliveInactivityMultiplier?: number;
|
||||||
|
extendedKeepAliveLifetime?: number;
|
||||||
|
metrics?: ISmartProxyOptions['metrics'];
|
||||||
|
acme?: IRustAcmeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustStatistics {
|
||||||
|
activeConnections: number;
|
||||||
|
totalConnections: number;
|
||||||
|
routesCount: number;
|
||||||
|
listeningPorts: number[];
|
||||||
|
uptimeSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustCertificateStatus {
|
||||||
|
domain: string;
|
||||||
|
source: string;
|
||||||
|
expiresAt: number;
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustThroughputSample {
|
||||||
|
timestampMs: number;
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustRouteMetrics {
|
||||||
|
activeConnections: number;
|
||||||
|
totalConnections: number;
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
throughputInBytesPerSec: number;
|
||||||
|
throughputOutBytesPerSec: number;
|
||||||
|
throughputRecentInBytesPerSec: number;
|
||||||
|
throughputRecentOutBytesPerSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustIpMetrics {
|
||||||
|
activeConnections: number;
|
||||||
|
totalConnections: number;
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
throughputInBytesPerSec: number;
|
||||||
|
throughputOutBytesPerSec: number;
|
||||||
|
domainRequests: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustBackendMetrics {
|
||||||
|
activeConnections: number;
|
||||||
|
totalConnections: number;
|
||||||
|
protocol: string;
|
||||||
|
connectErrors: number;
|
||||||
|
handshakeErrors: number;
|
||||||
|
requestErrors: number;
|
||||||
|
totalConnectTimeUs: number;
|
||||||
|
connectCount: number;
|
||||||
|
poolHits: number;
|
||||||
|
poolMisses: number;
|
||||||
|
h2Failures: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRustMetricsSnapshot {
|
||||||
|
activeConnections: number;
|
||||||
|
totalConnections: number;
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
throughputInBytesPerSec: number;
|
||||||
|
throughputOutBytesPerSec: number;
|
||||||
|
throughputRecentInBytesPerSec: number;
|
||||||
|
throughputRecentOutBytesPerSec: number;
|
||||||
|
routes: Record<string, IRustRouteMetrics>;
|
||||||
|
ips: Record<string, IRustIpMetrics>;
|
||||||
|
backends: Record<string, IRustBackendMetrics>;
|
||||||
|
throughputHistory: IRustThroughputSample[];
|
||||||
|
totalHttpRequests: number;
|
||||||
|
httpRequestsPerSec: number;
|
||||||
|
httpRequestsPerSecRecent: number;
|
||||||
|
activeUdpSessions: number;
|
||||||
|
totalUdpSessions: number;
|
||||||
|
totalDatagramsIn: number;
|
||||||
|
totalDatagramsOut: number;
|
||||||
|
detectedProtocols: IProtocolCacheEntry[];
|
||||||
|
frontendProtocols: IProtocolDistribution;
|
||||||
|
backendProtocols: IProtocolDistribution;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js';
|
import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js';
|
||||||
import { logger } from '../../core/utils/logger.js';
|
import type { IRustRouteConfig } from './models/rust-types.js';
|
||||||
|
import { serializeRouteForRust } from './utils/rust-config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preprocesses routes before sending them to Rust.
|
* Preprocesses routes before sending them to Rust.
|
||||||
@@ -24,7 +25,7 @@ export class RoutePreprocessor {
|
|||||||
* - Non-serializable fields are stripped
|
* - Non-serializable fields are stripped
|
||||||
* - Original routes are preserved in the local map for handler lookup
|
* - Original routes are preserved in the local map for handler lookup
|
||||||
*/
|
*/
|
||||||
public preprocessForRust(routes: IRouteConfig[]): IRouteConfig[] {
|
public preprocessForRust(routes: IRouteConfig[]): IRustRouteConfig[] {
|
||||||
this.originalRoutes.clear();
|
this.originalRoutes.clear();
|
||||||
return routes.map((route, index) => this.preprocessRoute(route, index));
|
return routes.map((route, index) => this.preprocessRoute(route, index));
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ export class RoutePreprocessor {
|
|||||||
return new Map(this.originalRoutes);
|
return new Map(this.originalRoutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private preprocessRoute(route: IRouteConfig, index: number): IRouteConfig {
|
private preprocessRoute(route: IRouteConfig, index: number): IRustRouteConfig {
|
||||||
const routeKey = route.name || route.id || `route_${index}`;
|
const routeKey = route.name || route.id || `route_${index}`;
|
||||||
|
|
||||||
// Check if this route needs TS-side handling
|
// Check if this route needs TS-side handling
|
||||||
@@ -57,7 +58,7 @@ export class RoutePreprocessor {
|
|||||||
// Create a clean copy for Rust
|
// Create a clean copy for Rust
|
||||||
const cleanRoute: IRouteConfig = {
|
const cleanRoute: IRouteConfig = {
|
||||||
...route,
|
...route,
|
||||||
action: this.cleanAction(route.action, routeKey, needsTsHandling),
|
action: this.cleanAction(route.action, needsTsHandling),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure we have a name for handler lookup
|
// Ensure we have a name for handler lookup
|
||||||
@@ -65,7 +66,7 @@ export class RoutePreprocessor {
|
|||||||
cleanRoute.name = routeKey;
|
cleanRoute.name = routeKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanRoute;
|
return serializeRouteForRust(cleanRoute);
|
||||||
}
|
}
|
||||||
|
|
||||||
private routeNeedsTsHandling(route: IRouteConfig): boolean {
|
private routeNeedsTsHandling(route: IRouteConfig): boolean {
|
||||||
@@ -91,15 +92,16 @@ export class RoutePreprocessor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanAction(action: IRouteAction, routeKey: string, needsTsHandling: boolean): IRouteAction {
|
private cleanAction(action: IRouteAction, needsTsHandling: boolean): IRouteAction {
|
||||||
const cleanAction: IRouteAction = { ...action };
|
let cleanAction: IRouteAction = { ...action };
|
||||||
|
|
||||||
if (needsTsHandling) {
|
if (needsTsHandling) {
|
||||||
// Convert to socket-handler type for Rust (Rust will relay back to TS)
|
// Convert to socket-handler type for Rust (Rust will relay back to TS)
|
||||||
cleanAction.type = 'socket-handler';
|
const { socketHandler: _socketHandler, datagramHandler: _datagramHandler, ...serializableAction } = cleanAction;
|
||||||
// Remove the JS handlers (not serializable)
|
cleanAction = {
|
||||||
delete (cleanAction as any).socketHandler;
|
...serializableAction,
|
||||||
delete (cleanAction as any).datagramHandler;
|
type: 'socket-handler',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean targets - replace functions with static values
|
// Clean targets - replace functions with static values
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IMetrics, IBackendMetrics, IProtocolCacheEntry, IProtocolDistribution, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js';
|
import type { IMetrics, IBackendMetrics, IProtocolCacheEntry, IProtocolDistribution, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js';
|
||||||
import type { RustProxyBridge } from './rust-proxy-bridge.js';
|
import type { RustProxyBridge } from './rust-proxy-bridge.js';
|
||||||
|
import type { IRustBackendMetrics, IRustIpMetrics, IRustMetricsSnapshot, IRustRouteMetrics } from './models/rust-types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapts Rust JSON metrics to the IMetrics interface.
|
* Adapts Rust JSON metrics to the IMetrics interface.
|
||||||
@@ -14,7 +15,7 @@ import type { RustProxyBridge } from './rust-proxy-bridge.js';
|
|||||||
*/
|
*/
|
||||||
export class RustMetricsAdapter implements IMetrics {
|
export class RustMetricsAdapter implements IMetrics {
|
||||||
private bridge: RustProxyBridge;
|
private bridge: RustProxyBridge;
|
||||||
private cache: any = null;
|
private cache: IRustMetricsSnapshot | null = null;
|
||||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private pollIntervalMs: number;
|
private pollIntervalMs: number;
|
||||||
|
|
||||||
@@ -65,8 +66,8 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byRoute: (): Map<string, number> => {
|
byRoute: (): Map<string, number> => {
|
||||||
const result = new Map<string, number>();
|
const result = new Map<string, number>();
|
||||||
if (this.cache?.routes) {
|
if (this.cache?.routes) {
|
||||||
for (const [name, rm] of Object.entries(this.cache.routes)) {
|
for (const [name, rm] of Object.entries(this.cache.routes) as Array<[string, IRustRouteMetrics]>) {
|
||||||
result.set(name, (rm as any).activeConnections ?? 0);
|
result.set(name, rm.activeConnections ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -74,8 +75,8 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byIP: (): Map<string, number> => {
|
byIP: (): Map<string, number> => {
|
||||||
const result = new Map<string, number>();
|
const result = new Map<string, number>();
|
||||||
if (this.cache?.ips) {
|
if (this.cache?.ips) {
|
||||||
for (const [ip, im] of Object.entries(this.cache.ips)) {
|
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
|
||||||
result.set(ip, (im as any).activeConnections ?? 0);
|
result.set(ip, im.activeConnections ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -83,8 +84,41 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
|
topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
|
||||||
const result: Array<{ ip: string; count: number }> = [];
|
const result: Array<{ ip: string; count: number }> = [];
|
||||||
if (this.cache?.ips) {
|
if (this.cache?.ips) {
|
||||||
for (const [ip, im] of Object.entries(this.cache.ips)) {
|
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
|
||||||
result.push({ ip, count: (im as any).activeConnections ?? 0 });
|
result.push({ ip, count: im.activeConnections ?? 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sort((a, b) => b.count - a.count);
|
||||||
|
return result.slice(0, limit);
|
||||||
|
},
|
||||||
|
domainRequestsByIP: (): Map<string, Map<string, number>> => {
|
||||||
|
const result = new Map<string, Map<string, number>>();
|
||||||
|
if (this.cache?.ips) {
|
||||||
|
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
|
||||||
|
const dr = im.domainRequests;
|
||||||
|
if (dr && typeof dr === 'object') {
|
||||||
|
const domainMap = new Map<string, number>();
|
||||||
|
for (const [domain, count] of Object.entries(dr)) {
|
||||||
|
domainMap.set(domain, count as number);
|
||||||
|
}
|
||||||
|
if (domainMap.size > 0) {
|
||||||
|
result.set(ip, domainMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
topDomainRequests: (limit: number = 20): Array<{ ip: string; domain: string; count: number }> => {
|
||||||
|
const result: Array<{ ip: string; domain: string; count: number }> = [];
|
||||||
|
if (this.cache?.ips) {
|
||||||
|
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
|
||||||
|
const dr = im.domainRequests;
|
||||||
|
if (dr && typeof dr === 'object') {
|
||||||
|
for (const [domain, count] of Object.entries(dr)) {
|
||||||
|
result.push({ ip, domain, count: count as number });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.sort((a, b) => b.count - a.count);
|
result.sort((a, b) => b.count - a.count);
|
||||||
@@ -143,7 +177,7 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
},
|
},
|
||||||
history: (seconds: number): Array<IThroughputHistoryPoint> => {
|
history: (seconds: number): Array<IThroughputHistoryPoint> => {
|
||||||
if (!this.cache?.throughputHistory) return [];
|
if (!this.cache?.throughputHistory) return [];
|
||||||
return this.cache.throughputHistory.slice(-seconds).map((p: any) => ({
|
return this.cache.throughputHistory.slice(-seconds).map((p) => ({
|
||||||
timestamp: p.timestampMs,
|
timestamp: p.timestampMs,
|
||||||
in: p.bytesIn,
|
in: p.bytesIn,
|
||||||
out: p.bytesOut,
|
out: p.bytesOut,
|
||||||
@@ -152,10 +186,10 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byRoute: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
byRoute: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
||||||
const result = new Map<string, IThroughputData>();
|
const result = new Map<string, IThroughputData>();
|
||||||
if (this.cache?.routes) {
|
if (this.cache?.routes) {
|
||||||
for (const [name, rm] of Object.entries(this.cache.routes)) {
|
for (const [name, rm] of Object.entries(this.cache.routes) as Array<[string, IRustRouteMetrics]>) {
|
||||||
result.set(name, {
|
result.set(name, {
|
||||||
in: (rm as any).throughputInBytesPerSec ?? 0,
|
in: rm.throughputInBytesPerSec ?? 0,
|
||||||
out: (rm as any).throughputOutBytesPerSec ?? 0,
|
out: rm.throughputOutBytesPerSec ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,10 +198,10 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byIP: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
byIP: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
||||||
const result = new Map<string, IThroughputData>();
|
const result = new Map<string, IThroughputData>();
|
||||||
if (this.cache?.ips) {
|
if (this.cache?.ips) {
|
||||||
for (const [ip, im] of Object.entries(this.cache.ips)) {
|
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
|
||||||
result.set(ip, {
|
result.set(ip, {
|
||||||
in: (im as any).throughputInBytesPerSec ?? 0,
|
in: im.throughputInBytesPerSec ?? 0,
|
||||||
out: (im as any).throughputOutBytesPerSec ?? 0,
|
out: im.throughputOutBytesPerSec ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,23 +237,22 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byBackend: (): Map<string, IBackendMetrics> => {
|
byBackend: (): Map<string, IBackendMetrics> => {
|
||||||
const result = new Map<string, IBackendMetrics>();
|
const result = new Map<string, IBackendMetrics>();
|
||||||
if (this.cache?.backends) {
|
if (this.cache?.backends) {
|
||||||
for (const [key, bm] of Object.entries(this.cache.backends)) {
|
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
|
||||||
const m = bm as any;
|
const totalTimeUs = bm.totalConnectTimeUs ?? 0;
|
||||||
const totalTimeUs = m.totalConnectTimeUs ?? 0;
|
const count = bm.connectCount ?? 0;
|
||||||
const count = m.connectCount ?? 0;
|
const poolHits = bm.poolHits ?? 0;
|
||||||
const poolHits = m.poolHits ?? 0;
|
const poolMisses = bm.poolMisses ?? 0;
|
||||||
const poolMisses = m.poolMisses ?? 0;
|
|
||||||
const poolTotal = poolHits + poolMisses;
|
const poolTotal = poolHits + poolMisses;
|
||||||
result.set(key, {
|
result.set(key, {
|
||||||
protocol: m.protocol ?? 'unknown',
|
protocol: bm.protocol ?? 'unknown',
|
||||||
activeConnections: m.activeConnections ?? 0,
|
activeConnections: bm.activeConnections ?? 0,
|
||||||
totalConnections: m.totalConnections ?? 0,
|
totalConnections: bm.totalConnections ?? 0,
|
||||||
connectErrors: m.connectErrors ?? 0,
|
connectErrors: bm.connectErrors ?? 0,
|
||||||
handshakeErrors: m.handshakeErrors ?? 0,
|
handshakeErrors: bm.handshakeErrors ?? 0,
|
||||||
requestErrors: m.requestErrors ?? 0,
|
requestErrors: bm.requestErrors ?? 0,
|
||||||
avgConnectTimeMs: count > 0 ? (totalTimeUs / count) / 1000 : 0,
|
avgConnectTimeMs: count > 0 ? (totalTimeUs / count) / 1000 : 0,
|
||||||
poolHitRate: poolTotal > 0 ? poolHits / poolTotal : 0,
|
poolHitRate: poolTotal > 0 ? poolHits / poolTotal : 0,
|
||||||
h2Failures: m.h2Failures ?? 0,
|
h2Failures: bm.h2Failures ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,8 +261,8 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
protocols: (): Map<string, string> => {
|
protocols: (): Map<string, string> => {
|
||||||
const result = new Map<string, string>();
|
const result = new Map<string, string>();
|
||||||
if (this.cache?.backends) {
|
if (this.cache?.backends) {
|
||||||
for (const [key, bm] of Object.entries(this.cache.backends)) {
|
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
|
||||||
result.set(key, (bm as any).protocol ?? 'unknown');
|
result.set(key, bm.protocol ?? 'unknown');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -237,9 +270,8 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
topByErrors: (limit: number = 10): Array<{ backend: string; errors: number }> => {
|
topByErrors: (limit: number = 10): Array<{ backend: string; errors: number }> => {
|
||||||
const result: Array<{ backend: string; errors: number }> = [];
|
const result: Array<{ backend: string; errors: number }> = [];
|
||||||
if (this.cache?.backends) {
|
if (this.cache?.backends) {
|
||||||
for (const [key, bm] of Object.entries(this.cache.backends)) {
|
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
|
||||||
const m = bm as any;
|
const errors = (bm.connectErrors ?? 0) + (bm.handshakeErrors ?? 0) + (bm.requestErrors ?? 0);
|
||||||
const errors = (m.connectErrors ?? 0) + (m.handshakeErrors ?? 0) + (m.requestErrors ?? 0);
|
|
||||||
if (errors > 0) result.push({ backend: key, errors });
|
if (errors > 0) result.push({ backend: key, errors });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { logger } from '../../core/utils/logger.js';
|
import { logger } from '../../core/utils/logger.js';
|
||||||
import type { IRouteConfig } from './models/route-types.js';
|
import type {
|
||||||
|
IRustCertificateStatus,
|
||||||
|
IRustMetricsSnapshot,
|
||||||
|
IRustProxyOptions,
|
||||||
|
IRustRouteConfig,
|
||||||
|
IRustStatistics,
|
||||||
|
} from './models/rust-types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-safe command definitions for the Rust proxy IPC protocol.
|
* Type-safe command definitions for the Rust proxy IPC protocol.
|
||||||
*/
|
*/
|
||||||
type TSmartProxyCommands = {
|
type TSmartProxyCommands = {
|
||||||
start: { params: { config: any }; result: void };
|
start: { params: { config: IRustProxyOptions }; result: void };
|
||||||
stop: { params: Record<string, never>; result: void };
|
stop: { params: Record<string, never>; result: void };
|
||||||
updateRoutes: { params: { routes: IRouteConfig[] }; result: void };
|
updateRoutes: { params: { routes: IRustRouteConfig[] }; result: void };
|
||||||
getMetrics: { params: Record<string, never>; result: any };
|
getMetrics: { params: Record<string, never>; result: IRustMetricsSnapshot };
|
||||||
getStatistics: { params: Record<string, never>; result: any };
|
getStatistics: { params: Record<string, never>; result: IRustStatistics };
|
||||||
provisionCertificate: { params: { routeName: string }; result: void };
|
provisionCertificate: { params: { routeName: string }; result: void };
|
||||||
renewCertificate: { params: { routeName: string }; result: void };
|
renewCertificate: { params: { routeName: string }; result: void };
|
||||||
getCertificateStatus: { params: { routeName: string }; result: any };
|
getCertificateStatus: { params: { routeName: string }; result: IRustCertificateStatus | null };
|
||||||
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
|
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
|
||||||
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
|
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
|
||||||
addListeningPort: { params: { port: number }; result: void };
|
addListeningPort: { params: { port: number }; result: void };
|
||||||
removeListeningPort: { params: { port: number }; result: void };
|
removeListeningPort: { params: { port: number }; result: void };
|
||||||
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
|
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
|
||||||
setDatagramHandlerRelay: { params: { socketPath: string }; result: void };
|
setDatagramHandlerRelay: { params: { socketPath: string }; result: void };
|
||||||
};
|
};
|
||||||
@@ -121,7 +127,7 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
|||||||
|
|
||||||
// --- Convenience methods for each management command ---
|
// --- Convenience methods for each management command ---
|
||||||
|
|
||||||
public async startProxy(config: any): Promise<void> {
|
public async startProxy(config: IRustProxyOptions): Promise<void> {
|
||||||
await this.bridge.sendCommand('start', { config });
|
await this.bridge.sendCommand('start', { config });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,15 +135,15 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
|||||||
await this.bridge.sendCommand('stop', {} as Record<string, never>);
|
await this.bridge.sendCommand('stop', {} as Record<string, never>);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
|
public async updateRoutes(routes: IRustRouteConfig[]): Promise<void> {
|
||||||
await this.bridge.sendCommand('updateRoutes', { routes });
|
await this.bridge.sendCommand('updateRoutes', { routes });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMetrics(): Promise<any> {
|
public async getMetrics(): Promise<IRustMetricsSnapshot> {
|
||||||
return this.bridge.sendCommand('getMetrics', {} as Record<string, never>);
|
return this.bridge.sendCommand('getMetrics', {} as Record<string, never>);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getStatistics(): Promise<any> {
|
public async getStatistics(): Promise<IRustStatistics> {
|
||||||
return this.bridge.sendCommand('getStatistics', {} as Record<string, never>);
|
return this.bridge.sendCommand('getStatistics', {} as Record<string, never>);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +155,7 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
|||||||
await this.bridge.sendCommand('renewCertificate', { routeName });
|
await this.bridge.sendCommand('renewCertificate', { routeName });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCertificateStatus(routeName: string): Promise<any> {
|
public async getCertificateStatus(routeName: string): Promise<IRustCertificateStatus | null> {
|
||||||
return this.bridge.sendCommand('getCertificateStatus', { routeName });
|
return this.bridge.sendCommand('getCertificateStatus', { routeName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
|||||||
// Route management
|
// Route management
|
||||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||||
import { RouteValidator } from './utils/route-validator.js';
|
import { RouteValidator } from './utils/route-validator.js';
|
||||||
|
import { buildRustProxyOptions } from './utils/rust-config.js';
|
||||||
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
|
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
|
||||||
import { Mutex } from './utils/mutex.js';
|
import { Mutex } from './utils/mutex.js';
|
||||||
import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
|
import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
|
||||||
@@ -19,6 +20,7 @@ import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
|
|||||||
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './models/interfaces.js';
|
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './models/interfaces.js';
|
||||||
import type { IRouteConfig } from './models/route-types.js';
|
import type { IRouteConfig } from './models/route-types.js';
|
||||||
import type { IMetrics } from './models/metrics-types.js';
|
import type { IMetrics } from './models/metrics-types.js';
|
||||||
|
import type { IRustCertificateStatus, IRustProxyOptions, IRustStatistics } from './models/rust-types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
|
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
|
||||||
@@ -365,7 +367,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Get certificate status for a route (async - calls Rust).
|
* Get certificate status for a route (async - calls Rust).
|
||||||
*/
|
*/
|
||||||
public async getCertificateStatus(routeName: string): Promise<any> {
|
public async getCertificateStatus(routeName: string): Promise<IRustCertificateStatus | null> {
|
||||||
return this.bridge.getCertificateStatus(routeName);
|
return this.bridge.getCertificateStatus(routeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +381,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Get statistics (async - calls Rust).
|
* Get statistics (async - calls Rust).
|
||||||
*/
|
*/
|
||||||
public async getStatistics(): Promise<any> {
|
public async getStatistics(): Promise<IRustStatistics> {
|
||||||
return this.bridge.getStatistics();
|
return this.bridge.getStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,37 +486,8 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Build the Rust configuration object from TS settings.
|
* Build the Rust configuration object from TS settings.
|
||||||
*/
|
*/
|
||||||
private buildRustConfig(routes: IRouteConfig[], acmeOverride?: IAcmeOptions): any {
|
private buildRustConfig(routes: IRustProxyOptions['routes'], acmeOverride?: IAcmeOptions): IRustProxyOptions {
|
||||||
const acme = acmeOverride !== undefined ? acmeOverride : this.settings.acme;
|
return buildRustProxyOptions(this.settings, routes, acmeOverride);
|
||||||
return {
|
|
||||||
routes,
|
|
||||||
defaults: this.settings.defaults,
|
|
||||||
acme: acme
|
|
||||||
? {
|
|
||||||
enabled: acme.enabled,
|
|
||||||
email: acme.email,
|
|
||||||
useProduction: acme.useProduction,
|
|
||||||
port: acme.port,
|
|
||||||
renewThresholdDays: acme.renewThresholdDays,
|
|
||||||
autoRenew: acme.autoRenew,
|
|
||||||
renewCheckIntervalHours: acme.renewCheckIntervalHours,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
connectionTimeout: this.settings.connectionTimeout,
|
|
||||||
initialDataTimeout: this.settings.initialDataTimeout,
|
|
||||||
socketTimeout: this.settings.socketTimeout,
|
|
||||||
maxConnectionLifetime: this.settings.maxConnectionLifetime,
|
|
||||||
gracefulShutdownTimeout: this.settings.gracefulShutdownTimeout,
|
|
||||||
maxConnectionsPerIp: this.settings.maxConnectionsPerIP,
|
|
||||||
connectionRateLimitPerMinute: this.settings.connectionRateLimitPerMinute,
|
|
||||||
keepAliveTreatment: this.settings.keepAliveTreatment,
|
|
||||||
keepAliveInactivityMultiplier: this.settings.keepAliveInactivityMultiplier,
|
|
||||||
extendedKeepAliveLifetime: this.settings.extendedKeepAliveLifetime,
|
|
||||||
proxyIps: this.settings.proxyIPs,
|
|
||||||
acceptProxyProtocol: this.settings.acceptProxyProtocol,
|
|
||||||
sendProxyProtocol: this.settings.sendProxyProtocol,
|
|
||||||
metrics: this.settings.metrics,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -168,14 +168,28 @@ export function routeMatchesHeaders(
|
|||||||
if (!route.match?.headers || Object.keys(route.match.headers).length === 0) {
|
if (!route.match?.headers || Object.keys(route.match.headers).length === 0) {
|
||||||
return true; // No headers specified means it matches any headers
|
return true; // No headers specified means it matches any headers
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert RegExp patterns to strings for HeaderMatcher
|
for (const [headerName, expectedValue] of Object.entries(route.match.headers)) {
|
||||||
const stringHeaders: Record<string, string> = {};
|
const actualKey = Object.keys(headers).find((key) => key.toLowerCase() === headerName.toLowerCase());
|
||||||
for (const [key, value] of Object.entries(route.match.headers)) {
|
const actualValue = actualKey ? headers[actualKey] : undefined;
|
||||||
stringHeaders[key] = value instanceof RegExp ? value.source : value;
|
|
||||||
|
if (actualValue === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedValue instanceof RegExp) {
|
||||||
|
if (!expectedValue.test(actualValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HeaderMatcher.match(expectedValue, actualValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return HeaderMatcher.matchAll(stringHeaders, headers);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,4 +297,4 @@ export function generateRouteId(route: IRouteConfig): string {
|
|||||||
*/
|
*/
|
||||||
export function cloneRoute(route: IRouteConfig): IRouteConfig {
|
export function cloneRoute(route: IRouteConfig): IRouteConfig {
|
||||||
return JSON.parse(JSON.stringify(route));
|
return JSON.parse(JSON.stringify(route));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { IAcmeOptions, ISmartProxyOptions } from '../models/interfaces.js';
|
||||||
|
import type { IRouteAction, IRouteConfig, IRouteMatch, IRouteTarget, ITargetMatch } from '../models/route-types.js';
|
||||||
|
import type {
|
||||||
|
IRustAcmeOptions,
|
||||||
|
IRustDefaultConfig,
|
||||||
|
IRustProxyOptions,
|
||||||
|
IRustRouteAction,
|
||||||
|
IRustRouteConfig,
|
||||||
|
IRustRouteMatch,
|
||||||
|
IRustRouteTarget,
|
||||||
|
IRustTargetMatch,
|
||||||
|
IRustRouteUdp,
|
||||||
|
TRustHeaderMatchers,
|
||||||
|
} from '../models/rust-types.js';
|
||||||
|
|
||||||
|
const SUPPORTED_REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g']);
|
||||||
|
|
||||||
|
export function serializeHeaderMatchValue(value: string | RegExp): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupportedFlags = Array.from(new Set(value.flags)).filter((flag) => !SUPPORTED_REGEX_FLAGS.has(flag));
|
||||||
|
if (unsupportedFlags.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Header RegExp uses unsupported flags for Rust serialization: ${unsupportedFlags.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${value.source}/${value.flags}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeHeaderMatchers(headers?: Record<string, string | RegExp>): TRustHeaderMatchers | undefined {
|
||||||
|
if (!headers) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(headers).map(([key, value]) => [key, serializeHeaderMatchValue(value)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeTargetMatchForRust(match?: ITargetMatch): IRustTargetMatch | undefined {
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
headers: serializeHeaderMatchers(match.headers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRouteMatchForRust(match: IRouteMatch): IRustRouteMatch {
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
headers: serializeHeaderMatchers(match.headers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRouteTargetForRust(target: IRouteTarget): IRustRouteTarget {
|
||||||
|
if (typeof target.host !== 'string' && !Array.isArray(target.host)) {
|
||||||
|
throw new Error('Route target host must be serialized before sending to Rust');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof target.port !== 'number' && target.port !== 'preserve') {
|
||||||
|
throw new Error('Route target port must be serialized before sending to Rust');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...target,
|
||||||
|
host: target.host,
|
||||||
|
port: target.port,
|
||||||
|
match: serializeTargetMatchForRust(target.match),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeUdpForRust(udp?: IRouteAction['udp']): IRustRouteUdp | undefined {
|
||||||
|
if (!udp) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { maxSessionsPerIP, ...rest } = udp;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
maxSessionsPerIp: maxSessionsPerIP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRouteActionForRust(action: IRouteAction): IRustRouteAction {
|
||||||
|
const {
|
||||||
|
socketHandler: _socketHandler,
|
||||||
|
datagramHandler: _datagramHandler,
|
||||||
|
forwardingEngine: _forwardingEngine,
|
||||||
|
nftables: _nftables,
|
||||||
|
targets,
|
||||||
|
udp,
|
||||||
|
...rest
|
||||||
|
} = action;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
targets: targets?.map((target) => serializeRouteTargetForRust(target)),
|
||||||
|
udp: serializeUdpForRust(udp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRouteForRust(route: IRouteConfig): IRustRouteConfig {
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
match: serializeRouteMatchForRust(route.match),
|
||||||
|
action: serializeRouteActionForRust(route.action),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAcmeForRust(acme?: IAcmeOptions): IRustAcmeOptions | undefined {
|
||||||
|
if (!acme) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: acme.enabled,
|
||||||
|
email: acme.email,
|
||||||
|
environment: acme.environment,
|
||||||
|
accountEmail: acme.accountEmail,
|
||||||
|
port: acme.port,
|
||||||
|
useProduction: acme.useProduction,
|
||||||
|
renewThresholdDays: acme.renewThresholdDays,
|
||||||
|
autoRenew: acme.autoRenew,
|
||||||
|
skipConfiguredCerts: acme.skipConfiguredCerts,
|
||||||
|
renewCheckIntervalHours: acme.renewCheckIntervalHours,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeDefaultsForRust(defaults?: ISmartProxyOptions['defaults']): IRustDefaultConfig | undefined {
|
||||||
|
if (!defaults) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { preserveSourceIP, ...rest } = defaults;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
preserveSourceIp: preserveSourceIP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRustProxyOptions(
|
||||||
|
settings: ISmartProxyOptions,
|
||||||
|
routes: IRustRouteConfig[],
|
||||||
|
acmeOverride?: IAcmeOptions,
|
||||||
|
): IRustProxyOptions {
|
||||||
|
const acme = acmeOverride !== undefined ? acmeOverride : settings.acme;
|
||||||
|
|
||||||
|
return {
|
||||||
|
routes,
|
||||||
|
preserveSourceIp: settings.preserveSourceIP,
|
||||||
|
proxyIps: settings.proxyIPs,
|
||||||
|
acceptProxyProtocol: settings.acceptProxyProtocol,
|
||||||
|
sendProxyProtocol: settings.sendProxyProtocol,
|
||||||
|
defaults: serializeDefaultsForRust(settings.defaults),
|
||||||
|
connectionTimeout: settings.connectionTimeout,
|
||||||
|
initialDataTimeout: settings.initialDataTimeout,
|
||||||
|
socketTimeout: settings.socketTimeout,
|
||||||
|
inactivityCheckInterval: settings.inactivityCheckInterval,
|
||||||
|
maxConnectionLifetime: settings.maxConnectionLifetime,
|
||||||
|
inactivityTimeout: settings.inactivityTimeout,
|
||||||
|
gracefulShutdownTimeout: settings.gracefulShutdownTimeout,
|
||||||
|
noDelay: settings.noDelay,
|
||||||
|
keepAlive: settings.keepAlive,
|
||||||
|
keepAliveInitialDelay: settings.keepAliveInitialDelay,
|
||||||
|
maxPendingDataSize: settings.maxPendingDataSize,
|
||||||
|
disableInactivityCheck: settings.disableInactivityCheck,
|
||||||
|
enableKeepAliveProbes: settings.enableKeepAliveProbes,
|
||||||
|
enableDetailedLogging: settings.enableDetailedLogging,
|
||||||
|
enableTlsDebugLogging: settings.enableTlsDebugLogging,
|
||||||
|
enableRandomizedTimeouts: settings.enableRandomizedTimeouts,
|
||||||
|
maxConnectionsPerIp: settings.maxConnectionsPerIP,
|
||||||
|
connectionRateLimitPerMinute: settings.connectionRateLimitPerMinute,
|
||||||
|
keepAliveTreatment: settings.keepAliveTreatment,
|
||||||
|
keepAliveInactivityMultiplier: settings.keepAliveInactivityMultiplier,
|
||||||
|
extendedKeepAliveLifetime: settings.extendedKeepAliveLifetime,
|
||||||
|
metrics: settings.metrics,
|
||||||
|
acme: serializeAcmeForRust(acme),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user