This commit is contained in:
Mateusz Gruszczyński committed 2026-10-13 00:00:06 +02:00
1 parent d23fa5e154
commit 81ab39e70a
51 files changed
+5352 -1489

No files matched your search

+108 -42
View File
@@ -1,61 +1,127 @@
# TCP/UDP streams
# TCP/UDP Stream pools (v0.32.0)
RustProxy 0.13.0 exposes raw Layer-4 forwarding on the main **Streams** page, alongside **Vhosts** and **Redirects**. A stream combines one dedicated TCP or UDP listener with one upstream. Stream traffic does **not** enter the HTTP pipeline: no HTTP vhost routing, ACL, cache, headers, compression, Tinyauth or TLS termination is applied.
Layer-4 forwarding runs on dedicated TCP/UDP listeners and never traverses HTTP Vhosts, ACL, cache, headers, compression, TLS termination or authentication. The existing **Streams** page provides a card/modal editor and per-Stream RX/TX chart. Changes to Stream listeners and backend pool configuration remain **desired state** until the next controlled RustProxy restart; saving Appearance preferences does not restart the proxy.
## Dashboard workflow
## Compatible configuration
Use **Streams > New stream** or **Add TCP/UDP listener**. The modal mirrors the Vhost workflow and contains Details, Listener & upstream, and Limits tabs. Existing streams are shown as searchable cards with live runtime counters and RX/TX rate charts.
The legacy document still works without modification:
A saved listener change is desired state only until RustProxy restarts. The page keeps the active startup listener visible and marks changed/new/removed stream configuration as pending restart.
```json
{"streams":[{"id":"ssh","name":"SSH relay","enabled":true,"protocol":"tcp","listen":"0.0.0.0:2222","upstream":"10.0.0.20:22","connect_timeout_seconds":5,"idle_timeout_seconds":300,"max_connections":512}]}
```
The HTTP/HTTPS listener editor remains under **Settings > Listeners**. Its bind addresses use the same chip-style interaction as vhost hostnames. Those named HTTP/HTTPS listeners are separate from stream listeners.
Without `backends`, `upstream` is treated as one enabled backend with ID `primary`, weight 1, no active health probe and detailed metrics disabled by default (opt in explicitly). The legacy field is retained in serialized configuration and is set to the first backend address when a pool is explicitly configured. Existing listeners continue to require restart on change.
## Configuration document
A TCP pool can be configured as follows (the same backend fields apply to UDP):
```json
{
"streams": [
{
"id": "ssh",
"name": "SSH relay",
"enabled": true,
"protocol": "tcp",
"listen": "0.0.0.0:2222",
"upstream": "10.0.0.20:22",
"connect_timeout_seconds": 5,
"idle_timeout_seconds": 300,
"max_connections": 512
},
{
"id": "dns-udp",
"name": "DNS UDP",
"enabled": true,
"protocol": "udp",
"listen": "0.0.0.0:5353",
"upstream": "10.0.0.53:53",
"connect_timeout_seconds": 5,
"idle_timeout_seconds": 30,
"max_connections": 2048
}
"id":"ssh", "name":"SSH pool", "enabled":true, "protocol":"tcp",
"listen":"0.0.0.0:2222", "upstream":"10.0.0.20:22",
"balance":"round_robin", "metrics_enabled":true,
"connect_timeout_seconds":5, "idle_timeout_seconds":300, "max_connections":512,
"backends":[
{"id":"node-a","name":"Server A","address":"10.0.0.20:22","enabled":true,"drain":false,"weight":2,"max_sessions":256,"metrics_enabled":true,"health":{"mode":"tcp_connect","interval_seconds":10,"timeout_ms":1000,"rise":2,"fall":2}},
{"id":"node-b","name":"Server B","address":"10.0.0.21:22","enabled":true,"drain":false,"weight":1,"max_sessions":256,"metrics_enabled":false,"health":{"mode":"tcp_connect","interval_seconds":10,"timeout_ms":1000,"rise":2,"fall":2}}
]
}
```
TCP uses a bidirectional byte relay with a connect timeout, inactivity timeout and connection limit. UDP creates a bounded per-client session mapped to one connected upstream socket, expires it after inactivity and limits concurrent sessions.
`balance` is `round_robin` (default), `least_connections` (TCP), or `least_sessions` (UDP). The least-load variants use an active-assignment-to-weight ratio. Each new TCP connection selects one backend for its entire connection; a UDP client session selects once and remains pinned for its lifetime (idle timeout). `drain: true`, `enabled: false`, `Unhealthy` state, or a reached `max_sessions` prevents new assignments. In-flight TCP relays and UDP sessions retain their selected backend; a controlled process restart still follows the existing shutdown grace period and may end in-flight sessions.
## Runtime telemetry
## Health monitoring
The administration status snapshot exposes fixed-cardinality metrics for active startup streams. The Streams page displays:
Each backend has independent `enabled`, `health` and `metrics_enabled` controls. Disabled and draining backends can continue independent health monitoring if probes are explicitly configured; it does not get new sessions. Probe concurrency is globally limited to 16, at most one health task per enabled/configured backend, with lifecycle tied to the listener cancellation token. Timeouts, probe intervals and rise/fall thresholds are bounded by validation. TCP checks use a timed TCP connect without application payload.
- active TCP connections or UDP client sessions;
- accepted connections/sessions and completed sessions;
- capacity rejects and relay/upstream errors;
- bytes received from clients and returned from upstreams;
- UDP packet counters;
- live RX/TX rates derived from monotonic backend byte counters.
UDP checks cannot use a local UDP `connect()` as evidence that the remote service exists. For a validated active UDP probe define both `payload_hex` and an exact, nonempty `expect_hex` response (up to 128 bytes each), e.g.:
The chart history exists only in the current browser panel session. Backend counters are process-lifetime counters and reset on process restart.
```json
{"mode":"udp_probe","interval_seconds":10,"timeout_ms":1000,"rise":2,"fall":2,"payload_hex":"50494e47","expect_hex":"504f4e47"}
```
## Listener safety
**Do not enable UDP probing without the service owner's approval.** Unknown application protocols must leave checks `off`; `udp_probe` without an expected reply is `Unverified`, sends no datagrams and does not report Healthy. A backend without active checks is `Unverified` (routing still allowed), while probed backends show `Checking`, `Healthy`, `Unhealthy` or `Disabled`; hysteresis prevents state flapping. An Unhealthy backend stops receiving new sessions, but no ongoing TCP connection is reassigned. If the pool has no eligible backend, new sessions or datagrams are rejected and the legacy per-Stream `rejected` counter advances.
RustProxy validates collisions before publishing configuration. TCP stream listeners may not overlap HTTP/TLS, administration or Prometheus TCP listeners. UDP stream listeners may not overlap HTTP/3 UDP listeners. TCP and UDP may intentionally use the same numeric port because they are different transports. Direct loops into another RustProxy listener of the same transport are rejected.
## Telemetry and Dashboard
The existing `/api/status` response and `/api/status?view=live` SSE/fallback add a bounded `backends` array under `streams.metrics[stream_id]`. It includes stable configured IDs, status, active sessions, health counts/last errors and (if enabled) optional completed/created/assignment, bytes, packets and error/reject metrics. `bytes_in` remains client → upstream, `bytes_out` upstream → client. A UDP datagram rejected before a session is established remains counted in the original per-Stream incoming counters, not backend delivered-byte counters.
In the editor, **Stream → Detailed metrics → Default (disabled)** means the absence of a Stream override resolves to `false` (it does **not** inherit from Prometheus). At the backend level, **Use Stream setting** resolves to that Stream's value; backend **Enabled**/**Disabled** override it. Prometheus retains `include_streams` as the separate global export gate. `metrics_enabled` defaults to `false` for detailed per-backend accounting (no new overhead on legacy streams). Stream-level `true` enables the default for its backends; backend-level `true`/`false` explicitly overrides the Stream value, while `null` inherits. This choice never changes session routing or health checks. Basic per-Stream counters and backend active gauges stay available. Backend label cardinality is bounded to IDs declared in config, never client IPs. History is a limited browser-memory window only; metrics are not persisted as periodic SQLite samples.
Under **Settings → Appearance → Dashboard layout**, toggle/reorder Stream overview, traffic, sessions and backend-health cards alongside existing telemetry cards. All Stream cards default hidden for existing accounts, including migrated 0.31.x layouts. An explicitly enabled card stays visible even when no Stream is configured, enabled or running; it displays a neutral **No stream data** state in that case rather than ignoring the Appearance setting. They use the existing API status/SSE/fallback and one dashboard refresh scheduler. Hiding a card avoids its chart computations, and changing visibility does not alter Stream runtime, existing widgets or global Prometheus settings. Widget descriptions in Appearance appear on hover/keyboard focus rather than permanently beneath settings labels. On the main Overview page, secondary values and RX/TX chart legend are always inline under the metric/graph (not tooltips), with the original six-column desktop layout and taller tiles for readability.
## Safety and operational limits
At most 128 Stream hosts and 32 backends per host, weights 1–16, 1–65535 stream/session concurrency and optional per-backend session caps. Validated concrete backend IPs and nonzero ports are required. Duplicate backend IDs, local listener loops, conflicting transport health modes and invalid probes are rejected before configuration is published. UDP client queues are bounded at 64 datagrams. The existing listener restart indicator remains authoritative, and no new public administration routes or polling tasks are added.
## Standalone TCP and UDP forwarding tests
The ready-to-use launcher and individual TCP/UDP test clients are all in `scripts/`.
Start four loopback-only mock backends (TCP 9101/9102; UDP 9201/9202):
```bash
./scripts/start_stream_backends.sh
# Optional, only on an isolated test network: ./scripts/start_stream_backends.sh 0.0.0.0
```
Point your corresponding Stream backend pools at these ports, restart RustProxy
(the Stream configuration's existing restart requirement), then run:
```bash
python3 scripts/test_stream_tcp.py client --port 2222 --sessions 32 --messages 5 --min-backends 2
python3 scripts/test_stream_udp.py client --port 5353 --sessions 32 --messages 5 --min-backends 2
```
Stop the launcher with `Ctrl+C`. Its four children are stopped together. If you
prefer launching backend peers separately or need different ports, use the examples below.
These are **black-box clients for a real running RustProxy** plus small, intentionally
loopback-only mock services. Do not aim them at live/production backends. Use
separate terminals for each mock, and configure the Stream editor with these
backend addresses. **Restart RustProxy after modifying Stream backends** (the
existing Stream lifecycle still applies).
TCP example (Stream listener `127.0.0.1:2222`, backend pool `127.0.0.1:9121` and
`127.0.0.1:9122`):
```bash
# Terminal 1
python3 scripts/test_stream_tcp.py server --port 9121 --backend-id node-a
# Terminal 2
python3 scripts/test_stream_tcp.py server --port 9122 --backend-id node-b
# Terminal 3 - after enabling the TCP Stream listener in RustProxy
python3 scripts/test_stream_tcp.py client --port 2222 --sessions 32 --messages 5 --min-backends 2
```
UDP example (Stream listener `127.0.0.1:5353`, pool `127.0.0.1:9131` and
`127.0.0.1:9132`):
```bash
# Terminal 1
python3 scripts/test_stream_udp.py server --port 9131 --backend-id node-a
# Terminal 2
python3 scripts/test_stream_udp.py server --port 9132 --backend-id node-b
# Terminal 3 - after enabling the UDP Stream listener in RustProxy
python3 scripts/test_stream_udp.py client --port 5353 --sessions 32 --messages 5 --min-backends 2
```
Each client verifies exact round-trip payloads and logs per-backend session
assignments. TCP verifies the selected backend stays unchanged through several
messages on one connection; UDP verifies the same over datagrams from one
client socket. Use `--min-backends 1` for a single backend, drain, or failover
case. Compare the reported assignments with Stream metrics and Prometheus.
`--parallel`, `--sessions`, `--messages`, and `--timeout` control test load.
UDP mock services also respond to the **explicit** health probe `PING`/`PONG`
(`payload_hex: 50494e47`, `expect_hex: 504f4e47`); do not apply this payload
arbitrarily to real application servers.
To check the scripts themselves *without RustProxy*, run:
```bash
python3 scripts/test_stream_tcp.py selftest
python3 scripts/test_stream_udp.py selftest
```
Selftests only exercise the Python mock peer/harness; they do not verify the
RustProxy forwarding path, balance algorithms or real health check lifecycle.
Run the `client` cases against your compiled proxy and manually repeat after
disabling, draining, or stopping one mock backend to verify failover behavior.