This commit is contained in:
iceBear67
2026-07-25 16:33:28 +08:00
parent a41cb7965e
commit e63a34d53a
20 changed files with 1787 additions and 95 deletions
+84 -13
View File
@@ -144,11 +144,21 @@ Flags : VarInt # feature flags; bit 0x01 (STREAM_FC) MUST be set
RecvWindow: VarInt # client's per-stream receive window, bytes (§7.3)
```
`Flags` is a bitfield of features. Bit `0x01` (STREAM_FC) declares
**per-stream flow control** (§7.3) and is mandatory: `RecvWindow` advertises
the client's per-stream receive window in bytes and must be positive. The hub
closes the connection if the flag is missing, `RecvWindow` is absent or
non-positive, or the fields are malformed.
`Flags` is a bitfield of features.
| Bit | Name | Meaning |
|-----|------|---------|
| `0x01` | STREAM_FC | **Per-stream flow control** (§7.3). Mandatory. |
| `0x02` | WORKER_HEARTBEAT | Mux-level `PING`/`PONG` on worker conns (§7.4). Optional. |
STREAM_FC is mandatory: `RecvWindow` advertises the client's per-stream receive
window in bytes and must be positive. The hub closes the connection if the flag
is missing, `RecvWindow` is absent or non-positive, or the fields are malformed.
Optional bits are **negotiated**: the hub echoes in `SessionReady` only those it
accepts, and the client enables a feature only when its bit comes back. A hub
that does not know WORKER_HEARTBEAT simply omits the bit and the client falls
back to TCP keepalive alone.
The hub:
@@ -269,6 +279,8 @@ Data : Bytes[...] # remainder of the frame payload
| `0x02` | FIN | both | *(empty)* — graceful close of the stream (both directions). This is the "disconnect" the hub sends when the player leaves. |
| `0x03` | RST | both | *(optional 1 byte reason)* — abnormal close (e.g. CID unknown/expired, destination dial failed). |
| `0x04` | WND | both | `Delta: VarInt` — flow-control credit grant (§7.3). |
| `0x05` | PING | both | `Nonce: I64` — liveness probe on reserved StreamID `0` (§7.4). |
| `0x06` | PONG | both | `Nonce: I64` — echoes the probe's nonce (§7.4). |
There is no explicit SYN-ACK: success is implied by the hub forwarding the
buffered Handshake as the stream's first `DATA`; failure is an `RST`.
@@ -276,12 +288,22 @@ buffered Handshake as the stream's first `DATA`; failure is an `RST`.
### 7.1 Stream allocation (client side)
The client keeps a pool of `1 ≤ N ≤ max_conn` worker conns (`max_conn`
configurable, `1..8`). To place a new stream:
configurable, `1..8`). The pool grows **breadth-first**: spreading streams over
several connections keeps any single TCP connection from becoming the shared
point of failure for every player on the tunnel. To place a new stream:
1. Pick the worker conn with the **fewest active streams**.
2. If that minimum conn is **saturated** (active streams `> 8`) **and**
`poolSize < max_conn`, dial a new worker conn and use it instead.
3. Otherwise use the least-loaded conn (even if it exceeds 8 at `max_conn`).
1. Pick the worker conn with the **fewest active streams**, and use it.
2. If that conn already carries at least one stream and
`poolSize + dialsInFlight < max_conn`, dial another worker conn **in the
background**. The stream just placed is not delayed by that dial; the new
conn becomes the least-loaded one and picks up subsequent streams.
3. Once the pool is at `max_conn`, streams stack on the least-loaded conn.
Exceeding `8` active streams there is logged as pool saturation.
Only when the pool is *empty* does a caller dial synchronously, and then exactly
one caller dials while the others wait for its result. A dial is never performed
while holding the pool lock: session establishment is network I/O, and one
unresponsive hub must not be able to block unrelated players.
### 7.2 End-to-end player flow
@@ -333,6 +355,37 @@ Every stream carries an independent credit window per direction:
Both windows may differ (each side enforces the one its peer advertised).
`Delta` must be positive; a `WND` for an unknown stream id is ignored.
### 7.4 Liveness
TCP alone cannot tell a healthy idle connection from a dead one. When a stateful
middlebox forgets an established flow — conntrack expiry, a firewall reload, a
cloud load balancer's idle timeout — it sends neither `FIN` nor `RST`. Both ends
keep a socket that will never again carry a byte, and a reader parked on it waits
forever. Without an application-level probe the client cannot notice: its worker
conn stays in the pool, the hub keeps routing players to a control session nobody
reads, and service does not return until the client process is restarted.
Every established session is therefore covered by a heartbeat:
* **Control session** — the client sends `Ping` every `pingIntervalMs` and the
hub answers `Pong`. If no `Pong` arrives for `3 × pingIntervalMs`, the client
closes the session, which triggers its normal reconnect with backoff.
* **Worker conns** — when WORKER_HEARTBEAT was negotiated, the same exchange
runs as mux `PING`/`PONG` frames on the reserved StreamID `0`. On timeout the
client closes the conn; its streams are reset and it is dropped from the pool,
so the next player gets a freshly dialed connection.
* **Hub side** — an established redapricot session that receives no frame for
`sessionIdleTimeoutMs` (default 90000, `0` disables) is closed. Player
connections are never subject to this.
Both ends also enable TCP keepalive, which catches the narrower case of a peer
that has become unreachable at the IP layer.
Session establishment (§3.2) is bounded by a single deadline covering the dial,
the `Rekey` write and the `SessionReady` read, and every frame write is bounded
too — a peer that stops reading must not be able to park a whole multiplexed
connection inside one write.
## 8. HAProxy protocol v2 (optional)
When a mapping has `proxyProtocol: true`, the client prepends a PROXY v2 header
@@ -361,13 +414,18 @@ big-endian.
"psk": "change-me",
"timestampWindowMs": 30000,
"pendingTimeoutMs": 10000,
"streamWindowBytes": 262144
"streamWindowBytes": 262144,
"sessionIdleTimeoutMs": 90000
}
```
`streamWindowBytes` (optional, default 262144, clamped to [32768, 8388608]) is
the hub's advertised per-stream receive window (§7.3).
`sessionIdleTimeoutMs` (optional, default 90000) closes an established control
session or worker conn that has gone silent for that long (§7.4). It must stay
comfortably above the client's `pingIntervalMs`; `0` disables the watchdog.
### 9.2 Client — JSON
```json
@@ -392,6 +450,15 @@ which is `mc\\.example\\.com` in JSON); an unescaped `.` matches any character.
Use ordinary regex to route wildcards, e.g. `.*\.example\.com` for every
subdomain or `(alpha|beta)\.mc\.net` for a fixed set.
`velocitySecret` (optional, per mapping) makes the client speak Velocity
"modern forwarding" towards that destination: during the Minecraft login phase
it swallows the backend's `velocity:player_info` Login Plugin Request and
answers with an HMAC-SHA256-signed payload carrying the player's real IP,
username and UUID (the UUID claimed in Login Start, or the offline-mode UUID
for protocols that carry none). Set it to the backend's
`proxies.velocity.secret`. This is purely client↔destination behavior — it does
not appear on the tunnel wire, and the exchange is invisible to the player.
## 10. Constants summary
| Name | Value |
@@ -408,9 +475,13 @@ subdomain or `(alpha|beta)\.mc\.net` for a fixed set.
| pattern matching | case-insensitive, whole-string regex; first match wins |
| CID length | 16 bytes |
| max frame payload | 1 MiB |
| saturation threshold | active streams `> 8` |
| pool growth | breadth-first: grow to `max_conn` before stacking |
| saturation threshold (logged) | active streams `> 8` at `max_conn` |
| max worker conns | `max_conn ∈ [1,8]` |
| feature flag: per-stream flow control | `0x01` |
| feature flag: per-stream flow control | `0x01` (mandatory) |
| feature flag: worker heartbeat | `0x02` (negotiated) |
| heartbeat timeout | `3 × pingIntervalMs` |
| hub session idle timeout | 90000 ms (`0` disables) |
| stream window default / bounds | 256 KiB, clamped to [32 KiB, 8 MiB] |
| WND grant batching (reference) | one grant per window/2 consumed |
| DATA chunk cap (reference) | 32 KiB |