break: replace muxed workers with 1:1 tunnels

Worker frames are now FrameType + payload; there is no stream id.
Each player gets its own worker conn. maxTunnels (default 256)
caps concurrent tunnels. The old maxConn pool size is ignored so
existing configs do not silently admit only a handful of players.

Resume, per-direction windows, the control session, and the
DATA-only shaper stay. A dropped worker still hangs that one
player and reattaches over a fresh conn.

Add a hub-side per-IP limiter for player intents only (default
8/s, burst 16, 64 concurrent). Unmatched hostnames consume a
token; Intent 17 is never counted. 0 disables each knob.
This commit is contained in:
iceBear67
2026-08-15 18:32:51 +08:00
parent da17140583
commit 4df2560331
27 changed files with 1174 additions and 856 deletions
+4 -1
View File
@@ -9,5 +9,8 @@
"resumeGraceMs": 20000,
"maxParkedStreams": 256,
"statsIntervalMs": 0,
"registrationGraceMs": 15000
"registrationGraceMs": 15000,
"playerRatePerSec": 8,
"playerBurst": 16,
"maxPlayersPerIp": 64
}
@@ -19,7 +19,10 @@ public record Config(
int maxParkedStreams,
long maxParkedBytes,
long statsIntervalMs,
long registrationGraceMs
long registrationGraceMs,
double playerRatePerSec,
int playerBurst,
int maxPlayersPerIp
) {
public static Config load(Path file) throws Exception {
JsonObject json = new JsonObject(Files.readString(file));
@@ -68,6 +71,14 @@ public record Config(
// (its backoff caps at 10s) without holding a player so long
// that they give up anyway; 0 disables and restores the old
// behaviour of dropping routes the moment a session closes.
json.getLong("registrationGraceMs", 15_000L));
json.getLong("registrationGraceMs", 15_000L),
// Player-only (Intent ∉ {17, 18}). 0 turns that mechanism off.
// Unmatched hostnames still consume a token — otherwise a
// hostname scan is a free flood. Intent 17 is never admitted
// through the limiter: every worker comes from the client's
// one IP.
Math.max(0, json.getDouble("playerRatePerSec", 8.0)),
Math.max(1, json.getInteger("playerBurst", 16)),
Math.max(0, json.getInteger("maxPlayersPerIp", 64)));
}
}
@@ -33,6 +33,7 @@ public final class Hub {
public final Config config;
public final byte[] pskBytes;
public final String pskAddress;
public final IpRateLimiter limiter;
private final Map<String, Registration> patterns = new ConcurrentHashMap<>();
private final Map<String, PendingPlayer> pending = new ConcurrentHashMap<>();
@@ -77,6 +78,25 @@ public final class Hub {
this.config = config;
this.pskBytes = config.psk().getBytes(StandardCharsets.UTF_8);
this.pskAddress = Crypto.pskAddress(config.psk());
this.limiter = new IpRateLimiter(
config.playerRatePerSec(), config.playerBurst(), config.maxPlayersPerIp());
// Unit tests construct a Hub with a null Vertx (no event loop).
if (limiter.enabled() && vertx != null) {
vertx.setPeriodic(IpRateLimiter.SWEEP_MS, id -> limiter.sweep(System.currentTimeMillis()));
}
}
/**
* Admit one player socket from {@code ip}. {@code null} means allowed;
* the caller must {@link #releasePlayer} on every close path (pending
* timeout, unmatched host, player FIN, park eviction).
*/
public IpRateLimiter.Deny admitPlayer(String ip) {
return limiter.admit(ip, System.currentTimeMillis());
}
public void releasePlayer(String ip) {
limiter.release(ip, System.currentTimeMillis());
}
// ---- pattern registry ----
@@ -297,6 +317,10 @@ public final class Hub {
if (streams.remove(st.cidHex) == null) return;
if (st.parked) unpark(st);
st.unacked.clear();
// The player's closeHandler was replaced at SYN/RESUME bind, so the
// HubConnection cleanup never runs. This is the live/parked release
// path; pending/unmatched still go through HubConnection.closeCleanup.
releasePlayer(st.playerIp);
}
/** Look up a stream by the CID a client presented, live or parked. */
@@ -280,6 +280,19 @@ public final class HubConnection {
// ---- player connection ----
private void handlePlayer(String address) {
// Before match / CID / pause: unmatched hostnames still consume a token,
// otherwise a hostname scan is a free flood. Intent 17 never reaches
// this method (PROTOCOL.md §9.1).
String ip = socket.remoteAddress() != null ? socket.remoteAddress().host() : "0.0.0.0";
if (hub.admitPlayer(ip) != null) {
socket.close();
return;
}
// Release on every close of this socket: pending timeout, unmatched
// host, player FIN, park eviction. Later cleanups wrap this, they
// must not replace it.
closeCleanup = () -> hub.releasePlayer(ip);
String host = Hub.normalizeAddress(address);
Hub.Match matched = hub.match(address);
if (matched == null) {
@@ -293,14 +306,16 @@ public final class HubConnection {
String pattern = matched.pattern();
byte[] cid = hub.newCid();
String cidHex = Hex.encode(cid);
String ip = socket.remoteAddress() != null ? socket.remoteAddress().host() : "0.0.0.0";
int port = socket.remoteAddress() != null ? socket.remoteAddress().port() : 0;
socket.pause();
Buffer buffered = hs.copy(); // handshake + any pipelined bytes, forwarded verbatim
PendingPlayer p = new PendingPlayer(cid, cidHex, socket, buffered, pattern, ip, port);
closeCleanup = () -> hub.removePending(cidHex);
closeCleanup = () -> {
hub.removePending(cidHex);
hub.releasePlayer(ip);
};
if (session == null) {
// The route is orphaned: its client's control session has closed and
@@ -0,0 +1,134 @@
package io.icybear.redapricot;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Per-IP admission for <em>player</em> connections only (PROTOCOL.md §9.1).
*
* <p>Intent 17 (control session + every worker conn) is never admitted through
* here. Those sockets all come from the client's one address; limiting them
* would be the hub throttling its own client. e2e shares {@code 127.0.0.1}
* between players and the client for the same reason.
*
* <p>Event-loop confined: no locking. A {@code 0} rate or concurrent cap turns
* that mechanism off; both {@code 0} makes {@link #admit} a no-op.
*/
public final class IpRateLimiter {
private static final Logger LOG = LogManager.getLogger("redapricot.limit");
/** Idle buckets older than this are dropped so a one-shot flood cannot leak. */
static final long SWEEP_MS = 60_000;
private static final long DENY_LOG_INTERVAL_MS = 2_000;
public enum Deny { RATE, CONCURRENT }
private final double ratePerSec; // 0 = token bucket off
private final double burst;
private final int maxConcurrent; // 0 = concurrent cap off
private final Map<String, Bucket> buckets = new LinkedHashMap<>();
static final class Bucket {
double tokens;
long lastRefillMs;
int concurrent;
long lastActivityMs;
long lastDenyLogMs;
int deniesSinceLog;
}
public IpRateLimiter(double ratePerSec, double burst, int maxConcurrent) {
this.ratePerSec = Math.max(0, ratePerSec);
this.burst = Math.max(1, burst);
this.maxConcurrent = Math.max(0, maxConcurrent);
}
public boolean enabled() {
return ratePerSec > 0 || maxConcurrent > 0;
}
/**
* Consume one player admission for {@code ip}. {@code null} means allowed
* and the caller <em>must</em> {@link #release} when the socket closes.
*/
public Deny admit(String ip, long nowMs) {
if (!enabled()) return null;
Bucket b = bucket(ip, nowMs);
b.lastActivityMs = nowMs;
refill(b, nowMs);
if (ratePerSec > 0 && b.tokens < 1.0) {
noteDeny(ip, b, nowMs, Deny.RATE);
return Deny.RATE;
}
if (maxConcurrent > 0 && b.concurrent >= maxConcurrent) {
noteDeny(ip, b, nowMs, Deny.CONCURRENT);
return Deny.CONCURRENT;
}
if (ratePerSec > 0) b.tokens -= 1.0;
b.concurrent++;
return null;
}
public void release(String ip, long nowMs) {
Bucket b = buckets.get(ip);
if (b == null) return;
if (b.concurrent > 0) b.concurrent--;
b.lastActivityMs = nowMs;
}
/** Drop idle empty buckets. Safe to call on a timer. */
public void sweep(long nowMs) {
Iterator<Map.Entry<String, Bucket>> it = buckets.entrySet().iterator();
while (it.hasNext()) {
Bucket b = it.next().getValue();
if (b.concurrent == 0 && nowMs - b.lastActivityMs >= SWEEP_MS) {
it.remove();
}
}
}
/** Visible for tests. */
int bucketCount() {
return buckets.size();
}
/** Visible for tests. */
int concurrent(String ip) {
Bucket b = buckets.get(ip);
return b == null ? 0 : b.concurrent;
}
private Bucket bucket(String ip, long nowMs) {
Bucket b = buckets.get(ip);
if (b != null) return b;
b = new Bucket();
b.tokens = burst;
b.lastRefillMs = nowMs;
b.lastActivityMs = nowMs;
buckets.put(ip, b);
return b;
}
private void refill(Bucket b, long nowMs) {
if (ratePerSec <= 0) return;
double elapsed = (nowMs - b.lastRefillMs) / 1000.0;
if (elapsed <= 0) return;
b.tokens = Math.min(burst, b.tokens + elapsed * ratePerSec);
b.lastRefillMs = nowMs;
}
private void noteDeny(String ip, Bucket b, long nowMs, Deny why) {
b.deniesSinceLog++;
if (b.lastDenyLogMs != 0 && nowMs - b.lastDenyLogMs < DENY_LOG_INTERVAL_MS) {
return;
}
LOG.warn("dropping player from {}: {} ({} similar since last log)",
ip, why == Deny.RATE ? "rate" : "maxPlayersPerIp", b.deniesSinceLog);
b.lastDenyLogMs = nowMs;
b.deniesSinceLog = 0;
}
}
@@ -4,10 +4,10 @@ import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
/**
* One tunneled player: the player socket plus the flow-control state of the mux
* stream carrying it (PROTOCOL.md §7.3).
* One tunneled player: the player socket plus the flow-control state of the
* worker conn carrying it (PROTOCOL.md §7.3).
*
* <p>This is deliberately <em>not</em> owned by {@link WorkerConn}. A stream's
* <p>This is deliberately <em>not</em> owned by {@link WorkerConn}. A tunnel's
* identity is the player, not the connection it happens to ride: the worker conn
* is a replaceable transport, and state that dies with it cannot be recovered
* when it drops.
@@ -25,9 +25,8 @@ public final class PlayerStream {
final String playerIp;
final int playerPort;
/** The conn currently carrying this stream, and its id there. */
/** The conn currently carrying this player. */
WorkerConn worker;
int sid;
/** Budget for player -> client DATA. */
int sendWnd;
@@ -39,11 +38,11 @@ public final class PlayerStream {
// Pause reasons. Vert.x pause() is a flag rather than a counter, so a socket
// can be paused for several reasons at once and must only be resumed once
// none of them hold — see WorkerConn#maybeResumePlayer.
boolean pausedForWindow; // this stream's send window is exhausted
boolean pausedForAggregate; // the shared worker socket's write queue is full
boolean pausedForWindow; // this tunnel's send window is exhausted
boolean pausedForAggregate; // the worker socket's write queue is full
boolean parked; // the worker conn died; hanging until a reattach
/** Whether the conn carrying this stream negotiated resumption (§7.5). */
/** Whether the conn carrying this player negotiated resumption (§7.5). */
boolean resumable;
// Resumption bookkeeping (§7.5). Three distinct offsets, and conflating them
@@ -64,7 +63,7 @@ public final class PlayerStream {
long graceDeadline;
long timerId = -1;
PlayerStream(PendingPlayer p, WorkerConn worker, int sid, int sendWnd) {
PlayerStream(PendingPlayer p, WorkerConn worker, int sendWnd) {
this.cid = p.getCid();
this.cidHex = p.getCidHex();
this.player = p.getSocket();
@@ -72,7 +71,6 @@ public final class PlayerStream {
this.playerIp = p.getPlayerIp();
this.playerPort = p.getPlayerPort();
this.worker = worker;
this.sid = sid;
this.sendWnd = sendWnd;
}
@@ -81,7 +79,7 @@ public final class PlayerStream {
return !pausedForWindow && !pausedForAggregate && !parked;
}
/** Roughly how much this stream holds while parked, for the hub-wide cap. */
/** Roughly how much this tunnel holds while parked, for the hub-wide cap. */
int parkedBytes() {
return unacked.length() + (pendingUp != null ? pendingUp.length() : 0);
}
@@ -26,22 +26,19 @@ public final class Protocol {
public static final int REGISTER_OK = 0x00;
public static final int REGISTER_ERR_PATTERN = 0x01; // pattern is not a valid regular expression
// Worker-conn mux frame types
// Worker-conn frame types (one player per conn; no stream id).
public static final int MUX_SYN = 0x00;
public static final int MUX_DATA = 0x01;
public static final int MUX_FIN = 0x02;
public static final int MUX_RST = 0x03;
public static final int MUX_WND = 0x04; // per-stream flow-control credit grant
public static final int MUX_PING = 0x05; // liveness probe, StreamID 0
public static final int MUX_WND = 0x04; // per-connection flow-control credit grant
public static final int MUX_PING = 0x05; // liveness probe
public static final int MUX_PONG = 0x06; // liveness reply, echoes the nonce
/** Reattach a parked stream to this conn: CID + the client's accepted offset (§7.5). */
/** Reattach a parked player to this conn: CID + the client's accepted offset (§7.5). */
public static final int MUX_RESUME = 0x07;
/** Hub's answer to RESUME: its accepted offset plus a freshly minted CID. */
public static final int MUX_RESUME_ACK = 0x08;
/** Reserved stream id for connection-scoped mux frames (PING/PONG). Streams start at 1. */
public static final int MUX_CTL_SID = 0;
// RST reason codes (optional trailing byte; absence means "unspecified").
// Distinguishing them matters for resume: "unknown stream" is terminal,
// "already bound" means a racing attempt won and this one must not tear down.
@@ -55,18 +52,18 @@ public final class Protocol {
// Session-establishment feature flags (trailing VarInt on the Rekey message,
// echoed after the SessionReady type byte when accepted).
public static final int FLAG_STREAM_FC = 0x01;
/** Mux-level PING/PONG on worker conns, so a silently dropped path is detected. */
/** Connection-level PING/PONG on worker conns, so a silently dropped path is detected. */
public static final int FLAG_WORKER_HEARTBEAT = 0x02;
/**
* Stream resumption (§7.5): on a worker-conn drop the hub hangs the player
* socket instead of closing it, and the client reattaches the stream
* socket instead of closing it, and the client reattaches that player
* byte-exactly over a fresh conn. When accepted, the hub appends its resume
* grace period to SessionReady so the client can bound its own retry budget
* against it.
*/
public static final int FLAG_STREAM_RESUME = 0x04;
// Per-stream flow-control window bounds (bytes).
// Per-connection flow-control window bounds (bytes).
public static final int DEFAULT_STREAM_WINDOW = 256 * 1024;
public static final int MIN_STREAM_WINDOW = 32 * 1024;
public static final int MAX_STREAM_WINDOW = 8 << 20;
@@ -8,29 +8,26 @@ import lombok.RequiredArgsConstructor;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.HashMap;
import java.util.Map;
/**
* An authenticated worker connection (Magic 0x02). Multiplexes many player
* streams; the client opens streams via SYN(CID) to take over pending players.
* An authenticated worker connection (Magic 0x02). Carries exactly one player:
* the TCP connection is the tunnel (PROTOCOL.md §7). The client binds it with
* SYN(CID) (or RESUME) after SessionReady.
*
* <p>Every stream has a credit window in both directions (PROTOCOL.md §7.3),
* so one slow player only ever stalls its own stream — the shared worker
* socket is never paused because of a single stream.
* <p>The connection has a credit window in both directions (PROTOCOL.md §7.3),
* so a slow player only ever stalls itself.
*/
@RequiredArgsConstructor
public final class WorkerConn {
private static final Logger LOG = LogManager.getLogger("redapricot.worker");
/** Cap on a single DATA frame so no stream monopolizes the shared link for long. */
/** Cap on a single DATA frame so one write cannot occupy the link for long. */
private static final int CHUNK = 32 * 1024;
private final Hub hub;
private final EncryptedFrames frames;
private final String id;
private final int sendWndInit; // client's advertised per-stream receive window (our send budget)
private final int recvWndInit; // our advertised per-stream receive window (basis for credit grants)
private final int sendWndInit; // client's advertised receive window (our send budget)
private final int recvWndInit; // our advertised receive window (basis for credit grants)
/**
* Whether this conn negotiated stream resumption. Gates the park path: a
* client that will never reattach is better served by an immediate close than
@@ -38,46 +35,52 @@ public final class WorkerConn {
*/
private final boolean resume;
private final Map<Integer, PlayerStream> streams = new HashMap<>();
/** The one player bound to this conn, or null until SYN/RESUME. */
private PlayerStream stream;
private boolean workerDrainArmed = false; // whether the worker socket's single drainHandler is set
private boolean workerDrainArmed = false; // whether the worker socket's drainHandler is set
public void onFrame(byte[] payload) {
ProtoReader r = new ProtoReader(payload);
int type = r.readUByte();
int sid = r.readVarInt();
switch (type) {
case Protocol.MUX_SYN -> handleSyn(sid, r.readBytes(Protocol.CID_LEN));
case Protocol.MUX_DATA -> handleData(sid, r.readBuffer(r.remaining()));
case Protocol.MUX_WND -> handleWnd(sid, r.readVarInt());
case Protocol.MUX_SYN -> handleSyn(r.readBytes(Protocol.CID_LEN));
case Protocol.MUX_DATA -> handleData(r.readBuffer(r.remaining()));
case Protocol.MUX_WND -> handleWnd(r.readVarInt());
case Protocol.MUX_RESUME ->
handleResume(sid, r.readBytes(Protocol.CID_LEN), r.readI64(), r.readI64());
case Protocol.MUX_FIN, Protocol.MUX_RST -> closeStream(sid);
handleResume(r.readBytes(Protocol.CID_LEN), r.readI64(), r.readI64());
case Protocol.MUX_FIN, Protocol.MUX_RST -> closeBound();
case Protocol.MUX_PING -> sendPong(r.readI64());
case Protocol.MUX_PONG -> { /* liveness only; arrival is what matters */ }
case Protocol.FRAME_ERROR -> LOG.warn("worker {} error frame", id);
default -> LOG.warn("worker {} unknown mux type {}", id, type);
default -> LOG.warn("worker {} unknown frame type {}", id, type);
}
}
private void handleSyn(int sid, byte[] cid) {
private void handleSyn(byte[] cid) {
if (stream != null) {
LOG.warn("worker {} SYN on an already-bound conn; closing", id);
sendRst(Protocol.RST_ALREADY_BOUND);
frames.close();
return;
}
PendingPlayer p = hub.takePending(cid);
if (p == null) {
LOG.warn("worker {} SYN for unknown CID", id);
sendRst(sid, Protocol.RST_UNKNOWN_STREAM);
sendRst(Protocol.RST_UNKNOWN_STREAM);
return;
}
PlayerStream st = new PlayerStream(p, this, sid, sendWndInit);
PlayerStream st = new PlayerStream(p, this, sendWndInit);
st.resumable = resume;
streams.put(sid, st);
stream = st;
hub.addStream(st);
// From now on the player socket belongs to this stream. The handlers are
// From now on the player socket belongs to this tunnel. The handlers are
// installed once and route through the hub, which dispatches to whichever
// conn currently carries the stream.
// conn currently carries the player.
//
// They must not call this conn's methods directly: a lambda defined here
// captures `this`, so after the stream moves to another conn it would keep
// captures `this`, so after the player moves to another conn it would keep
// writing into the dead one's transport, where sends are silently dropped
// and the player goes mute with nothing logged. Re-installing handlers on
// every reattach would be the other option, but a Vert.x socket resumed
@@ -91,17 +94,17 @@ public final class WorkerConn {
sendUpstream(st, p.getBuffered());
maybeResumePlayer(st);
checkAggregate(st);
LOG.info("worker {} stream {} bound to {}", id, sid, st.pattern);
LOG.info("worker {} bound to {}", id, st.pattern);
}
/** Player bytes arrived on a stream this conn currently carries. */
/** Player bytes arrived on the player this conn currently carries. */
void playerData(PlayerStream st, Buffer buf) {
sendUpstream(st, buf);
checkAggregate(st);
}
/**
* Send player bytes to the client, chunked and clipped to the stream window;
* Send player bytes to the client, chunked and clipped to the send window;
* the overflow is parked in {@code pendingUp} and the player socket paused
* until the client grants more credit.
*/
@@ -120,7 +123,7 @@ public final class WorkerConn {
}
}
/** Send from {@code buf[off..]} within the stream window, chunked; returns the new offset. */
/** Send from {@code buf[off..]} within the send window, chunked; returns the new offset. */
private int drainUpstream(PlayerStream st, Buffer buf, int off) {
while (off < buf.length() && st.sendWnd > 0) {
int n = Math.min(Math.min(CHUNK, st.sendWnd), buf.length() - off);
@@ -134,16 +137,16 @@ public final class WorkerConn {
st.unacked.append(chunk);
}
st.sentOffset += n;
sendData(st.sid, chunk);
sendData(chunk);
st.sendWnd -= n;
off += n;
}
return off;
}
/** The client granted {@code delta} more bytes of credit on a stream. */
private void handleWnd(int sid, int delta) {
PlayerStream st = streams.get(sid);
/** The client granted {@code delta} more bytes of credit. */
private void handleWnd(int delta) {
PlayerStream st = stream;
if (st == null || delta <= 0) return;
// The running sum doubles as the acked offset: the client grants credit
// exactly as bytes reach the destination socket, so a credited byte can
@@ -160,13 +163,13 @@ public final class WorkerConn {
checkAggregate(st);
}
private void handleData(int sid, Buffer data) {
PlayerStream st = streams.get(sid);
private void handleData(Buffer data) {
PlayerStream st = stream;
if (st == null) return;
// Never pause the shared socket: the client bounds what it sends per
// stream to our advertised window, so a slow player only piles up a
// bounded amount in its own write queue; credit is granted back as the
// write completes (i.e. the bytes reached the player socket).
// Never pause the worker socket: the client bounds what it sends to our
// advertised window, so a slow player only piles up a bounded amount in
// its own write queue; credit is granted back as the write completes
// (i.e. the bytes reached the player socket).
int len = data.length();
// Accepted the moment the bytes are taken off the wire, not when the write
// completes. Completion is asynchronous and suppressed once the connection
@@ -176,7 +179,7 @@ public final class WorkerConn {
st.acceptedOffset += len;
st.player.write(data).onComplete(ar -> {
if (ar.failed()) return;
// Both counters advance even if this conn has since died or the stream
// Both counters advance even if this conn has since died or the player
// has moved on. Discarding them would destroy up to half a window of
// credit per outage, and — worse — leave the delivered offset that a
// reattach restates the window from permanently short.
@@ -186,28 +189,34 @@ public final class WorkerConn {
if (st.credited * 2 >= recvWndInit) {
int delta = st.credited;
st.credited = 0;
sendWnd(st.sid, delta);
sendWnd(delta);
}
});
}
/**
* Reattach a parked stream to this connection (§7.5).
* Reattach a parked player to this connection (§7.5).
*
* <p>Runs to completion in one event-loop turn — rebind, acknowledge, replay —
* so the hub's single-threaded model makes the ordering race-free by
* construction, with no interleaving of live and replayed bytes to reason
* about.
*/
private void handleResume(int sid, byte[] cid, long clientAccepted, long clientDelivered) {
private void handleResume(byte[] cid, long clientAccepted, long clientDelivered) {
if (stream != null) {
LOG.warn("worker {} RESUME on an already-bound conn; closing", id);
sendRst(Protocol.RST_ALREADY_BOUND);
frames.close();
return;
}
PlayerStream st = hub.takeParked(cid);
if (st == null) {
// Tell a stream we have never heard of apart from one that is still
// Tell a player we have never heard of apart from one that is still
// bound: the first is terminal for the client, the second means a
// racing attempt won and this one must leave the player alone.
boolean bound = hub.streamByCid(cid) != null;
LOG.warn("worker {} RESUME for {} CID", id, bound ? "still-bound" : "unknown");
sendRst(sid, bound ? Protocol.RST_ALREADY_BOUND : Protocol.RST_UNKNOWN_STREAM);
sendRst(bound ? Protocol.RST_ALREADY_BOUND : Protocol.RST_UNKNOWN_STREAM);
return;
}
@@ -222,16 +231,15 @@ public final class WorkerConn {
if (replay == null) {
LOG.warn("worker {} RESUME at offset {} outside the retained region [{}, {}]; closing player",
id, clientAccepted, st.unacked.base(), st.unacked.end());
sendRst(sid, Protocol.RST_UNKNOWN_STREAM);
sendRst(Protocol.RST_UNKNOWN_STREAM);
hub.removeStream(st);
st.player.close();
return;
}
st.worker = this;
st.sid = sid;
st.resumable = resume;
streams.put(sid, st);
stream = st;
// Restate the window rather than patching it. Three offsets, three jobs:
// the replay above is measured from what the client *accepted*, the window
@@ -253,7 +261,7 @@ public final class WorkerConn {
byte[] newCid = hub.newCid();
hub.rekeyStream(st, newCid);
frames.send(new ProtoWriter()
.u8(Protocol.MUX_RESUME_ACK).varInt(sid)
.u8(Protocol.MUX_RESUME_ACK)
.i64(st.acceptedOffset)
.i64(st.deliveredOffset)
.bytes(newCid)
@@ -264,7 +272,7 @@ public final class WorkerConn {
// drainUpstream would do.
for (int off = 0; off < replay.length(); off += CHUNK) {
int end = Math.min(off + CHUNK, replay.length());
sendData(sid, replay.getBytes(off, end));
sendData(replay.getBytes(off, end));
}
if (st.pendingUp != null) {
@@ -275,12 +283,13 @@ public final class WorkerConn {
if (st.pendingUp == null) st.pausedForWindow = false;
maybeResumePlayer(st);
checkAggregate(st);
LOG.info("worker {} stream {} resumed ({} bytes replayed, {} outstanding)",
id, sid, replay.length(), outstanding);
LOG.info("worker {} resumed ({} bytes replayed, {} outstanding)",
id, replay.length(), outstanding);
}
private void closeStream(int sid) {
PlayerStream st = streams.remove(sid);
private void closeBound() {
PlayerStream st = stream;
stream = null;
if (st != null) {
st.worker = null;
hub.removeStream(st);
@@ -288,7 +297,7 @@ public final class WorkerConn {
}
}
/** Park the player if the shared worker socket's write queue is congested. */
/** Park the player if the worker socket's write queue is congested. */
private void checkAggregate(PlayerStream st) {
if (!st.pausedForAggregate && frames.writeQueueFull()) {
st.pausedForAggregate = true;
@@ -297,17 +306,16 @@ public final class WorkerConn {
}
}
/** Register (once) the shared worker socket's single drain handler; on drain, wake parked players. */
/** Register (once) the worker socket's drain handler; on drain, wake the player. */
private void armWorkerDrain() {
if (workerDrainArmed) return;
workerDrainArmed = true;
frames.socket().drainHandler(v -> {
workerDrainArmed = false;
for (PlayerStream st : streams.values()) {
if (!st.pausedForAggregate) continue;
st.pausedForAggregate = false;
maybeResumePlayer(st);
}
PlayerStream st = stream;
if (st == null || !st.pausedForAggregate) return;
st.pausedForAggregate = false;
maybeResumePlayer(st);
});
}
@@ -322,53 +330,56 @@ public final class WorkerConn {
if (st.shouldFlow()) st.player.resume();
}
/** The player side of a stream this conn carries vanished: unbind it and FIN the client. */
/** The player side of the tunnel this conn carries vanished: unbind it and FIN the client. */
void playerGone(PlayerStream st) {
boolean wasLive = streams.remove(st.sid) == st;
boolean wasLive = stream == st;
if (wasLive) stream = null;
st.worker = null;
if (wasLive) sendFin(st.sid);
if (wasLive) sendFin();
}
private void sendData(int sid, byte[] data) {
frames.send(new ProtoWriter().u8(Protocol.MUX_DATA).varInt(sid).bytes(data).toBytes());
private void sendData(byte[] data) {
frames.send(new ProtoWriter().u8(Protocol.MUX_DATA).bytes(data).toBytes());
}
private void sendFin(int sid) {
frames.send(new ProtoWriter().u8(Protocol.MUX_FIN).varInt(sid).toBytes());
private void sendFin() {
frames.send(new ProtoWriter().u8(Protocol.MUX_FIN).toBytes());
}
/** The reason is a trailing byte, optional on the wire; peers that predate it send none. */
private void sendRst(int sid, int reason) {
frames.send(new ProtoWriter().u8(Protocol.MUX_RST).varInt(sid).u8(reason).toBytes());
private void sendRst(int reason) {
frames.send(new ProtoWriter().u8(Protocol.MUX_RST).u8(reason).toBytes());
}
private void sendWnd(int sid, int delta) {
frames.send(new ProtoWriter().u8(Protocol.MUX_WND).varInt(sid).varInt(delta).toBytes());
private void sendWnd(int delta) {
frames.send(new ProtoWriter().u8(Protocol.MUX_WND).varInt(delta).toBytes());
}
/** Answer the client's liveness probe, echoing its nonce. */
private void sendPong(long nonce) {
frames.send(new ProtoWriter().u8(Protocol.MUX_PONG).varInt(Protocol.MUX_CTL_SID).i64(nonce).toBytes());
frames.send(new ProtoWriter().u8(Protocol.MUX_PONG).i64(nonce).toBytes());
}
/**
* Only the tunnel leg died. Where the session negotiated resumption the
* player sockets are hung rather than closed, and wait for the client to
* reattach their streams over a fresh conn (§7.5); otherwise this is the old,
* player socket is hung rather than closed, and waits for the client to
* reattach over a fresh conn (§7.5); otherwise this is the old,
* unconditional close.
*/
public void onClose() {
int parked = 0;
for (PlayerStream st : streams.values()) {
st.worker = null;
if (hub.park(st)) {
parked++;
} else {
hub.removeStream(st);
st.player.close();
}
PlayerStream st = stream;
stream = null;
if (st == null) {
LOG.info("worker {} closed (unbound)", id);
return;
}
st.worker = null;
if (hub.park(st)) {
LOG.info("worker {} closed (player hung for reattach)", id);
} else {
hub.removeStream(st);
st.player.close();
LOG.info("worker {} closed (player dropped)", id);
}
LOG.info("worker {} closed ({} of {} player(s) hung for reattach)", id, parked, streams.size());
streams.clear();
}
}
@@ -79,7 +79,8 @@ class CryptoCodecTest {
private static Hub testHub() {
return new Hub(null, new Config("0.0.0.0", 25565, "test-psk", 30_000L, 10_000L,
Protocol.DEFAULT_STREAM_WINDOW, 90_000L,
true, 20_000L, 256, 256L * 2 * Protocol.DEFAULT_STREAM_WINDOW, 0L, 15_000L));
true, 20_000L, 256, 256L * 2 * Protocol.DEFAULT_STREAM_WINDOW, 0L, 15_000L,
0, 1, 0));
}
private static ControlSession testSession(Hub hub, String id) {
@@ -0,0 +1,86 @@
package io.icybear.redapricot;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
class IpRateLimiterTest {
@Test
void burstThenRefill() {
IpRateLimiter lim = new IpRateLimiter(2, 2, 0);
long t = 1_000;
assertNull(lim.admit("1.1.1.1", t));
assertNull(lim.admit("1.1.1.1", t));
assertEquals(IpRateLimiter.Deny.RATE, lim.admit("1.1.1.1", t));
// 500 ms at 2/s = 1 token.
assertNull(lim.admit("1.1.1.1", t + 500));
assertEquals(IpRateLimiter.Deny.RATE, lim.admit("1.1.1.1", t + 500));
}
@Test
void concurrentCapIndependentOfRate() {
IpRateLimiter lim = new IpRateLimiter(0, 16, 1);
long t = 1_000;
assertNull(lim.admit("10.0.0.1", t));
assertEquals(1, lim.concurrent("10.0.0.1"));
assertEquals(IpRateLimiter.Deny.CONCURRENT, lim.admit("10.0.0.1", t));
lim.release("10.0.0.1", t);
assertEquals(0, lim.concurrent("10.0.0.1"));
assertNull(lim.admit("10.0.0.1", t));
}
@Test
void ipsAreIndependent() {
IpRateLimiter lim = new IpRateLimiter(1, 1, 1);
long t = 1_000;
assertNull(lim.admit("a", t));
assertNull(lim.admit("b", t));
assertEquals(IpRateLimiter.Deny.RATE, lim.admit("a", t));
assertEquals(IpRateLimiter.Deny.RATE, lim.admit("b", t));
}
@Test
void bothOffIsNoOp() {
IpRateLimiter lim = new IpRateLimiter(0, 16, 0);
long t = 1_000;
for (int i = 0; i < 100; i++) {
assertNull(lim.admit("1.2.3.4", t));
}
assertEquals(0, lim.bucketCount());
}
@Test
void sweepDropsIdleEmptyBuckets() {
IpRateLimiter lim = new IpRateLimiter(8, 8, 64);
long t = 1_000;
assertNull(lim.admit("9.9.9.9", t));
lim.release("9.9.9.9", t);
assertEquals(1, lim.bucketCount());
lim.sweep(t + IpRateLimiter.SWEEP_MS - 1);
assertEquals(1, lim.bucketCount());
lim.sweep(t + IpRateLimiter.SWEEP_MS);
assertEquals(0, lim.bucketCount());
}
@Test
void sweepKeepsLiveBuckets() {
IpRateLimiter lim = new IpRateLimiter(8, 8, 64);
long t = 1_000;
assertNull(lim.admit("9.9.9.9", t));
lim.sweep(t + IpRateLimiter.SWEEP_MS * 2);
assertEquals(1, lim.bucketCount());
assertEquals(1, lim.concurrent("9.9.9.9"));
}
@Test
void denyDoesNotConsumeASlot() {
IpRateLimiter lim = new IpRateLimiter(1, 1, 8);
long t = 1_000;
assertNull(lim.admit("x", t));
assertNotNull(lim.admit("x", t));
assertEquals(1, lim.concurrent("x"));
}
}