Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af132f40fc | |||
| 781634446a | |||
| e988d935b6 | |||
| 99a026627d | |||
| 572e31587a | |||
| 8587fb997c | |||
| 9ba101c59b | |||
| 1ad3e61c15 |
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
add domain-scoped IP allow list support across HTTP and passthrough filtering
|
||||
|
||||
- extend route security types to accept IP allow entries scoped to specific domains
|
||||
- apply domain-aware IP checks using Host headers for HTTP and SNI context for QUIC and passthrough connections
|
||||
- preserve compatibility for existing plain allow list entries and add validation and tests for scoped matching
|
||||
|
||||
## 2026-04-04 - 27.4.0 - feat(rustproxy)
|
||||
add HTTP/3 proxy service wiring for QUIC listeners
|
||||
|
||||
- registers H3ProxyService with the UDP listener manager so QUIC connections can serve HTTP/3
|
||||
- keeps proxy IP configuration intact while enabling HTTP/3 handling during listener setup
|
||||
|
||||
## 2026-04-04 - 27.3.1 - fix(metrics)
|
||||
correct frontend and backend protocol connection tracking across h1, h2, h3, and websocket traffic
|
||||
|
||||
- move frontend protocol accounting from per-request to connection lifetime tracking for HTTP/1, HTTP/2, and HTTP/3
|
||||
- add backend protocol guards to connection drivers so active protocol metrics reflect live upstream connections
|
||||
- prevent protocol counter underflow by using atomic saturating decrements in the metrics collector
|
||||
- read backend protocol distribution directly from cached aggregate counters in the Rust metrics adapter
|
||||
|
||||
## 2026-04-04 - 27.3.0 - feat(test)
|
||||
add end-to-end WebSocket proxy test coverage
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
"npm:@push.rocks/smartserve@^2.0.3": "2.0.3",
|
||||
"npm:@tsclass/tsclass@^9.5.0": "9.5.0",
|
||||
"npm:@types/node@^25.5.0": "25.5.0",
|
||||
"npm:@types/ws@^8.18.1": "8.18.1",
|
||||
"npm:minimatch@^10.2.4": "10.2.4",
|
||||
"npm:typescript@^6.0.2": "6.0.2",
|
||||
"npm:why-is-node-running@^3.2.2": "3.2.2"
|
||||
"npm:why-is-node-running@^3.2.2": "3.2.2",
|
||||
"npm:ws@^8.20.0": "8.20.0"
|
||||
},
|
||||
"npm": {
|
||||
"@api.global/typedrequest-interfaces@2.0.2": {
|
||||
@@ -6743,9 +6745,11 @@
|
||||
"npm:@push.rocks/smartserve@^2.0.3",
|
||||
"npm:@tsclass/tsclass@^9.5.0",
|
||||
"npm:@types/node@^25.5.0",
|
||||
"npm:@types/ws@^8.18.1",
|
||||
"npm:minimatch@^10.2.4",
|
||||
"npm:typescript@^6.0.2",
|
||||
"npm:why-is-node-running@^3.2.2"
|
||||
"npm:why-is-node-running@^3.2.2",
|
||||
"npm:ws@^8.20.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "27.3.0",
|
||||
"version": "27.6.0",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
||||
@@ -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. |
|
||||
@@ -103,14 +103,30 @@ pub struct JwtAuthConfig {
|
||||
pub exclude_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// An entry in the IP allow list: either a plain IP/CIDR string
|
||||
/// or a domain-scoped entry that restricts the IP to specific domains.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum IpAllowEntry {
|
||||
/// Plain IP/CIDR — allowed for all domains on this route
|
||||
Plain(String),
|
||||
/// Domain-scoped — allowed only when the requested domain matches
|
||||
DomainScoped {
|
||||
ip: String,
|
||||
domains: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Security options for routes.
|
||||
/// Matches TypeScript: `IRouteSecurity`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteSecurity {
|
||||
/// IP addresses that are allowed to connect
|
||||
/// IP addresses that are allowed to connect.
|
||||
/// Entries can be plain strings (full route access) or objects with
|
||||
/// `{ ip, domains }` to scope access to specific domains.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ip_allow_list: Option<Vec<String>>,
|
||||
pub ip_allow_list: Option<Vec<IpAllowEntry>>,
|
||||
/// IP addresses that are blocked from connecting
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ip_block_list: Option<Vec<String>>,
|
||||
|
||||
@@ -18,7 +18,7 @@ use tracing::{debug, warn};
|
||||
use rustproxy_config::RouteConfig;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::proxy_service::{ConnActivity, HttpProxyService};
|
||||
use crate::proxy_service::{ConnActivity, HttpProxyService, ProtocolGuard};
|
||||
|
||||
/// HTTP/3 proxy service.
|
||||
///
|
||||
@@ -48,6 +48,9 @@ impl H3ProxyService {
|
||||
let remote_addr = real_client_addr.unwrap_or_else(|| connection.remote_address());
|
||||
debug!("HTTP/3 connection from {} on port {}", remote_addr, port);
|
||||
|
||||
// Track frontend H3 connection for the QUIC connection's lifetime.
|
||||
let _frontend_h3_guard = ProtocolGuard::frontend(Arc::clone(self.http_proxy.metrics()), "h3");
|
||||
|
||||
let mut h3_conn: h3::server::Connection<h3_quinn::Connection, Bytes> =
|
||||
h3::server::builder()
|
||||
.send_grease(false)
|
||||
|
||||
@@ -140,6 +140,38 @@ impl Drop for ProtocolGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection-level frontend protocol tracker.
|
||||
///
|
||||
/// In `handle_io`, the HTTP protocol (h1 vs h2) is unknown until the first request
|
||||
/// arrives. This struct uses `OnceLock` so the first request detects the protocol
|
||||
/// and opens the counter; subsequent requests on the same connection are no-ops.
|
||||
/// On Drop (when the connection ends), the counter is closed.
|
||||
pub(crate) struct FrontendProtocolTracker {
|
||||
metrics: Arc<MetricsCollector>,
|
||||
proto: std::sync::OnceLock<&'static str>,
|
||||
}
|
||||
|
||||
impl FrontendProtocolTracker {
|
||||
fn new(metrics: Arc<MetricsCollector>) -> Self {
|
||||
Self { metrics, proto: std::sync::OnceLock::new() }
|
||||
}
|
||||
|
||||
/// Set the frontend protocol. Only the first call opens the counter.
|
||||
fn set(&self, proto: &'static str) {
|
||||
if self.proto.set(proto).is_ok() {
|
||||
self.metrics.frontend_protocol_opened(proto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FrontendProtocolTracker {
|
||||
fn drop(&mut self) {
|
||||
if let Some(proto) = self.proto.get() {
|
||||
self.metrics.frontend_protocol_closed(proto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend stream that can be either plain TCP or TLS-wrapped.
|
||||
/// Used for `terminate-and-reencrypt` mode where the backend requires TLS.
|
||||
pub(crate) enum BackendStream {
|
||||
@@ -365,6 +397,11 @@ impl HttpProxyService {
|
||||
self.protocol_cache.snapshot()
|
||||
}
|
||||
|
||||
/// Access the shared metrics collector (used by H3ProxyService for protocol tracking).
|
||||
pub fn metrics(&self) -> &Arc<MetricsCollector> {
|
||||
&self.metrics
|
||||
}
|
||||
|
||||
/// Handle an incoming HTTP connection on a plain TCP stream.
|
||||
pub async fn handle_connection(
|
||||
self: Arc<Self>,
|
||||
@@ -409,10 +446,24 @@ impl HttpProxyService {
|
||||
let active_requests = Arc::new(AtomicU64::new(0));
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Connection-level frontend protocol tracker: the first request detects
|
||||
// h1 vs h2 from req.version() and opens the counter. On connection close
|
||||
// (when handle_io returns), Drop closes the counter.
|
||||
let frontend_tracker = Arc::new(FrontendProtocolTracker::new(Arc::clone(&self.metrics)));
|
||||
let ft_inner = Arc::clone(&frontend_tracker);
|
||||
|
||||
let la_inner = Arc::clone(&last_activity);
|
||||
let ar_inner = Arc::clone(&active_requests);
|
||||
let cancel_inner = cancel.clone();
|
||||
let service = hyper::service::service_fn(move |req: Request<Incoming>| {
|
||||
// Detect frontend protocol from the first request on this connection.
|
||||
// OnceLock ensures only the first call opens the counter.
|
||||
let proto: &'static str = match req.version() {
|
||||
hyper::Version::HTTP_2 => "h2",
|
||||
_ => "h1",
|
||||
};
|
||||
ft_inner.set(proto);
|
||||
|
||||
// Mark request start — RAII guard decrements on drop (panic-safe)
|
||||
la_inner.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
let req_guard = ActiveRequestGuard::new(Arc::clone(&ar_inner));
|
||||
@@ -575,6 +626,9 @@ impl HttpProxyService {
|
||||
let route_id = route_match.route.id.as_deref();
|
||||
let ip_str = ip_string; // reuse from above (avoid redundant to_string())
|
||||
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)
|
||||
if let Some(ref security) = route_match.route.security {
|
||||
@@ -655,17 +709,8 @@ impl HttpProxyService {
|
||||
.map(|p| p.as_str().eq_ignore_ascii_case("websocket"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Track frontend protocol for distribution metrics (h1/h2/h3/ws)
|
||||
let frontend_proto: &'static str = if is_h1_websocket || is_h2_websocket {
|
||||
"ws"
|
||||
} else {
|
||||
match req.version() {
|
||||
hyper::Version::HTTP_2 => "h2",
|
||||
hyper::Version::HTTP_3 => "h3",
|
||||
_ => "h1", // HTTP/1.0, HTTP/1.1
|
||||
}
|
||||
};
|
||||
let _frontend_proto_guard = ProtocolGuard::frontend(Arc::clone(&self.metrics), frontend_proto);
|
||||
// Frontend protocol is tracked at the connection level (handle_io / h3_service).
|
||||
// WebSocket tunnels additionally get their own "ws" guards in the spawned task.
|
||||
|
||||
if is_h1_websocket || is_h2_websocket {
|
||||
let result = self.handle_websocket_upgrade(
|
||||
@@ -1275,13 +1320,18 @@ impl HttpProxyService {
|
||||
}
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
|
||||
Ok(Err(e)) => debug!("Upstream connection error: {}", e),
|
||||
Err(_) => debug!("H1 connection driver timed out after 300s"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
{
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H1 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h1");
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
|
||||
Ok(Err(e)) => debug!("Upstream connection error: {}", e),
|
||||
Err(_) => debug!("H1 connection driver timed out after 300s"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.forward_h1_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, domain, conn_activity, backend_key).await
|
||||
}
|
||||
@@ -1402,7 +1452,10 @@ impl HttpProxyService {
|
||||
let pool = Arc::clone(&self.connection_pool);
|
||||
let key = pool_key.clone();
|
||||
let gen = Arc::clone(&gen_holder);
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H2 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h2");
|
||||
if let Err(e) = conn.await {
|
||||
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
|
||||
}
|
||||
@@ -1701,7 +1754,10 @@ impl HttpProxyService {
|
||||
let pool = Arc::clone(&self.connection_pool);
|
||||
let key = pool_key.clone();
|
||||
let gen = Arc::clone(&gen_holder);
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H2 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h2");
|
||||
if let Err(e) = conn.await {
|
||||
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
|
||||
}
|
||||
@@ -1871,13 +1927,18 @@ impl HttpProxyService {
|
||||
}
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
|
||||
Ok(Err(e)) => debug!("H1 fallback: upstream connection error: {}", e),
|
||||
Err(_) => debug!("H1 fallback: connection driver timed out after 300s"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
{
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H1 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h1");
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
|
||||
Ok(Err(e)) => debug!("H1 fallback: upstream connection error: {}", e),
|
||||
Err(_) => debug!("H1 fallback: connection driver timed out after 300s"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut upstream_req = Request::builder()
|
||||
.method(method)
|
||||
@@ -2425,7 +2486,10 @@ impl HttpProxyService {
|
||||
selector: upstream_selector,
|
||||
key: upstream_key_owned.clone(),
|
||||
};
|
||||
// Track backend WebSocket connection — guard decrements on tunnel close
|
||||
// Track WebSocket tunnel as "ws" on both frontend and backend.
|
||||
// Frontend h1/h2 is tracked at the connection level (handle_io); this
|
||||
// additional "ws" guard captures the tunnel's lifetime independently.
|
||||
let _frontend_ws_guard = ProtocolGuard::frontend(Arc::clone(&metrics), "ws");
|
||||
let _backend_ws_guard = ProtocolGuard::backend(Arc::clone(&metrics), "ws");
|
||||
|
||||
let client_upgraded = match on_client_upgrade.await {
|
||||
@@ -2889,7 +2953,10 @@ impl HttpProxyService {
|
||||
let driver_pool = Arc::clone(&self.connection_pool);
|
||||
let driver_pool_key = pool_key.clone();
|
||||
let driver_gen = Arc::clone(&gen_holder);
|
||||
let driver_metrics = Arc::clone(&self.metrics);
|
||||
tokio::spawn(async move {
|
||||
// Track backend H3 connection for the driver's lifetime
|
||||
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h3");
|
||||
let close_err = std::future::poll_fn(|cx| driver.poll_close(cx)).await;
|
||||
debug!("H3 connection driver closed: {:?}", close_err);
|
||||
let g = driver_gen.load(std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
@@ -35,13 +35,17 @@ impl RequestFilter {
|
||||
let client_ip = peer_addr.ip();
|
||||
let request_path = req.uri().path();
|
||||
|
||||
// IP filter
|
||||
// IP filter (domain-aware: extract Host header for domain-scoped entries)
|
||||
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
|
||||
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
|
||||
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
|
||||
let filter = IpFilter::new(allow, block);
|
||||
let normalized = IpFilter::normalize_ip(&client_ip);
|
||||
if !filter.is_allowed(&normalized) {
|
||||
let host = req.headers()
|
||||
.get("host")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|h| h.split(':').next().unwrap_or(h));
|
||||
if !filter.is_allowed_for_domain(&normalized, host) {
|
||||
return Some(error_response(StatusCode::FORBIDDEN, "Access denied"));
|
||||
}
|
||||
}
|
||||
@@ -203,14 +207,15 @@ impl RequestFilter {
|
||||
}
|
||||
|
||||
/// Check IP-based security (for use in passthrough / TCP-level connections).
|
||||
/// `domain` is the SNI from the TLS handshake (if available) for domain-scoped filtering.
|
||||
/// Returns true if allowed, false if blocked.
|
||||
pub fn check_ip_security(security: &RouteSecurity, client_ip: &std::net::IpAddr) -> bool {
|
||||
pub fn check_ip_security(security: &RouteSecurity, client_ip: &std::net::IpAddr, domain: Option<&str>) -> bool {
|
||||
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
|
||||
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
|
||||
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
|
||||
let filter = IpFilter::new(allow, block);
|
||||
let normalized = IpFilter::normalize_ip(client_ip);
|
||||
filter.is_allowed(&normalized)
|
||||
filter.is_allowed_for_domain(&normalized, domain)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
@@ -62,6 +62,8 @@ pub struct IpMetrics {
|
||||
pub bytes_out: u64,
|
||||
pub throughput_in_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").
|
||||
@@ -139,6 +141,9 @@ const MAX_IPS_IN_SNAPSHOT: usize = 100;
|
||||
/// Maximum number of backends to include in a snapshot (top by total connections).
|
||||
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.
|
||||
///
|
||||
/// 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_pending_tp: DashMap<String, (AtomicU64, AtomicU64)>,
|
||||
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") ──
|
||||
backend_active: DashMap<String, AtomicU64>,
|
||||
@@ -247,6 +256,7 @@ impl MetricsCollector {
|
||||
ip_bytes_out: DashMap::new(),
|
||||
ip_pending_tp: DashMap::new(),
|
||||
ip_throughput: DashMap::new(),
|
||||
ip_domain_requests: DashMap::new(),
|
||||
backend_active: DashMap::new(),
|
||||
backend_total: DashMap::new(),
|
||||
backend_protocol: DashMap::new(),
|
||||
@@ -351,6 +361,7 @@ impl MetricsCollector {
|
||||
self.ip_bytes_out.remove(ip);
|
||||
self.ip_pending_tp.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);
|
||||
}
|
||||
|
||||
/// 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 ──
|
||||
|
||||
/// Record a new UDP session opened.
|
||||
@@ -506,13 +548,14 @@ impl MetricsCollector {
|
||||
total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Record a frontend request/connection closed with a given protocol.
|
||||
/// Record a frontend connection closed with a given protocol.
|
||||
pub fn frontend_protocol_closed(&self, proto: &str) {
|
||||
let (active, _) = self.frontend_proto_counters(proto);
|
||||
let val = active.load(Ordering::Relaxed);
|
||||
if val > 0 {
|
||||
active.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
// Atomic saturating decrement — avoids TOCTOU race where concurrent
|
||||
// closes could both read val=1, both subtract, wrapping to u64::MAX.
|
||||
active.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
if v > 0 { Some(v - 1) } else { None }
|
||||
}).ok();
|
||||
}
|
||||
|
||||
/// Record a backend connection opened with a given protocol.
|
||||
@@ -525,10 +568,10 @@ impl MetricsCollector {
|
||||
/// Record a backend connection closed with a given protocol.
|
||||
pub fn backend_protocol_closed(&self, proto: &str) {
|
||||
let (active, _) = self.backend_proto_counters(proto);
|
||||
let val = active.load(Ordering::Relaxed);
|
||||
if val > 0 {
|
||||
active.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
// Atomic saturating decrement — see frontend_protocol_closed for rationale.
|
||||
active.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
if v > 0 { Some(v - 1) } else { None }
|
||||
}).ok();
|
||||
}
|
||||
|
||||
// ── Per-backend recording methods ──
|
||||
@@ -744,6 +787,7 @@ impl MetricsCollector {
|
||||
self.ip_pending_tp.retain(|k, _| self.ip_connections.contains_key(k));
|
||||
self.ip_throughput.retain(|k, _| self.ip_connections.contains_key(k));
|
||||
self.ip_total_connections.retain(|k, _| self.ip_connections.contains_key(k));
|
||||
self.ip_domain_requests.retain(|k, _| self.ip_connections.contains_key(k));
|
||||
|
||||
// Safety-net: prune orphaned backend error/stats entries for backends
|
||||
// that have no active or total connections (error-only backends).
|
||||
@@ -851,7 +895,7 @@ impl MetricsCollector {
|
||||
|
||||
// Collect per-IP metrics — only IPs with active connections or total > 0,
|
||||
// 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() {
|
||||
let ip = entry.key().clone();
|
||||
let total = entry.value().load(Ordering::Relaxed);
|
||||
@@ -871,14 +915,23 @@ impl MetricsCollector {
|
||||
.get(&ip)
|
||||
.and_then(|entry| entry.value().lock().ok().map(|t| t.instant()))
|
||||
.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
|
||||
ip_entries.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
ip_entries.truncate(MAX_IPS_IN_SNAPSHOT);
|
||||
|
||||
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 {
|
||||
active_connections: active,
|
||||
total_connections: total,
|
||||
@@ -886,6 +939,7 @@ impl MetricsCollector {
|
||||
bytes_out,
|
||||
throughput_in_bytes_per_sec: tp_in,
|
||||
throughput_out_bytes_per_sec: tp_out,
|
||||
domain_requests,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ impl ConnectionRegistry {
|
||||
let mut recycled = 0u64;
|
||||
self.connections.retain(|_, entry| {
|
||||
if entry.route_id.as_deref() == Some(route_id) {
|
||||
if !RequestFilter::check_ip_security(new_security, &entry.source_ip) {
|
||||
if !RequestFilter::check_ip_security(new_security, &entry.source_ip, entry.domain.as_deref()) {
|
||||
info!(
|
||||
"Terminating connection from {} — IP now blocked on route '{}'",
|
||||
entry.source_ip, route_id
|
||||
|
||||
@@ -409,10 +409,10 @@ pub async fn quic_accept_loop(
|
||||
}
|
||||
};
|
||||
|
||||
// Check route-level IP security (previously missing for QUIC)
|
||||
// Check route-level IP security for QUIC (domain from SNI context)
|
||||
if let Some(ref security) = route.security {
|
||||
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
|
||||
security, &ip,
|
||||
security, &ip, ctx.domain,
|
||||
) {
|
||||
debug!("QUIC connection from {} blocked by route security", real_addr);
|
||||
continue;
|
||||
|
||||
@@ -737,10 +737,10 @@ impl TcpListenerManager {
|
||||
},
|
||||
);
|
||||
|
||||
// Check route-level IP security
|
||||
// Check route-level IP security (fast path: no SNI available)
|
||||
if let Some(ref security) = quick_match.route.security {
|
||||
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
|
||||
security, &peer_addr.ip(),
|
||||
security, &peer_addr.ip(), None,
|
||||
) {
|
||||
warn!("Connection from {} blocked by route security", peer_addr);
|
||||
return Ok(());
|
||||
@@ -929,11 +929,12 @@ impl TcpListenerManager {
|
||||
},
|
||||
);
|
||||
|
||||
// Check route-level IP security for passthrough connections
|
||||
// Check route-level IP security for passthrough connections (SNI available)
|
||||
if let Some(ref security) = route_match.route.security {
|
||||
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
|
||||
security,
|
||||
&peer_addr.ip(),
|
||||
domain.as_deref(),
|
||||
) {
|
||||
warn!("Connection from {} blocked by route security", peer_addr);
|
||||
return Ok(());
|
||||
@@ -942,6 +943,9 @@ impl TcpListenerManager {
|
||||
|
||||
// Track connection in metrics — guard ensures connection_closed on all exit paths
|
||||
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));
|
||||
|
||||
// Check if this is a socket-handler route that should be relayed to TypeScript
|
||||
|
||||
@@ -2,12 +2,24 @@ use ipnet::IpNet;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use rustproxy_config::IpAllowEntry;
|
||||
|
||||
/// IP filter supporting CIDR ranges, wildcards, and exact matches.
|
||||
/// Supports domain-scoped allow entries that restrict an IP to specific domains.
|
||||
pub struct IpFilter {
|
||||
/// Plain allow entries — IP allowed for any domain on the route
|
||||
allow_list: Vec<IpPattern>,
|
||||
/// Domain-scoped allow entries — IP allowed only for matching domains
|
||||
domain_scoped: Vec<DomainScopedEntry>,
|
||||
block_list: Vec<IpPattern>,
|
||||
}
|
||||
|
||||
/// A domain-scoped allow entry: IP + list of allowed domain patterns.
|
||||
struct DomainScopedEntry {
|
||||
pattern: IpPattern,
|
||||
domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// Represents an IP pattern for matching.
|
||||
#[derive(Debug)]
|
||||
enum IpPattern {
|
||||
@@ -31,10 +43,6 @@ impl IpPattern {
|
||||
if let Ok(addr) = IpAddr::from_str(s) {
|
||||
return IpPattern::Exact(addr);
|
||||
}
|
||||
// Try as CIDR by appending default prefix
|
||||
if let Ok(addr) = IpAddr::from_str(s) {
|
||||
return IpPattern::Exact(addr);
|
||||
}
|
||||
// Fallback: treat as exact, will never match an invalid string
|
||||
IpPattern::Exact(IpAddr::from_str("0.0.0.0").unwrap())
|
||||
}
|
||||
@@ -48,19 +56,56 @@ impl IpPattern {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple domain pattern matching (exact, `*`, or `*.suffix`).
|
||||
fn domain_matches_pattern(pattern: &str, domain: &str) -> bool {
|
||||
let p = pattern.trim();
|
||||
let d = domain.trim();
|
||||
if p == "*" {
|
||||
return true;
|
||||
}
|
||||
if p.eq_ignore_ascii_case(d) {
|
||||
return true;
|
||||
}
|
||||
if p.starts_with("*.") {
|
||||
let suffix = &p[1..]; // e.g., ".abc.xyz"
|
||||
d.len() > suffix.len()
|
||||
&& d[d.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl IpFilter {
|
||||
/// Create a new IP filter from allow and block lists.
|
||||
pub fn new(allow_list: &[String], block_list: &[String]) -> Self {
|
||||
/// Create a new IP filter from allow entries and a block list.
|
||||
pub fn new(allow_entries: &[IpAllowEntry], block_list: &[String]) -> Self {
|
||||
let mut allow_list = Vec::new();
|
||||
let mut domain_scoped = Vec::new();
|
||||
|
||||
for entry in allow_entries {
|
||||
match entry {
|
||||
IpAllowEntry::Plain(ip) => {
|
||||
allow_list.push(IpPattern::parse(ip));
|
||||
}
|
||||
IpAllowEntry::DomainScoped { ip, domains } => {
|
||||
domain_scoped.push(DomainScopedEntry {
|
||||
pattern: IpPattern::parse(ip),
|
||||
domains: domains.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
allow_list: allow_list.iter().map(|s| IpPattern::parse(s)).collect(),
|
||||
allow_list,
|
||||
domain_scoped,
|
||||
block_list: block_list.iter().map(|s| IpPattern::parse(s)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an IP is allowed.
|
||||
/// If allow_list is non-empty, IP must match at least one entry.
|
||||
/// If block_list is non-empty, IP must NOT match any entry.
|
||||
pub fn is_allowed(&self, ip: &IpAddr) -> bool {
|
||||
/// Check if an IP is allowed, considering domain-scoped entries.
|
||||
/// If `domain` is Some, domain-scoped entries are evaluated against it.
|
||||
/// If `domain` is None, only plain allow entries are considered.
|
||||
pub fn is_allowed_for_domain(&self, ip: &IpAddr, domain: Option<&str>) -> bool {
|
||||
// Check block list first
|
||||
if !self.block_list.is_empty() {
|
||||
for pattern in &self.block_list {
|
||||
@@ -70,14 +115,36 @@ impl IpFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// If allow list is non-empty, must match at least one
|
||||
if !self.allow_list.is_empty() {
|
||||
return self.allow_list.iter().any(|p| p.matches(ip));
|
||||
// If there are any allow entries (plain or domain-scoped), IP must match
|
||||
let has_any_allow = !self.allow_list.is_empty() || !self.domain_scoped.is_empty();
|
||||
if has_any_allow {
|
||||
// Check plain allow list — grants access to entire route
|
||||
if self.allow_list.iter().any(|p| p.matches(ip)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check domain-scoped entries — grants access only if domain matches
|
||||
if let Some(req_domain) = domain {
|
||||
for entry in &self.domain_scoped {
|
||||
if entry.pattern.matches(ip) {
|
||||
if entry.domains.iter().any(|d| domain_matches_pattern(d, req_domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if an IP is allowed (backwards-compat wrapper, no domain context).
|
||||
pub fn is_allowed(&self, ip: &IpAddr) -> bool {
|
||||
self.is_allowed_for_domain(ip, None)
|
||||
}
|
||||
|
||||
/// Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x -> x.x.x.x)
|
||||
pub fn normalize_ip(ip: &IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
@@ -97,19 +164,28 @@ impl IpFilter {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn plain(s: &str) -> IpAllowEntry {
|
||||
IpAllowEntry::Plain(s.to_string())
|
||||
}
|
||||
|
||||
fn scoped(ip: &str, domains: &[&str]) -> IpAllowEntry {
|
||||
IpAllowEntry::DomainScoped {
|
||||
ip: ip.to_string(),
|
||||
domains: domains.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_lists_allow_all() {
|
||||
let filter = IpFilter::new(&[], &[]);
|
||||
let ip: IpAddr = "192.168.1.1".parse().unwrap();
|
||||
assert!(filter.is_allowed(&ip));
|
||||
assert!(filter.is_allowed_for_domain(&ip, Some("example.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_list_exact() {
|
||||
let filter = IpFilter::new(
|
||||
&["10.0.0.1".to_string()],
|
||||
&[],
|
||||
);
|
||||
fn test_plain_allow_list_exact() {
|
||||
let filter = IpFilter::new(&[plain("10.0.0.1")], &[]);
|
||||
let allowed: IpAddr = "10.0.0.1".parse().unwrap();
|
||||
let denied: IpAddr = "10.0.0.2".parse().unwrap();
|
||||
assert!(filter.is_allowed(&allowed));
|
||||
@@ -117,11 +193,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_list_cidr() {
|
||||
let filter = IpFilter::new(
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[],
|
||||
);
|
||||
fn test_plain_allow_list_cidr() {
|
||||
let filter = IpFilter::new(&[plain("10.0.0.0/8")], &[]);
|
||||
let allowed: IpAddr = "10.255.255.255".parse().unwrap();
|
||||
let denied: IpAddr = "192.168.1.1".parse().unwrap();
|
||||
assert!(filter.is_allowed(&allowed));
|
||||
@@ -130,10 +203,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_block_list() {
|
||||
let filter = IpFilter::new(
|
||||
&[],
|
||||
&["192.168.1.100".to_string()],
|
||||
);
|
||||
let filter = IpFilter::new(&[], &["192.168.1.100".to_string()]);
|
||||
let blocked: IpAddr = "192.168.1.100".parse().unwrap();
|
||||
let allowed: IpAddr = "192.168.1.101".parse().unwrap();
|
||||
assert!(!filter.is_allowed(&blocked));
|
||||
@@ -143,7 +213,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_block_trumps_allow() {
|
||||
let filter = IpFilter::new(
|
||||
&["10.0.0.0/8".to_string()],
|
||||
&[plain("10.0.0.0/8")],
|
||||
&["10.0.0.5".to_string()],
|
||||
);
|
||||
let blocked: IpAddr = "10.0.0.5".parse().unwrap();
|
||||
@@ -154,20 +224,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_allow() {
|
||||
let filter = IpFilter::new(
|
||||
&["*".to_string()],
|
||||
&[],
|
||||
);
|
||||
let filter = IpFilter::new(&[plain("*")], &[]);
|
||||
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
||||
assert!(filter.is_allowed(&ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_block() {
|
||||
let filter = IpFilter::new(
|
||||
&[],
|
||||
&["*".to_string()],
|
||||
);
|
||||
let filter = IpFilter::new(&[], &["*".to_string()]);
|
||||
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
||||
assert!(!filter.is_allowed(&ip));
|
||||
}
|
||||
@@ -186,4 +250,97 @@ mod tests {
|
||||
let normalized = IpFilter::normalize_ip(&ip);
|
||||
assert_eq!(normalized, ip);
|
||||
}
|
||||
|
||||
// Domain-scoped tests
|
||||
|
||||
#[test]
|
||||
fn test_domain_scoped_allows_matching_domain() {
|
||||
let filter = IpFilter::new(
|
||||
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
|
||||
&[],
|
||||
);
|
||||
let ip: IpAddr = "10.8.0.2".parse().unwrap();
|
||||
assert!(filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_scoped_denies_non_matching_domain() {
|
||||
let filter = IpFilter::new(
|
||||
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
|
||||
&[],
|
||||
);
|
||||
let ip: IpAddr = "10.8.0.2".parse().unwrap();
|
||||
assert!(!filter.is_allowed_for_domain(&ip, Some("app.abc.xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_scoped_denies_without_domain() {
|
||||
let filter = IpFilter::new(
|
||||
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
|
||||
&[],
|
||||
);
|
||||
let ip: IpAddr = "10.8.0.2".parse().unwrap();
|
||||
// Without domain context, domain-scoped entries cannot match
|
||||
assert!(!filter.is_allowed_for_domain(&ip, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_scoped_wildcard_domain() {
|
||||
let filter = IpFilter::new(
|
||||
&[scoped("10.8.0.2", &["*.abc.xyz"])],
|
||||
&[],
|
||||
);
|
||||
let ip: IpAddr = "10.8.0.2".parse().unwrap();
|
||||
assert!(filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
|
||||
assert!(filter.is_allowed_for_domain(&ip, Some("app.abc.xyz")));
|
||||
assert!(!filter.is_allowed_for_domain(&ip, Some("other.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plain_and_domain_scoped_coexist() {
|
||||
let filter = IpFilter::new(
|
||||
&[
|
||||
plain("1.2.3.4"), // full route access
|
||||
scoped("10.8.0.2", &["outline.abc.xyz"]), // scoped access
|
||||
],
|
||||
&[],
|
||||
);
|
||||
|
||||
let admin: IpAddr = "1.2.3.4".parse().unwrap();
|
||||
let vpn: IpAddr = "10.8.0.2".parse().unwrap();
|
||||
let other: IpAddr = "9.9.9.9".parse().unwrap();
|
||||
|
||||
// Admin IP has full access
|
||||
assert!(filter.is_allowed_for_domain(&admin, Some("anything.abc.xyz")));
|
||||
assert!(filter.is_allowed_for_domain(&admin, Some("outline.abc.xyz")));
|
||||
|
||||
// VPN IP only has scoped access
|
||||
assert!(filter.is_allowed_for_domain(&vpn, Some("outline.abc.xyz")));
|
||||
assert!(!filter.is_allowed_for_domain(&vpn, Some("app.abc.xyz")));
|
||||
|
||||
// Unknown IP denied
|
||||
assert!(!filter.is_allowed_for_domain(&other, Some("outline.abc.xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_trumps_domain_scoped() {
|
||||
let filter = IpFilter::new(
|
||||
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
|
||||
&["10.8.0.2".to_string()],
|
||||
);
|
||||
let ip: IpAddr = "10.8.0.2".parse().unwrap();
|
||||
assert!(!filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_matches_pattern_fn() {
|
||||
assert!(domain_matches_pattern("example.com", "example.com"));
|
||||
assert!(domain_matches_pattern("*.abc.xyz", "outline.abc.xyz"));
|
||||
assert!(domain_matches_pattern("*.abc.xyz", "app.abc.xyz"));
|
||||
assert!(!domain_matches_pattern("*.abc.xyz", "abc.xyz")); // suffix only, not exact parent
|
||||
assert!(domain_matches_pattern("*", "anything.com"));
|
||||
assert!(!domain_matches_pattern("outline.abc.xyz", "app.abc.xyz"));
|
||||
// Case insensitive
|
||||
assert!(domain_matches_pattern("*.ABC.XYZ", "outline.abc.xyz"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,9 @@ impl RustProxy {
|
||||
};
|
||||
|
||||
if let Some(ref allow_list) = default_security.ip_allow_list {
|
||||
security.ip_allow_list = Some(allow_list.clone());
|
||||
security.ip_allow_list = Some(
|
||||
allow_list.iter().map(|s| rustproxy_config::IpAllowEntry::Plain(s.clone())).collect()
|
||||
);
|
||||
}
|
||||
if let Some(ref block_list) = default_security.ip_block_list {
|
||||
security.ip_block_list = Some(block_list.clone());
|
||||
@@ -845,6 +847,10 @@ impl RustProxy {
|
||||
connection_registry,
|
||||
);
|
||||
udp_mgr.set_proxy_ips(conn_config.proxy_ips);
|
||||
// Wire up H3ProxyService so QUIC connections can serve HTTP/3
|
||||
let http_proxy = listener.http_proxy().clone();
|
||||
let h3_svc = rustproxy_http::h3_service::H3ProxyService::new(http_proxy);
|
||||
udp_mgr.set_h3_service(std::sync::Arc::new(h3_svc));
|
||||
self.udp_listener_manager = Some(udp_mgr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '27.3.0',
|
||||
version: '27.6.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.'
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export interface IMetrics {
|
||||
byRoute(): Map<string, number>;
|
||||
byIP(): Map<string, 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;
|
||||
backendProtocols(): IProtocolDistribution;
|
||||
};
|
||||
|
||||
@@ -141,8 +141,10 @@ export interface IRouteAuthentication {
|
||||
* Security options for routes
|
||||
*/
|
||||
export interface IRouteSecurity {
|
||||
// Access control lists
|
||||
ipAllowList?: string[]; // IP addresses that are allowed to connect
|
||||
// Access control lists.
|
||||
// Entries can be plain IP/CIDR strings (full route access) or
|
||||
// objects { ip, domains } to scope access to specific domains on this route.
|
||||
ipAllowList?: Array<string | { ip: string; domains: string[] }>;
|
||||
ipBlockList?: string[]; // IP addresses that are blocked from connecting
|
||||
|
||||
// Connection limits
|
||||
|
||||
@@ -90,6 +90,39 @@ export class RustMetricsAdapter implements IMetrics {
|
||||
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)) {
|
||||
const dr = (im as any).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)) {
|
||||
const dr = (im as any).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);
|
||||
return result.slice(0, limit);
|
||||
},
|
||||
frontendProtocols: (): IProtocolDistribution => {
|
||||
const fp = this.cache?.frontendProtocols;
|
||||
return {
|
||||
@@ -106,27 +139,14 @@ export class RustMetricsAdapter implements IMetrics {
|
||||
};
|
||||
},
|
||||
backendProtocols: (): IProtocolDistribution => {
|
||||
// Merge per-backend h1/h2/h3 data with aggregate ws/other counters
|
||||
const bp = this.cache?.backendProtocols;
|
||||
let h1Active = 0, h1Total = 0;
|
||||
let h2Active = 0, h2Total = 0;
|
||||
let h3Active = 0, h3Total = 0;
|
||||
if (this.cache?.backends) {
|
||||
for (const bm of Object.values(this.cache.backends)) {
|
||||
const m = bm as any;
|
||||
const active = m.activeConnections ?? 0;
|
||||
const total = m.totalConnections ?? 0;
|
||||
switch (m.protocol) {
|
||||
case 'h2': h2Active += active; h2Total += total; break;
|
||||
case 'h3': h3Active += active; h3Total += total; break;
|
||||
default: h1Active += active; h1Total += total; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
h1Active, h1Total,
|
||||
h2Active, h2Total,
|
||||
h3Active, h3Total,
|
||||
h1Active: bp?.h1Active ?? 0,
|
||||
h1Total: bp?.h1Total ?? 0,
|
||||
h2Active: bp?.h2Active ?? 0,
|
||||
h2Total: bp?.h2Total ?? 0,
|
||||
h3Active: bp?.h3Active ?? 0,
|
||||
h3Total: bp?.h3Total ?? 0,
|
||||
wsActive: bp?.wsActive ?? 0,
|
||||
wsTotal: bp?.wsTotal ?? 0,
|
||||
otherActive: bp?.otherActive ?? 0,
|
||||
|
||||
@@ -196,10 +196,19 @@ export class RouteValidator {
|
||||
// Validate IP allow/block lists
|
||||
if (route.security.ipAllowList) {
|
||||
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
|
||||
|
||||
for (const ip of allowList) {
|
||||
if (!this.isValidIPPattern(ip)) {
|
||||
errors.push(`Invalid IP pattern in allow list: ${ip}`);
|
||||
|
||||
for (const entry of allowList) {
|
||||
if (typeof entry === 'string') {
|
||||
if (!this.isValidIPPattern(entry)) {
|
||||
errors.push(`Invalid IP pattern in allow list: ${entry}`);
|
||||
}
|
||||
} else if (entry && typeof entry === 'object') {
|
||||
if (!this.isValidIPPattern(entry.ip)) {
|
||||
errors.push(`Invalid IP pattern in domain-scoped allow entry: ${entry.ip}`);
|
||||
}
|
||||
if (!Array.isArray(entry.domains) || entry.domains.length === 0) {
|
||||
errors.push(`Domain-scoped allow entry for ${entry.ip} must have non-empty domains array`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user