From 6492ffcb60eae3ba105c7f3f2c11124543cc93ab Mon Sep 17 00:00:00 2001 From: Zulu Date: Fri, 4 Sep 2026 03:09:39 +0100 Subject: [PATCH 1/9] Add bounded stateless native admission and JNI transport conformance --- .github/workflows/build.yml | 27 +- docs/native-admission.md | 88 +++ gradle.properties | 3 +- scripts/bootstrap-native-admission.sh | 18 + settings.gradle.kts | 3 +- transport-nethernet/build.gradle.kts | 3 +- .../nethernet/admission/AdmissionGate.java | 102 +++ .../admission/AdmissionPrincipal.java | 8 + .../admission/AdmissionValidator.java | 8 + .../AdmittedNetherNetChildChannel.java | 142 ++++ .../NativeAdmissionServerChannel.java | 165 +++++ .../admission/NativeHostIdentity.java | 31 + .../admission/NetherNetFrameDecoder.java | 30 + .../nethernet/admission/NetherNetPacket.java | 14 + .../nethernet/admission/StunBinding.java | 78 ++ .../admission/VerifiedAdmission.java | 22 + .../admission/NativeAdmissionWriteTest.java | 35 + .../admission/NetherNetFrameDecoderTest.java | 34 + warden-signalling/build.gradle.kts | 24 + .../StatelessAdmissionValidator.java | 97 +++ .../netty/warden/ProviderTransport.java | 24 + .../warden/admission/AdmissionGateTest.java | 50 ++ .../kastle/warden/admission/FakeWarden.java | 37 + .../NativeAdmissionIntegrationTest.java | 141 ++++ .../StatelessAdmissionValidatorTest.java | 119 ++++ .../cloudburst-protocol-vectors.v1.json | 674 ++++++++++++++++++ .../src/test/resources/warden/provenance.json | 8 + .../stateless-admission-v2.fixtures.json | 47 ++ 28 files changed, 2026 insertions(+), 6 deletions(-) create mode 100644 docs/native-admission.md create mode 100755 scripts/bootstrap-native-admission.sh create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionGate.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionPrincipal.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionValidator.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeHostIdentity.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoder.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetPacket.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/StunBinding.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/VerifiedAdmission.java create mode 100644 transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java create mode 100644 transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoderTest.java create mode 100644 warden-signalling/build.gradle.kts create mode 100644 warden-signalling/src/main/java/dev/kastle/warden/admission/StatelessAdmissionValidator.java create mode 100644 warden-signalling/src/main/java/org/cloudburstmc/netty/warden/ProviderTransport.java create mode 100644 warden-signalling/src/test/java/dev/kastle/warden/admission/AdmissionGateTest.java create mode 100644 warden-signalling/src/test/java/dev/kastle/warden/admission/FakeWarden.java create mode 100644 warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java create mode 100644 warden-signalling/src/test/java/dev/kastle/warden/admission/StatelessAdmissionValidatorTest.java create mode 100644 warden-signalling/src/test/resources/warden/cloudburst-protocol-vectors.v1.json create mode 100644 warden-signalling/src/test/resources/warden/provenance.json create mode 100644 warden-signalling/src/test/resources/warden/stateless-admission-v2.fixtures.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d7f40563..40c3bbd4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,11 +6,32 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - - uses: Kas-tle/NetworkCompatible/.github/setup-gradle-composite@master + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + - uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 + with: + distribution: temurin + java-version: | + 8 + 17 + 21 + - uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 + - name: Native build dependencies + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build g++ libssl-dev pkg-config + - name: Build pinned JNI and run primitive probes + run: ./scripts/bootstrap-native-admission.sh - name: Build - run: ./gradlew build + run: ./gradlew build :warden-signalling:nativeAdmissionTest + - name: Archive conformance evidence + if: always() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 + with: + name: native-admission-test-reports + path: | + **/build/test-results/** + **/build/reports/tests/** - name: Archive Artifacts (transport-raknet) uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 with: diff --git a/docs/native-admission.md b/docs/native-admission.md new file mode 100644 index 00000000..057075ef --- /dev/null +++ b/docs/native-admission.md @@ -0,0 +1,88 @@ +# Experimental native Warden admission + +The WDA2 host owns one fixed UDP endpoint and validates a confidential token from +STUN USERNAME plus MESSAGE-INTEGRITY before reserving replay/session state or +calling native peer creation. There is no offer/control input to the endpoint. +The signalling-side token carries the client ICE password, SHA-256 fingerprint, +SCTP parameters, identity hash and network ID. Host/profile audience and client +ufrag are authenticated. A minimal remote SDP is reconstructed only after +validation. Full ICE learns the incoming peer-reflexive tuple; ICE-lite is not +required by the demonstrated native path. + +The conventional NetherNet server and RakNet implementation are unchanged. This +branch adds `NativeAdmissionServerChannel` and a bounded child transport. The +provider SPI is the shared WS2 boundary; the actual registration adapter and +Worker profile/answer route are still separate integration work. This is **not** +stock-client admission or gameplay evidence. + +## Reproduce + +Linux x86_64, JDK 8/17/21, OpenSSL development headers/CLI, CMake, C++ compiler, +Git and Gradle wrapper access are required. The Gradle daemon uses JDK 21. + +```sh +./scripts/bootstrap-native-admission.sh +./gradlew :transport-nethernet:test :warden-signalling:test :warden-signalling:nativeAdmissionTest +``` + +The bootstrap script builds the exact owned JNI SHA in `gradle.properties`, +including its pinned owned libdatachannel/libjuice submodules. It preserves +existing JNI classifier packaging under the distinct `dev.ziax.warden` group. +The development classifier links Linux system OpenSSL; it is not a portable +release artifact. An existing clean checkout can be selected with +`WARDEN_NATIVE_BINDING_CHECKOUT`. Build output goes under ignored `build/`. + +Canonical fixture copies record the Warden source revision and SHA-256 hashes in +`warden-signalling/src/test/resources/warden/provenance.json`. Tests decode the +TypeScript-generated encrypted token and independently verify the RFC5769 raw +STUN fixture. Fixture keys are public test material. + +## Demonstrated checks + +- Real JNI/Netty echo across ReliableDataChannel (20,013-byte segmented message) + and UnreliableDataChannel (7-byte message); nonzero ByteBuf reader index. +- No per-join control input or prestaged client state; zero initial host peers. +- Tampered, expired, wrong-host, wrong-client-ufrag and bad-STUN-integrity packets + produce zero native creation attempts, agents, promoted tuples and reservations. +- One lazy peer, natural ICE retransmission, conflicting-tuple replay rejection, + closed replay tombstones, concurrent duplicate reservation and cleanup. +- Fixed high UDP port 49190, duplicate listener refusal and socket reuse after + close. Existing RakNet responds to unconnected pings on UDP 49191 while the + native session is active. This is coexistence evidence, not RakNet gameplay. +- Bounded fragment assembly and partial cleanup; bounded unflushed Netty writes, + refused oversized unreliable writes, failed promises and exact buffer release. + +The primitive JNI probe additionally establishes 167-, 178- and 256-character +ufrags and rejects a wrong token-bound fingerprint at DTLS. See the owned JNI +repository's `native-test/README.md` for that distinct gate. + +## Limits and lifecycle + +Defaults: 1,024 live reservations, 64 pending creations, 8,192 total replay +claims, 15-second handshake deadline, 8 background key epochs. No live/tombstone +eviction to admit new traffic: capacity exhaustion refuses admission. Creation +failure is terminal until token expiry. A periodic sweep retires closed claims. +Same-tuple consent and retransmissions keep working after token expiry; another +tuple never acquires a used token. A fresh profile incarnation on restart is a +required provider contract, not currently supplied by this bare endpoint API. + +Java receive queue: 128 frames of at most 10,000 bytes. Reliable assembly: at most +262,144 bytes. Java outbound hard bound: 1 MiB including pending write overhead; +native send buffer: at most 512 KiB per data channel, checked before a whole +application message is submitted. Backpressure keeps Netty ownership until send +or rejects the write. Overflow closes the child; callback data is copied before +native storage expires. A bounded periodic pump performs native creation and +Netty delivery, never the raw mux callback. + +Reliable traffic is ordered/reliable; unreliable traffic is unordered with zero +retransmissions. Unreliable application messages must fit a single 9,999-byte +payload: countdown-only framing cannot identify interleaved/lost fragments on an +unordered stream. Outbound oversize and received nonzero fragment headers are +refused. Stock-client compatibility of this explicit restriction remains to be +measured; it must not be represented as proven gameplay support. + +Only validated token identity metadata is placed in `AdmissionPrincipal.KEY` on +the accepted child. DTLS must verify the corresponding token-bound fingerprint. +This does not itself prove an authenticated Minecraft game join. Events are +bounded to 256 entries with an explicit dropped-event counter. No token, ICE +password, SDP, private key or player credentials are logged. diff --git a/gradle.properties b/gradle.properties index e598958b..b3c2421e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,3 @@ # Only update version on publishing to Maven Central -version=1.8.0 \ No newline at end of file +version=1.8.0 +wardenNativeVersion=0.24.1.1-warden.70efb59d8459cbe64982a4cb5e60e5362ae5f9e7 diff --git a/scripts/bootstrap-native-admission.sh b/scripts/bootstrap-native-admission.sh new file mode 100755 index 00000000..668ed62b --- /dev/null +++ b/scripts/bootstrap-native-admission.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +version=$(sed -n 's/^wardenNativeVersion=//p' gradle.properties) +revision=${version#0.24.1.1-warden.} +if ! [[ $revision =~ ^[0-9a-f]{40}$ ]]; then echo 'Invalid pinned native revision' >&2; exit 1; fi +checkout=${WARDEN_NATIVE_BINDING_CHECKOUT:-build/native-admission/libdatachannel-java} +if ! test -e "$checkout/.git"; then + mkdir -p "$(dirname "$checkout")" + git clone https://github.com/teamziax/libdatachannel-java.git "$checkout" +fi +if test -n "$(git -C "$checkout" status --porcelain)"; then echo 'Native checkout must be clean' >&2; exit 1; fi +if test "$(git -C "$checkout" rev-parse HEAD)" != "$revision"; then + git -C "$checkout" fetch origin "$revision" + git -C "$checkout" checkout --detach "$revision" +fi +git -C "$checkout" submodule update --init --recursive +"$checkout/scripts/package-admission-development.sh" "${WARDEN_MAVEN_REPOSITORY:-$HOME/.m2/repository}" diff --git a/settings.gradle.kts b/settings.gradle.kts index 588d30f8..18931c40 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,4 +28,5 @@ plugins { } include("transport-raknet") -include("transport-nethernet") \ No newline at end of file +include("transport-nethernet") +include("warden-signalling") diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts index 2956651f..3e44da4c 100644 --- a/transport-nethernet/build.gradle.kts +++ b/transport-nethernet/build.gradle.kts @@ -8,7 +8,8 @@ dependencies { api(libs.bundles.netty) api(libs.netty.codec.http) api(libs.expiringmap) - api(libs.libdatachannel.java) + api("dev.ziax.warden:libdatachannel-java:${rootProject.property("wardenNativeVersion")}") + testRuntimeOnly("dev.ziax.warden:libdatachannel-java:${rootProject.property("wardenNativeVersion")}:x86_64") implementation(libs.gson) implementation(libs.jose4j) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionGate.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionGate.java new file mode 100644 index 00000000..169280b3 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionGate.java @@ -0,0 +1,102 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import java.net.InetSocketAddress; +import java.util.*; +import java.util.function.Consumer; + +/** Fixed-size replay and session reservation state. No native APIs under this monitor. */ +public final class AdmissionGate { + public record Limits(int sessions, int claims, int pending, long handshakeMillis) { + public Limits { + if (sessions < 1 || sessions > 65536 || claims < sessions || claims > 262144 || pending < 1 || pending > sessions || handshakeMillis < 100 || handshakeMillis > 120_000) + throw new IllegalArgumentException("Admission limits"); + } + public static Limits defaults() { return new Limits(1024, 8192, 64, 15_000); } + } + public static final class Reservation { + private VerifiedAdmission admission; + private final String tokenId; + private final InetSocketAddress tuple; + private final long expiresAt, acceptedNanos; + private boolean ready, connected, closed; + private Reservation(VerifiedAdmission admission, InetSocketAddress tuple, long nanos) { + this.admission = admission; this.tokenId = admission.tokenId(); this.tuple = tuple; + this.expiresAt = admission.expiresAt(); this.acceptedNanos = nanos; + } + public String tokenId() { return tokenId; } + public InetSocketAddress tuple() { return tuple; } + public long acceptedNanos() { return acceptedNanos; } + @Override public String toString() { return "Reservation[tokenId=" + tokenId + "]"; } + } + public record Stats(int sessions, int pending, int claims, long invalid, long replayRejected, long capacityRejected, long accepted, long retransmissions) {} + private final Limits limits; + private final AdmissionValidator validator; + private final Map claims = new HashMap<>(); + private final Map tuples = new HashMap<>(); + private int pending; + private boolean draining, closed; + private long invalid, replayRejected, capacityRejected, accepted, retransmissions; + + public AdmissionGate(Limits limits, AdmissionValidator validator) { this.limits = Objects.requireNonNull(limits); this.validator = Objects.requireNonNull(validator); } + + /** enqueue MUST be bounded and nonblocking, and never execute creation inline. */ + public synchronized boolean ingress(byte[] packet, InetSocketAddress tuple, long nowMillis, long nowNanos, Consumer enqueue) { + if (closed) return false; + Reservation existing = tuples.get(tuple); + StunBinding binding = StunBinding.parse(packet); + if (existing != null) { + if (binding != null) { + VerifiedAdmission a = existing.admission; + if (!binding.localUfrag().equals(a.localUfrag()) || !binding.remoteUfrag().equals(a.remoteUfrag()) || !binding.verify(packet, a.localPassword())) { invalid++; return false; } + retransmissions++; + // Token expiry ends NEW admission. Consent/retransmits on the same live session remain valid. + return existing.ready; + } + // DTLS and ICE responses are authenticated by the existing native peer. Malformed Binding requests never pass. + return existing.ready && packet.length >= 13 && ((packet[0] >= 20 && packet[0] <= 63) || + (packet.length >= 20 && packet[0] == 1 && (packet[1] == 1 || packet[1] == 17))); + } + if (binding == null) { invalid++; return false; } + VerifiedAdmission a = validator.validate(packet, binding, nowMillis); + if (a == null) { invalid++; return false; } + if (claims.containsKey(a.tokenId())) { replayRejected++; return false; } + if (draining || tuples.size() >= limits.sessions() || pending >= limits.pending() || claims.size() >= limits.claims()) { capacityRejected++; return false; } + Reservation r = new Reservation(a, tuple, nowNanos); + claims.put(r.tokenId, r); tuples.put(tuple, r); pending++; accepted++; + try { enqueue.accept(r); } + catch (RuntimeException rejected) { finish(r); capacityRejected++; } + return false; // first packet is dropped; the stock ICE retransmission is routed only after creation + } + + public synchronized VerifiedAdmission admission(Reservation r) { return current(r) ? r.admission : null; } + public synchronized boolean ready(Reservation r) { + if (!current(r)) return false; + if (!r.ready) { r.ready = true; pending--; } + return true; + } + public synchronized void connected(Reservation r) { if (current(r)) r.connected = true; } + public synchronized boolean finish(Reservation r) { + if (!current(r)) return false; + if (!r.ready) pending--; + tuples.remove(r.tuple); r.closed = true; r.admission = null; // retain only a bounded replay tombstone + return true; + } + private boolean current(Reservation r) { return !r.closed && claims.get(r.tokenId) == r; } + + /** Periodic sweep, independent of incoming traffic. Caller closes native peers outside the monitor. */ + public synchronized List sweep(long nowMillis, long nowNanos) { + List timedOut = new ArrayList<>(); + for (Reservation r : claims.values()) if (!r.closed && !r.connected && nowNanos - r.acceptedNanos >= limits.handshakeMillis() * 1_000_000L) timedOut.add(r); + for (Reservation r : timedOut) finish(r); + claims.values().removeIf(r -> r.closed && r.expiresAt <= nowMillis); + return timedOut; + } + public synchronized void drain() { draining = true; } + public synchronized List close() { + closed = true; + List active = new ArrayList<>(tuples.values()); + for (Reservation r : active) finish(r); + claims.clear(); return active; + } + public synchronized Stats stats() { return new Stats(tuples.size(), pending, claims.size(), invalid, replayRejected, capacityRejected, accepted, retransmissions); } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionPrincipal.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionPrincipal.java new file mode 100644 index 00000000..71e4a226 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionPrincipal.java @@ -0,0 +1,8 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import io.netty.util.AttributeKey; + +/** Token-authenticated context bound to the certificate checked by native DTLS. No credentials. */ +public record AdmissionPrincipal(String ticketId, String networkId, String playerIdentityHash, String keyId) { + public static final AttributeKey KEY = AttributeKey.valueOf(AdmissionPrincipal.class, "principal"); +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionValidator.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionValidator.java new file mode 100644 index 00000000..d51aa9a4 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionValidator.java @@ -0,0 +1,8 @@ +package dev.kastle.netty.channel.nethernet.admission; + +/** Local-only validation against a bounded background key/profile snapshot. No network calls. */ +@FunctionalInterface +public interface AdmissionValidator { + /** Return null on rejection. Must authenticate the token AND raw STUN integrity. */ + VerifiedAdmission validate(byte[] packet, StunBinding binding, long nowMillis); +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java new file mode 100644 index 00000000..9f9c85ef --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java @@ -0,0 +1,142 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import dev.kastle.netty.channel.nethernet.NetherNetChildChannel; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.*; +import io.netty.util.concurrent.ScheduledFuture; +import tel.schich.libdatachannel.*; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Native admission child with bounded queues and both NetherNet channel semantics. */ +public final class AdmittedNetherNetChildChannel extends NetherNetChildChannel { + public static final int WRITE_LIMIT = 1 << 20, NATIVE_WRITE_LIMIT = 1 << 19, INBOUND_FRAMES = 128; + private record Incoming(byte[] bytes, boolean reliable) {} + private final ArrayBlockingQueue incoming = new ArrayBlockingQueue<>(INBOUND_FRAMES); + private final NetherNetFrameDecoder decoder = new NetherNetFrameDecoder(); + private final AtomicBoolean failed = new AtomicBoolean(); + private ScheduledFuture tick; + private volatile boolean installed; + private boolean activated, readDemand; + + public AdmittedNetherNetChildChannel(Channel parent, PeerConnection peer, InetSocketAddress remote, InetSocketAddress local) { + super(parent, peer, remote, local); + config().setWriteBufferWaterMark(new WriteBufferWaterMark(WRITE_LIMIT / 4, WRITE_LIMIT / 2)); + } + @Override protected void doRegister() { + tick = eventLoop().scheduleWithFixedDelay(this::pump, 0, 5, TimeUnit.MILLISECONDS); + } + @Override public synchronized void setDataChannels(DataChannel reliable, DataChannel unreliable) { + acceptDataChannel(reliable); acceptDataChannel(unreliable); + } + /** Install immediately on the inline JNI callback; never retain an unobserved receive queue. */ + public synchronized void acceptDataChannel(DataChannel dc) { + if (!isOpen()) throw new IllegalStateException("Child closed"); + String label = dc.label(); + if (label.equals("ReliableDataChannel") && reliableChannel == null) { + checkSemantics(dc, true); listen(dc, true); reliableChannel = dc; + } else if (label.equals("UnreliableDataChannel") && unreliableChannel == null) { + checkSemantics(dc, false); listen(dc, false); unreliableChannel = dc; + } else throw new IllegalArgumentException("Unexpected or duplicate NetherNet channel"); + installed = reliableChannel != null && unreliableChannel != null; + } + private static void checkSemantics(DataChannel channel, boolean reliable) { + DataChannelReliability r = channel.reliability(); + if (r.isUnordered() == reliable || r.isUnreliable() == reliable || + (!reliable && (r.maxRetransmits() != 0 || !r.maxPacketLifeTime().isZero()))) + throw new IllegalArgumentException("Incorrect NetherNet channel reliability"); + } + private void listen(DataChannel dc, boolean reliable) { + // Peers use an INLINE JNI executor. Copy before native callback storage expires. + dc.onMessage.register(DataChannelCallback.Message.handleBinary((channel, bytes) -> { + if (!isOpen()) return; + if (bytes.remaining() < 2 || bytes.remaining() > NetherNetFrameDecoder.FRAME_LIMIT) { failed.set(true); return; } + byte[] copy = new byte[bytes.remaining()]; bytes.get(copy); + if (!incoming.offer(new Incoming(copy, reliable))) failed.set(true); + })); + dc.onClosed.register(channel -> failed.set(true)); + dc.onError.register((channel, message) -> failed.set(true)); + dc.bufferedAmountLowThreshold(NATIVE_WRITE_LIMIT / 2); + } + private void pump() { + if (!isOpen()) return; + if (failed.get()) { close(); return; } + try { + if (isActive() && !activated) { activated = true; pipeline().fireChannelActive(); } + if (config().isAutoRead() || readDemand) { + readDemand = false; + boolean read = false; + for (int count = 0; count < INBOUND_FRAMES; count++) { + Incoming frame = incoming.poll(); if (frame == null) break; + byte[] message = decoder.decode(frame.bytes(), frame.reliable()); + if (message != null) { + pipeline().fireUserEventTriggered(new NetherNetPacket.Delivery(frame.reliable())); + pipeline().fireChannelRead(Unpooled.wrappedBuffer(message)); read = true; + } + } + if (read) pipeline().fireChannelReadComplete(); + } + if (isActive()) { + ChannelOutboundBuffer out = unsafe().outboundBuffer(); + if (out != null) { out.setUserDefinedWritability(1, reliableChannel.bufferedAmount() < NATIVE_WRITE_LIMIT / 2 && unreliableChannel.bufferedAmount() < NATIVE_WRITE_LIMIT / 2); unsafe().flush(); } + } + } catch (Exception e) { pipeline().fireExceptionCaught(e); close(); } + } + @Override protected Object filterOutboundMessage(Object message) { + ByteBuf payload = payload(message); + boolean reliable = !(message instanceof NetherNetPacket p) || p.reliable(); + int size = payload.readableBytes(); + if (size < 1 || size > (reliable ? NetherNetFrameDecoder.MESSAGE_LIMIT : NetherNetFrameDecoder.FRAME_LIMIT - 1)) + throw new IllegalArgumentException("NetherNet message exceeds channel framing limit"); + ChannelOutboundBuffer out = unsafe().outboundBuffer(); + if (out == null || out.totalPendingWriteBytes() + size + 128 > WRITE_LIMIT) + throw new IllegalStateException("NetherNet outbound queue full"); + return message; + } + private static ByteBuf payload(Object message) { + if (message instanceof ByteBuf b) return b; + if (message instanceof NetherNetPacket p) return p.content(); + throw new IllegalArgumentException("Expected ByteBuf or NetherNetPacket"); + } + @Override protected void doWrite(ChannelOutboundBuffer out) { + if (!isActive()) return; // Netty retains ownership and promises; no private unbounded queue + while (out.current() != null) { + Object message = out.current(); ByteBuf payload = payload(message); + DataChannel dc = message instanceof NetherNetPacket p && !p.reliable() ? unreliableChannel : reliableChannel; + int length = payload.readableBytes(), chunks = (length + 9998) / 9999; + if (dc.bufferedAmount() + length + chunks > NATIVE_WRITE_LIMIT) { out.setUserDefinedWritability(1, false); return; } + try { + for (int i = 0, offset = payload.readerIndex(); i < chunks; i++) { + int count = Math.min(9999, length - i * 9999); + ByteBuffer frame = ByteBuffer.allocateDirect(count + 1); + frame.put((byte)(chunks - i - 1)); payload.getBytes(offset, frame); frame.flip(); + dc.sendMessage(frame); offset += count; + } + out.remove(); + } catch (Exception failure) { out.remove(failure); close(); return; } + } + } + @Override protected void doBeginRead() { readDemand = true; } + @Override public boolean isActive() { + DataChannel reliable = reliableChannel, unreliable = unreliableChannel; + return open && installed && reliable != null && unreliable != null && reliable.isOpen() && unreliable.isOpen(); + } + @Override protected void doClose() { + PeerConnection peer; + synchronized (this) { + open = false; installed = false; peer = peerConnection; peerConnection = null; + reliableChannel = null; unreliableChannel = null; + } + if (tick != null) { tick.cancel(false); tick = null; } + // Native close waits for callbacks. Never hold the monitor used by acceptDataChannel here. + try { if (peer != null) peer.close(); } + finally { incoming.clear(); decoder.clear(); } + } + void closeUnregistered() { doClose(); } + public int queuedFrames() { return incoming.size(); } + public int retainedAssemblyBytes() { return decoder.retainedBytes(); } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java new file mode 100644 index 00000000..e5a90fd0 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java @@ -0,0 +1,165 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherServerChannelConfig; +import io.netty.channel.*; +import io.netty.util.NetUtil; +import io.netty.util.concurrent.ScheduledFuture; +import tel.schich.libdatachannel.*; +import java.net.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** Fixed-UDP native host. The only source of client context is authenticated raw STUN. */ +public final class NativeAdmissionServerChannel extends AbstractServerChannel { + public record Event(String ticketId, String stage, String reason, long occurredAt, long validationToCreationNanos) {} + private static final class Session { + final AdmissionGate.Reservation reservation; + final AdmittedNetherNetChildChannel child; + final long creationNanos; + final CompletableFuture closed = new CompletableFuture<>(); + volatile boolean failed; + boolean reported; + Session(AdmissionGate.Reservation reservation, AdmittedNetherNetChildChannel child) { this.reservation = reservation; this.child = child; creationNanos = System.nanoTime(); } + } + private final DefaultNetherServerChannelConfig config = new DefaultNetherServerChannelConfig(this); + private final NativeHostIdentity identity; + private final AdmissionGate gate; + private final int maxNativePeers; + private final AtomicReference nativeCloseFailure = new AtomicReference<>(); + private final AtomicInteger liveNativePeers = new AtomicInteger(); + private final Set> nativeClosures = ConcurrentHashMap.newKeySet(); + private final ArrayBlockingQueue pending; + private final Map sessions = new HashMap<>(); + private final ArrayBlockingQueue events = new ArrayBlockingQueue<>(256); + private final AtomicLong droppedEvents = new AtomicLong(), creations = new AtomicLong(); + private final CompletableFuture termination = new CompletableFuture<>(); + private volatile boolean open = true; + private volatile InetSocketAddress address; + private volatile RawUdpMuxListener mux; + private ScheduledFuture tick; + + public NativeAdmissionServerChannel(NativeHostIdentity identity, AdmissionValidator validator, AdmissionGate.Limits limits) { + this.identity = Objects.requireNonNull(identity); gate = new AdmissionGate(limits, validator); pending = new ArrayBlockingQueue<>(limits.pending()); maxNativePeers = limits.sessions(); + } + @Override protected void doBind(SocketAddress socketAddress) throws Exception { + if (!(socketAddress instanceof InetSocketAddress a) || a.isUnresolved() || a.getPort() == 0 || a.getAddress().isAnyLocalAddress()) + throw new IllegalArgumentException("Resolved explicit interface address and fixed UDP port required"); + RawUdpMuxListener listener = new RawUdpMuxListener(a.getAddress(), a.getPort(), (packet, host, port) -> { + byte[] ip = NetUtil.createByteArrayFromIpAddressString(host); + if (ip == null) return false; + try { + return gate.ingress(packet, new InetSocketAddress(InetAddress.getByAddress(ip), port), System.currentTimeMillis(), System.nanoTime(), reservation -> { + if (!pending.offer(reservation)) throw new RejectedExecutionException("Admission queue full"); + }); + } catch (UnknownHostException invalid) { return false; } + }); + address = a; mux = listener; + tick = eventLoop().scheduleWithFixedDelay(this::pump, 0, 5, TimeUnit.MILLISECONDS); + } + private void pump() { + if (!isOpen()) return; + try { + if (mux.failure() != null || nativeCloseFailure.get() != null) { close(); return; } + for (AdmissionGate.Reservation r : gate.sweep(System.currentTimeMillis(), System.nanoTime())) finish(r, "timeout"); + // Limit creation work per tick, independent of packet rate and native callback rate. + for (int i = 0; i < 4 && liveNativePeers.get() < maxNativePeers; i++) { var r = pending.poll(); if (r == null) break; create(r); } + for (Session session : new ArrayList<>(sessions.values())) { + if (session.failed || !session.child.isOpen()) { finish(session.reservation, "closed"); continue; } + if (!session.reported && session.child.isActive()) { + session.reported = true; gate.connected(session.reservation); + emit(session.reservation, "ticket.data_channels_open", "both_channels_open", session.creationNanos); + } + } + } catch (Exception failure) { pipeline().fireExceptionCaught(failure); close(); } + } + private void create(AdmissionGate.Reservation reservation) { + VerifiedAdmission a = gate.admission(reservation); + if (a == null || a.expiresAt() <= System.currentTimeMillis()) { gate.finish(reservation); return; } + PeerConnection peer = null; + AdmittedNetherNetChildChannel child = null; + Session allocated = null; + try { + creations.incrementAndGet(); + peer = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(address.getAddress()) + .withEnableIceUdpMux(true).withPortRangeBegin((short)address.getPort()).withPortRangeEnd((short)address.getPort()) + .withMaxMessageSize(NetherNetFrameDecoder.MESSAGE_LIMIT), Runnable::run, identity.certificate(), identity.privateKey()); + child = new AdmittedNetherNetChildChannel(this, peer, reservation.tuple(), address); + child.attr(AdmissionPrincipal.KEY).set(new AdmissionPrincipal(a.tokenId(), a.networkId(), a.playerIdentityHash(), a.keyId())); + Session session = new Session(reservation, child); + allocated = session; liveNativePeers.incrementAndGet(); nativeClosures.add(session.closed); + session.closed.whenComplete((ignored, failure) -> { + if (failure != null) nativeCloseFailure.compareAndSet(null, failure); + nativeClosures.remove(session.closed); liveNativePeers.decrementAndGet(); + }); + child.closeFuture().addListener(future -> { if (future.isSuccess()) session.closed.complete(null); else session.closed.completeExceptionally(future.cause()); }); + peer.onStateChange.register((p, state) -> { if (state == PeerState.RTC_FAILED || state == PeerState.RTC_CLOSED) session.failed = true; }); + peer.onDataChannel.register((p, dc) -> { + if (session.failed) return; + try { session.child.acceptDataChannel(dc); } + catch (Exception invalidChannel) { session.failed = true; } + }); + peer.setRemoteDescription(a.remoteDescription(), SessionDescriptionType.OFFER); + peer.setLocalDescription("answer", a.localUfrag(), a.localPassword()); + // Refuse identity files replaced between profile publication and allocation. + String local = peer.localDescription(); + if (!local.contains("a=fingerprint:" + identity.fingerprint() + "\r\n") || !local.contains("a=ice-ufrag:" + a.localUfrag() + "\r\n")) + throw new IllegalStateException("Native identity does not match published profile"); + sessions.put(reservation, session); + pipeline().fireChannelRead(child); pipeline().fireChannelReadComplete(); + if (!gate.ready(reservation)) { finish(reservation, "cancelled"); return; } + emit(reservation, "ticket.ice_seen", "token_and_stun_validated", session.creationNanos); + } catch (Exception failure) { + gate.finish(reservation); sessions.remove(reservation); + if (allocated != null) closeChild(allocated); + else if (peer != null) peer.close(); + emit(reservation, "ticket.failed", "native_creation_failed", System.nanoTime()); + } + } + private void finish(AdmissionGate.Reservation r, String reason) { + gate.finish(r); Session session = sessions.remove(r); + if (session != null) { closeChild(session); if (!session.reported) emit(r, "ticket.failed", reason, session.creationNanos); } + } + private static void closeChild(Session session) { + try { session.child.close(); } + catch (IllegalStateException unregistered) { + // Negotiation can fail before the child is handed to ServerBootstrap. + try { session.child.closeUnregistered(); session.closed.complete(null); } + catch (Exception failedClose) { session.closed.completeExceptionally(failedClose); } + } + } + private void emit(AdmissionGate.Reservation r, String stage, String reason, long createdAt) { + if (!events.offer(new Event(r.tokenId(), stage, reason, System.currentTimeMillis(), Math.max(0, createdAt - r.acceptedNanos())))) droppedEvents.incrementAndGet(); + } + public List pollEvents() { List result = new ArrayList<>(256); events.drainTo(result); return result; } + public AdmissionGate.Stats admissionStats() { return gate.stats(); } + public int liveNativePeers() { return liveNativePeers.get(); } + public long creationAttempts() { return creations.get(); } + public long droppedEvents() { return droppedEvents.get(); } + public long[] nativeStats() { RawUdpMuxListener listener = mux; if (listener == null) throw new IllegalStateException("Endpoint not bound"); return listener.stats(); } + public NativeHostIdentity identity() { return identity; } + public CompletionStage termination() { return termination; } + public void drainAdmissions() { gate.drain(); } + @Override protected void doClose() { + open = false; gate.close(); pending.clear(); + if (tick != null) tick.cancel(false); + for (Session session : sessions.values()) closeChild(session); + sessions.clear(); + RawUdpMuxListener listener = mux; mux = null; + if (listener != null) listener.close(); // any still-closing peer is fail-closed in the native gate + CompletableFuture.allOf(nativeClosures.toArray(CompletableFuture[]::new)).whenComplete((ignored, error) -> { + events.clear(); + Throwable failure = error == null ? nativeCloseFailure.get() : error; + if (failure == null) termination.complete(null); else termination.completeExceptionally(failure); + }); + } + @Override protected void doBeginRead() {} + @Override protected boolean isCompatible(EventLoop loop) { return true; } + @Override protected SocketAddress localAddress0() { return address; } + @Override public ChannelConfig config() { return config; } + @Override public boolean isOpen() { return open; } + @Override public boolean isActive() { return open && mux != null; } + @Override public ChannelMetadata metadata() { return new ChannelMetadata(false, 16); } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeHostIdentity.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeHostIdentity.java new file mode 100644 index 00000000..52d5a380 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeHostIdentity.java @@ -0,0 +1,31 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import java.nio.file.*; +import java.security.*; +import java.security.cert.CertificateFactory; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.*; + +/** Validated background PEM identity; never creates a peer to obtain its fingerprint. */ +public record NativeHostIdentity(Path certificate, Path privateKey, String fingerprint) { + public static NativeHostIdentity load(Path certificate, Path privateKey) throws Exception { + if (Files.size(certificate) > 65536 || Files.size(privateKey) > 65536) throw new IllegalArgumentException("Oversized PEM identity"); + java.security.cert.Certificate cert; + try (var in = Files.newInputStream(certificate)) { cert = CertificateFactory.getInstance("X.509").generateCertificate(in); } + String pem = Files.readString(privateKey); + if (!pem.startsWith("-----BEGIN PRIVATE KEY-----")) throw new IllegalArgumentException("PKCS8 PEM private key required"); + byte[] der = Base64.getMimeDecoder().decode(pem.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "")); + try { + String algorithm = cert.getPublicKey().getAlgorithm(); + String signature = switch (algorithm) { case "EC" -> "SHA256withECDSA"; case "RSA" -> "SHA256withRSA"; default -> throw new IllegalArgumentException("Unsupported DTLS certificate key type"); }; + PrivateKey key = KeyFactory.getInstance(algorithm).generatePrivate(new PKCS8EncodedKeySpec(der)); + byte[] challenge = new byte[32]; new SecureRandom().nextBytes(challenge); + Signature signer = Signature.getInstance(signature); signer.initSign(key); signer.update(challenge); byte[] signed = signer.sign(); + signer.initVerify(cert.getPublicKey()); signer.update(challenge); + if (!signer.verify(signed)) throw new IllegalArgumentException("Certificate/private key mismatch"); + } finally { Arrays.fill(der, (byte)0); } + String fp = HexFormat.ofDelimiter(":").withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(cert.getEncoded())); + return new NativeHostIdentity(certificate.toRealPath(), privateKey.toRealPath(), "sha-256 " + fp); + } + @Override public String toString() { return "NativeHostIdentity[fingerprint=" + fingerprint + "]"; } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoder.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoder.java new file mode 100644 index 00000000..6bc8f1b1 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoder.java @@ -0,0 +1,30 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import java.util.Arrays; + +/** Bounded countdown framing. Unordered traffic must fit one SCTP message. */ +public final class NetherNetFrameDecoder { + public static final int FRAME_LIMIT = 10000, MESSAGE_LIMIT = 262144; + private byte[] assembly; + private int size, expected = -1; + public byte[] decode(byte[] frame, boolean reliable) { + if (frame.length < 2 || frame.length > FRAME_LIMIT) throw new IllegalArgumentException("Invalid NetherNet frame length"); + int remaining = Byte.toUnsignedInt(frame[0]), payload = frame.length - 1; + // Countdown alone cannot disambiguate interleaved/reordered fragmented messages. + if (!reliable) { + if (remaining != 0) throw new IllegalArgumentException("Fragmented unordered NetherNet message is unsupported"); + return Arrays.copyOfRange(frame, 1, frame.length); + } + if (remaining >= (MESSAGE_LIMIT + FRAME_LIMIT - 2) / (FRAME_LIMIT - 1) || + (expected != -1 && expected != remaining) || size + payload > MESSAGE_LIMIT) { + clear(); throw new IllegalArgumentException("Invalid NetherNet fragment sequence"); + } + if (expected == -1 && remaining == 0) return Arrays.copyOfRange(frame, 1, frame.length); + if (assembly == null) assembly = new byte[MESSAGE_LIMIT]; + System.arraycopy(frame, 1, assembly, size, payload); size += payload; expected = remaining - 1; + if (remaining != 0) return null; + byte[] message = Arrays.copyOf(assembly, size); clear(); return message; + } + public void clear() { assembly = null; size = 0; expected = -1; } + public int retainedBytes() { return assembly == null ? 0 : assembly.length; } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetPacket.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetPacket.java new file mode 100644 index 00000000..478365e8 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetPacket.java @@ -0,0 +1,14 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.DefaultByteBufHolder; + +/** Explicit outbound channel selection. Plain ByteBuf writes use the reliable channel. */ +public final class NetherNetPacket extends DefaultByteBufHolder { + private final boolean reliable; + public NetherNetPacket(ByteBuf content, boolean reliable) { super(content); this.reliable = reliable; } + public boolean reliable() { return reliable; } + @Override public NetherNetPacket replace(ByteBuf content) { return new NetherNetPacket(content, reliable); } + /** Fired immediately before the corresponding inbound ByteBuf, on the same event loop. */ + public record Delivery(boolean reliable) {} +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/StunBinding.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/StunBinding.java new file mode 100644 index 00000000..120f88a5 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/StunBinding.java @@ -0,0 +1,78 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.CRC32; + +/** Bounded, strict parsing of the complete raw ICE Binding Request before admission. */ +public record StunBinding(String localUfrag, String remoteUfrag, int integrityOffset) { + public static StunBinding parse(byte[] packet) { + if (packet.length < 20 || packet.length > 2048) return null; + ByteBuffer b = ByteBuffer.wrap(packet); + if (b.getShort(0) != 1 || b.getInt(4) != 0x2112a442 || + Short.toUnsignedInt(b.getShort(2)) + 20 != packet.length || packet.length % 4 != 0) return null; + String username = null; + int integrity = -1; + Set seen = new HashSet<>(); + for (int offset = 20; offset < packet.length;) { + if (offset + 4 > packet.length) return null; + int type = Short.toUnsignedInt(b.getShort(offset)), size = Short.toUnsignedInt(b.getShort(offset + 2)); + int end = offset + 4 + size; + if (end > packet.length) return null; + if ((type == 6 || type == 8 || type == 0x8028 || type == 0x24 || type == 0x25 || type == 0x8029 || type == 0x802a) && !seen.add(type)) return null; + if ((type == 0x24 && size != 4) || (type == 0x25 && size != 0) || ((type == 0x8029 || type == 0x802a) && size != 8)) return null; + if (seen.contains(0x8029) && seen.contains(0x802a)) return null; + if (type < 0x8000 && type != 6 && type != 8 && type != 0x24 && type != 0x25) return null; + if (type == 0x8028) { + if (integrity < 0 || size != 4 || end != packet.length) return null; + CRC32 crc = new CRC32(); crc.update(packet, 0, offset); + if (((int)crc.getValue() ^ 0x5354554e) != b.getInt(offset + 4)) return null; + } + // Only FINGERPRINT may follow MESSAGE-INTEGRITY. Never use unsigned attributes. + if (integrity >= 0 && type != 0x8028) return null; + if (type == 6) { + if (username != null || size > 513) return null; + for (int i = offset + 4; i < end; i++) if (packet[i] < 0 || packet[i] == 0) return null; + username = new String(packet, offset + 4, size, StandardCharsets.US_ASCII); + } else if (type == 8) { + if (integrity >= 0 || size != 20 || username == null) return null; + integrity = offset; + } + offset = end + ((4 - (size % 4)) % 4); + if (offset > packet.length) return null; + } + if (username == null || integrity < 0) return null; + int colon = username.indexOf(':'); + if (colon < 4 || colon != username.lastIndexOf(':')) return null; + String local = username.substring(0, colon), remote = username.substring(colon + 1); + if (!iceString(local, 4, 256) || !iceString(remote, 4, 256)) return null; + return new StunBinding(local, remote, integrity); + } + + public boolean verify(byte[] packet, String password) { + try { + if (integrityOffset < 20 || integrityOffset + 24 > packet.length) return false; + byte[] input = Arrays.copyOf(packet, integrityOffset); + ByteBuffer.wrap(input).putShort(2, (short) (integrityOffset + 24 - 20)); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(password.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + return MessageDigest.isEqual(mac.doFinal(input), Arrays.copyOfRange(packet, integrityOffset + 4, integrityOffset + 24)); + } catch (Exception e) { return false; } + } + + public static boolean iceString(String value, int min, int max) { + if (value == null || value.length() < min || value.length() > max) return false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '+' && c != '/') return false; + } + return true; + } + @Override public String toString() { return "StunBinding[redacted]"; } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/VerifiedAdmission.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/VerifiedAdmission.java new file mode 100644 index 00000000..887640dd --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/VerifiedAdmission.java @@ -0,0 +1,22 @@ +package dev.kastle.netty.channel.nethernet.admission; + +/** Trusted validator output. Never log credentials or reconstructed SDP. */ +public record VerifiedAdmission(String tokenId, String localUfrag, String localPassword, + String remoteUfrag, String remotePassword, String remoteFingerprint, + int remoteSctpPort, int remoteMaxMessageSize, long expiresAt, + String networkId, String playerIdentityHash, String keyId) { + public VerifiedAdmission { + if (tokenId == null || !tokenId.matches("[0-9a-f]{32}")) throw new IllegalArgumentException("tokenId"); + if (!StunBinding.iceString(localUfrag, 4, 256) || !StunBinding.iceString(remoteUfrag, 4, 256) || + !StunBinding.iceString(localPassword, 22, 256) || !StunBinding.iceString(remotePassword, 22, 256)) throw new IllegalArgumentException("ICE identity"); + if (remoteFingerprint == null || !remoteFingerprint.matches("sha-256 ([0-9A-F]{2}:){31}[0-9A-F]{2}")) throw new IllegalArgumentException("DTLS fingerprint"); + if (remoteSctpPort < 1 || remoteSctpPort > 65535 || remoteMaxMessageSize < 1 || remoteMaxMessageSize > 262144) throw new IllegalArgumentException("SCTP parameters"); + } + public String remoteDescription() { + return "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n" + + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=mid:0\r\na=setup:actpass\r\n" + + "a=ice-ufrag:" + remoteUfrag + "\r\na=ice-pwd:" + remotePassword + "\r\na=fingerprint:" + remoteFingerprint + + "\r\na=sctp-port:" + remoteSctpPort + "\r\na=max-message-size:" + remoteMaxMessageSize + "\r\n"; + } + @Override public String toString() { return "VerifiedAdmission[tokenId=" + tokenId + "]"; } +} diff --git a/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java new file mode 100644 index 00000000..4c959e7b --- /dev/null +++ b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java @@ -0,0 +1,35 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import io.netty.buffer.*; +import io.netty.channel.*; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +class NativeAdmissionWriteTest { + @Test void preHandshakeWritesAreBoundedPromisesFailAndBuffersReleaseOnClose() throws Exception { + var group = new DefaultEventLoopGroup(1); + var channel = new AdmittedNetherNetChildChannel(null,null,new InetSocketAddress(1),new InetSocketAddress(2)); + List buffers = new ArrayList<>(); List writes = new ArrayList<>(); + try { + group.register(channel).sync(); + for (int i = 0; i < 8; i++) { + ByteBuf buffer = Unpooled.buffer(200_000).writeZero(200_000);buffers.add(buffer);writes.add(channel.write(buffer)); + } + group.next().submit(() -> {}).sync(); + long pending = channel.unsafe().outboundBuffer().totalPendingWriteBytes(); + assertTrue(pending > 0 && pending <= AdmittedNetherNetChildChannel.WRITE_LIMIT, "pending=" + pending); + assertFalse(channel.isWritable()); + assertTrue(writes.stream().anyMatch(f -> f.isDone() && !f.isSuccess())); + assertTrue(writes.stream().anyMatch(f -> !f.isDone())); // acceptance waits for actual native send + ByteBuf unrel = Unpooled.buffer(10_000).writeZero(10_000);buffers.add(unrel); + ChannelFuture oversized = channel.write(new NetherNetPacket(unrel,false)).await(); + assertFalse(oversized.isSuccess());assertInstanceOf(IllegalArgumentException.class,oversized.cause()); + channel.close().sync();channel.eventLoop().submit(() -> {}).sync(); + for (ChannelFuture write : writes) { assertTrue(write.isDone());assertFalse(write.isSuccess()); } + for (ByteBuf buffer : buffers) assertEquals(0,buffer.refCnt()); + } finally { channel.close().awaitUninterruptibly();group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } + } +} diff --git a/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoderTest.java b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoderTest.java new file mode 100644 index 00000000..7024997e --- /dev/null +++ b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoderTest.java @@ -0,0 +1,34 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import org.junit.jupiter.api.Test; +import java.util.Arrays; +import static org.junit.jupiter.api.Assertions.*; + +class NetherNetFrameDecoderTest { + @Test void channelsStayIndependentAndPartialCloseReleasesAssembly() { + var decoder = new NetherNetFrameDecoder(); + assertNull(decoder.decode(new byte[]{1, 10, 11}, true)); + assertArrayEquals(new byte[]{99}, decoder.decode(new byte[]{0, 99}, false)); + assertArrayEquals(new byte[]{10, 11, 12}, decoder.decode(new byte[]{0, 12}, true)); + assertEquals(0, decoder.retainedBytes()); + decoder.decode(new byte[]{1, 42}, true); decoder.clear(); assertEquals(0, decoder.retainedBytes()); + } + @Test void malformedOutOfOrderAndOverLimitAreRejectedWithoutLeaking() { + var decoder = new NetherNetFrameDecoder(); + decoder.decode(new byte[]{2, 1}, true); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[]{0, 2}, true)); + assertEquals(0, decoder.retainedBytes()); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[]{(byte)255, 1}, true)); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[10001], true)); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[]{0}, true)); + for (int i = 26; i > 0; i--) { byte[] frame = new byte[10000]; frame[0] = (byte)i; assertNull(decoder.decode(frame, true)); } + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[10000], true)); + assertEquals(0, decoder.retainedBytes()); + } + @Test void unreliableFragmentsCannotBeMisassembledAcrossReordering() { + var decoder = new NetherNetFrameDecoder(); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[]{1, 7}, false)); + assertEquals(0, decoder.retainedBytes()); + assertArrayEquals(new byte[]{8}, decoder.decode(new byte[]{0, 8}, false)); + } +} diff --git a/warden-signalling/build.gradle.kts b/warden-signalling/build.gradle.kts new file mode 100644 index 00000000..e12684e7 --- /dev/null +++ b/warden-signalling/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { id("com.gradleup.nmcp") } +description = "Provider registration and Warden control client" +java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } } +dependencies { + api(libs.gson) + implementation(project(":transport-nethernet")) + testImplementation(libs.bundles.junit) + testImplementation(project(":transport-raknet")) + testRuntimeOnly(libs.junit.platform.launcher) + testRuntimeOnly("dev.ziax.warden:libdatachannel-java:${rootProject.property("wardenNativeVersion")}:x86_64") +} +tasks.jar { manifest.attributes["Automatic-Module-Name"] = "org.cloudburstmc.netty.warden" } + + +tasks.test { useJUnitPlatform { excludeTags("native") } } +tasks.register("nativeAdmissionTest") { + description = "Real fixed-UDP stateless host integration against the pinned JNI library" + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + javaLauncher.set(javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(21)) }) + useJUnitPlatform { includeTags("native") } + maxParallelForks = 1 + testLogging { showStandardStreams = true } +} diff --git a/warden-signalling/src/main/java/dev/kastle/warden/admission/StatelessAdmissionValidator.java b/warden-signalling/src/main/java/dev/kastle/warden/admission/StatelessAdmissionValidator.java new file mode 100644 index 00000000..42a20bd0 --- /dev/null +++ b/warden-signalling/src/main/java/dev/kastle/warden/admission/StatelessAdmissionValidator.java @@ -0,0 +1,97 @@ +package dev.kastle.warden.admission; + +import dev.kastle.netty.channel.nethernet.admission.AdmissionValidator; +import dev.kastle.netty.channel.nethernet.admission.StunBinding; +import dev.kastle.netty.channel.nethernet.admission.VerifiedAdmission; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +/** WDA2 validation using only a background key snapshot and raw client STUN. */ +public final class StatelessAdmissionValidator implements AdmissionValidator { + public record TicketKey(String keyId, String secret, long notBefore, long retireAfter) { + public TicketKey(String keyId, String secret) { this(keyId, secret, 0, Long.MAX_VALUE); } + @Override public String toString() { return "TicketKey[keyId=" + keyId + "]"; } + } + private record Material(byte[] encryption, byte[] secret, long notBefore, long retireAfter) { + void erase() { Arrays.fill(encryption, (byte)0); Arrays.fill(secret, (byte)0); } + } + private static final Base64.Encoder BASE64 = Base64.getEncoder().withoutPadding(); + private final String audience; + private final long maxTtlMs; + private volatile Map keys = Map.of(); + + public StatelessAdmissionValidator(String audience, long maxTtlMs) { + if (audience == null || audience.isEmpty() || audience.length() > 512 || audience.indexOf(0) >= 0 || maxTtlMs <= 0 || maxTtlMs > 120_000) throw new IllegalArgumentException("Admission context"); + this.audience = audience; + this.maxTtlMs = maxTtlMs; + } + + /** Validates everything before atomically replacing a bounded snapshot. */ + public synchronized void installKeys(List snapshot) { + if (snapshot.size() > 8) throw new IllegalArgumentException("At most eight admission epochs"); + Map next = new HashMap<>(); + for (TicketKey key : snapshot) { + if (key.keyId() == null || !key.keyId().matches("[A-Z0-9]{4}") || key.secret() == null || key.secret().length() < 32 || key.secret().length() > 256 || next.containsKey(key.keyId()) || key.notBefore() < 0 || key.retireAfter() <= key.notBefore()) throw new IllegalArgumentException("Invalid admission key snapshot"); + byte[] secret = utf8(key.secret()); + next.put(key.keyId(), new Material(hmac("HmacSHA256", secret, utf8("warden-stateless-aead-v2\0" + audience)), secret, key.notBefore(), key.retireAfter())); + } + Map previous = keys; keys = Map.copyOf(next); + previous.values().forEach(Material::erase); + } + + public boolean ready() { return !keys.isEmpty(); } + public Set keyIds() { return keys.keySet(); } + public synchronized void clear() { keys.values().forEach(Material::erase); keys = Map.of(); } + + @Override public synchronized VerifiedAdmission validate(byte[] packet, StunBinding binding, long nowMillis) { + if (binding == null) return null; + byte[] plaintext = null; + try { + String token = binding.localUfrag(); + if (token.length() < 8 || !token.startsWith("WDA2")) return null; + String keyId = token.substring(4, 8); + Material key = keys.get(keyId); + if (key == null || nowMillis < key.notBefore() || nowMillis >= key.retireAfter()) return null; + String encoded = token.substring(8); + byte[] envelope = Base64.getDecoder().decode(encoded); + if (envelope.length < 117 || envelope.length > 186 || !BASE64.encodeToString(envelope).equals(encoded)) return null; + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.encryption(), "AES"), new GCMParameterSpec(128, Arrays.copyOf(envelope, 12))); + cipher.updateAAD(utf8("warden-stateless-admission-v2\0" + token.substring(0, 8) + "\0" + audience + "\0" + binding.remoteUfrag())); + plaintext = cipher.doFinal(Arrays.copyOfRange(envelope, 12, envelope.length)); + if (plaintext.length < 89) return null; + ByteBuffer body = ByteBuffer.wrap(plaintext); + long expiresAt = Integer.toUnsignedLong(body.getInt()) * 1000; + if (expiresAt <= nowMillis || expiresAt - nowMillis > maxTtlMs) return null; + byte[] fingerprint = new byte[32]; body.get(fingerprint); + int sctp = Short.toUnsignedInt(body.getShort()), max = body.getInt(); + byte[] identity = new byte[16]; body.get(identity); + String networkId = Long.toUnsignedString(body.getLong()); + int length = Byte.toUnsignedInt(body.get()); + if (length < 22 || length > 91 || body.remaining() != length) return null; + String remotePassword = new String(plaintext, 67, length, StandardCharsets.US_ASCII); + String localPassword = BASE64.encodeToString(Arrays.copyOf(hmac("HmacSHA256", key.secret(), utf8("warden-stateless-ice-v2\0" + audience + "\0" + token)), 24)); + if (!binding.verify(packet, localPassword)) return null; + return new VerifiedAdmission(tokenId(token), token, localPassword, binding.remoteUfrag(), remotePassword, + "sha-256 " + HexFormat.ofDelimiter(":").withUpperCase().formatHex(fingerprint), sctp, max, expiresAt, + networkId, HexFormat.of().formatHex(identity), keyId); + } catch (Exception invalid) { return null; } + finally { if (plaintext != null) Arrays.fill(plaintext, (byte) 0); } + } + + public static String tokenId(String token) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(utf8(token)), 0, 16); } + catch (Exception impossible) { throw new IllegalStateException(impossible); } + } + private static byte[] utf8(String text) { return text.getBytes(StandardCharsets.UTF_8); } + private static byte[] hmac(String algorithm, byte[] key, byte[] data) { + try { Mac mac = Mac.getInstance(algorithm); mac.init(new SecretKeySpec(key, algorithm)); return mac.doFinal(data); } + catch (Exception impossible) { throw new IllegalStateException(impossible); } + } +} diff --git a/warden-signalling/src/main/java/org/cloudburstmc/netty/warden/ProviderTransport.java b/warden-signalling/src/main/java/org/cloudburstmc/netty/warden/ProviderTransport.java new file mode 100644 index 00000000..80d66134 --- /dev/null +++ b/warden-signalling/src/main/java/org/cloudburstmc/netty/warden/ProviderTransport.java @@ -0,0 +1,24 @@ +package org.cloudburstmc.netty.warden; + +import com.google.gson.JsonObject; +import java.util.List; +import java.util.concurrent.CompletionStage; + +/** Transport boundary. Provider code performs no native allocation or game packet handling. */ +public interface ProviderTransport { + enum ApplyResult { PENDING, APPLIED, REJECTED } + /** Existing PublishHostProfileRequest, exported from actual bound native metadata. */ + CompletionStage hostProfile(); + /** Atomic snapshot; completion means every supplied key is persisted and usable. */ + CompletionStage installTicketKeys(List keys); + /** Existing complete AgentControlCommand envelope. PENDING holds whole-page acknowledgement. */ + CompletionStage applyControl(JsonObject command); + /** Bounded events using existing ticket.* and separate authenticated game_joined semantics. */ + List pollEvents(); + CompletionStage drain(); + CompletionStage close(); + record TicketKey(String keyId, String secret, long notBefore, long retireAfter) { + public TicketKey(String keyId, String secret) { this(keyId, secret, 0, Long.MAX_VALUE); } + @Override public String toString() { return "TicketKey[keyId=" + keyId + "]"; } + } +} diff --git a/warden-signalling/src/test/java/dev/kastle/warden/admission/AdmissionGateTest.java b/warden-signalling/src/test/java/dev/kastle/warden/admission/AdmissionGateTest.java new file mode 100644 index 00000000..21306a16 --- /dev/null +++ b/warden-signalling/src/test/java/dev/kastle/warden/admission/AdmissionGateTest.java @@ -0,0 +1,50 @@ +package dev.kastle.warden.admission; + +import dev.kastle.netty.channel.nethernet.admission.*; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class AdmissionGateTest extends AdmissionFixture { + final InetSocketAddress first = new InetSocketAddress("127.0.0.1", 23450), other = new InetSocketAddress("127.0.0.1", 23451); + final byte[] valid = binding(token + ":" + remote, password); + AdmissionGate gate() { return new AdmissionGate(new AdmissionGate.Limits(2, 2, 1, 1000), validator()); } + @Test void invalidTrafficHasNoReservationsOrQueuedWork() { + var gate = gate(); var work = new ArrayBlockingQueue(1); + for (int i = 0; i < 1000; i++) assertFalse(gate.ingress(binding(token + ":" + remote, "wrong-password-000000000000"), first, now, 0, work::add)); + assertEquals(0, gate.stats().sessions()); assertEquals(0, gate.stats().claims()); assertTrue(work.isEmpty()); + assertEquals(1000, gate.stats().invalid()); + } + @Test void concurrentRetransmitsCreateOnlyOneAndConflictingTupleCannotClaim() throws Exception { + var gate = gate(); var work = new ArrayBlockingQueue(1); + try (var executor = Executors.newFixedThreadPool(8)) { + List> calls = new ArrayList<>(); + for (int i = 0; i < 64; i++) calls.add(() -> gate.ingress(valid, first, now, 0, work::add)); + for (Future result : executor.invokeAll(calls)) assertFalse(result.get()); + } + assertEquals(1, work.size()); assertEquals(1, gate.stats().accepted()); + var r = work.remove(); assertFalse(gate.ingress(valid, other, now, 0, work::add)); + assertEquals(1, gate.stats().replayRejected()); assertTrue(gate.ready(r)); gate.connected(r); + assertTrue(gate.ingress(valid, first, now + 120_000, 120_000_000_000L, work::add)); + assertEquals(0, gate.sweep(now + 120_000, 120_000_000_000L).size()); + assertEquals(1, gate.stats().claims()); // active consent is not expiry eviction + assertTrue(gate.finish(r)); assertNull(gate.admission(r)); + assertFalse(gate.ingress(valid, first, now, 0, work::add)); // failed/closed cannot allocate again + assertEquals(1, gate.stats().claims()); + gate.sweep(now + 120_000, 120_000_000_000L); assertEquals(0, gate.stats().claims()); + } + @Test void timeoutCapacityQueueFailureAndCloseAreTerminal() { + var gate = gate(); var work = new ArrayBlockingQueue(1); + gate.ingress(valid, first, now, 0, work::add); var r = work.remove(); + assertEquals(List.of(r), gate.sweep(now + 1000, 1_000_000_000)); + assertFalse(gate.ready(r)); assertNull(gate.admission(r)); assertEquals(0, gate.stats().pending()); + gate.close();assertEquals(0, gate.stats().claims()); + assertFalse(gate.ingress(valid, first, now, 0, work::add));assertTrue(work.isEmpty()); + var failed = gate(); failed.ingress(valid, first, now, 0, ignored -> { throw new RejectedExecutionException(); }); + assertEquals(0, failed.stats().sessions()); assertEquals(1, failed.stats().claims()); + assertFalse(failed.ingress(valid, first, now, 0, work::add)); assertTrue(work.isEmpty()); + var drained = gate(); drained.drain();assertFalse(drained.ingress(valid, first, now, 0, work::add));assertEquals(0, drained.stats().claims()); + } +} diff --git a/warden-signalling/src/test/java/dev/kastle/warden/admission/FakeWarden.java b/warden-signalling/src/test/java/dev/kastle/warden/admission/FakeWarden.java new file mode 100644 index 00000000..0ec054fb --- /dev/null +++ b/warden-signalling/src/test/java/dev/kastle/warden/admission/FakeWarden.java @@ -0,0 +1,37 @@ +package dev.kastle.warden.admission; + +import javax.crypto.*; +import javax.crypto.spec.*; +import java.nio.*; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.*; + +/** Test signalling source. Offer and issued token are NEVER delivered to the host. */ +final class FakeWarden { + static final String AUDIENCE = "sig_fixture/gs_one/test_boot_001", SECRET = "stateless-fixture-secret-32-bytes-minimum"; + record Answer(String sdp, String token, String password) { @Override public String toString() { return "Answer[redacted]"; } } + static String field(String sdp, String name) { return sdp.lines().filter(s -> s.startsWith("a=" + name + ":")).findFirst().orElseThrow().substring(name.length() + 3).trim(); } + static byte[] utf8(String text) { return text.getBytes(StandardCharsets.UTF_8); } + static byte[] hmac(byte[] key, String data) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key,"HmacSHA256")); return mac.doFinal(utf8(data)); } + static Answer answer(String offer, String fingerprint, int port, long expiry, String audience, boolean wrongClientFingerprint) throws Exception { + String ufrag = field(offer, "ice-ufrag"), pwd = field(offer, "ice-pwd"); + byte[] fp = HexFormat.of().parseHex(field(offer, "fingerprint").substring(8).replace(":", "")); + if (wrongClientFingerprint) fp[0] ^= 1; + ByteBuffer claims = ByteBuffer.allocate(67 + pwd.length()); + claims.putInt((int)(expiry / 1000)).put(fp).putShort((short)5000).putInt(262144).put(new byte[16]).putLong(42).put((byte)pwd.length()).put(utf8(pwd)); + byte[] nonce = new byte[12];new SecureRandom().nextBytes(nonce); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(hmac(utf8(SECRET),"warden-stateless-aead-v2\0"+audience),"AES"),new GCMParameterSpec(128,nonce)); + cipher.updateAAD(utf8("warden-stateless-admission-v2\0WDA2K001\0"+audience+"\0"+ufrag)); + byte[] sealed = cipher.doFinal(claims.array());Arrays.fill(claims.array(),(byte)0); + var base64 = Base64.getEncoder().withoutPadding(); + String token = "WDA2K001" + base64.encodeToString(ByteBuffer.allocate(12+sealed.length).put(nonce).put(sealed).array()); + String password = base64.encodeToString(Arrays.copyOf(hmac(utf8(SECRET),"warden-stateless-ice-v2\0"+audience+"\0"+token),24)); + String sdp = "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n" + + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=mid:0\r\na=setup:active\r\n" + + "a=ice-ufrag:"+token+"\r\na=ice-pwd:"+password+"\r\na=fingerprint:"+fingerprint+"\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"+ + "a=candidate:1 1 UDP 2130706431 127.0.0.1 "+port+" typ host\r\na=end-of-candidates\r\n"; + return new Answer(sdp,token,password); + } +} diff --git a/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java new file mode 100644 index 00000000..a65a5307 --- /dev/null +++ b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java @@ -0,0 +1,141 @@ +package dev.kastle.warden.admission; + +import dev.kastle.netty.channel.nethernet.admission.*; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioDatagramChannel; +import org.cloudburstmc.netty.channel.raknet.RakChannelFactory; +import org.cloudburstmc.netty.channel.raknet.RakConstants; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import tel.schich.libdatachannel.*; +import java.net.*; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; + +@Tag("native") +class NativeAdmissionIntegrationTest { + @TempDir Path directory; + NativeHostIdentity identity() throws Exception { + Path cert = directory.resolve("host.crt"), key = directory.resolve("host.key"); + Process p = new ProcessBuilder("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", "-keyout", key.toString(), "-out", cert.toString(), "-days", "1", "-subj", "/CN=public-test-only").redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start(); + assertTrue(p.waitFor(10, TimeUnit.SECONDS));assertEquals(0,p.exitValue()); + return NativeHostIdentity.load(cert,key); + } + static void await(BooleanSupplier check) throws Exception { + long end = System.nanoTime()+TimeUnit.SECONDS.toNanos(12); + while (!check.getAsBoolean() && System.nanoTime() child = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + ServerBootstrap bootstrap = new ServerBootstrap().group(group).channelFactory(() -> endpoint).childHandler(new ChannelInitializer() { + @Override protected void initChannel(AdmittedNetherNetChildChannel ch) { + child.set(ch); + ch.pipeline().addLast(new SimpleChannelInboundHandler() { + boolean reliable = true; + @Override public void userEventTriggered(ChannelHandlerContext ctx,Object event) { if(event instanceof NetherNetPacket.Delivery d) reliable=d.reliable(); } + @Override protected void channelRead0(ChannelHandlerContext ctx,ByteBuf data) { + inboundMask.getAndUpdate(mask -> mask | (reliable?1:2)); + // Nonzero reader index catches the old transport offset bug. + ByteBuf echo=ctx.alloc().buffer(data.readableBytes()+3).writeZero(3).writeBytes(data);echo.skipBytes(3); + ctx.writeAndFlush(new NetherNetPacket(echo,reliable)); + } + @Override public void exceptionCaught(ChannelHandlerContext ctx,Throwable error) { failure.compareAndSet(null,error);ctx.close(); } + }); + } + }); + try { + bootstrap.bind(new InetSocketAddress(loopback,port)).sync();rakPing(49191); + assertEquals(0,endpoint.nativeStats()[2]);assertEquals(0,endpoint.admissionStats().claims()); + long beforeInvalid=PeerConnection.nativeCreationAttempts(); + try(var noise=new DatagramSocket()) { byte[] packet=new byte[40];noise.send(new DatagramPacket(packet,packet.length,loopback,port)); } + await(()->endpoint.admissionStats().invalid()>0); + assertEquals(beforeInvalid,PeerConnection.nativeCreationAttempts());assertEquals(0,endpoint.nativeStats()[3]); + assertThrows(IllegalStateException.class,()->new RawUdpMuxListener(loopback,port,(p,a,n)->false)); + try(PeerConnection client=PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(loopback),Runnable::run)) { + CountDownLatch echoed = new CountDownLatch(2);List channels=new ArrayList<>(); + for(int index=0;index<2;index++) { + boolean reliable=index==0;String label=reliable?"ReliableDataChannel":"UnreliableDataChannel"; + DataChannel dc=client.createDataChannel(label,DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(!reliable,!reliable,0,0))); + channels.add(dc);var decoder=new NetherNetFrameDecoder();byte[] payload=new byte[reliable?20013:7];Arrays.fill(payload,(byte)(reliable?11:22)); + dc.onMessage.register(DataChannelCallback.Message.handleBinary((d,buffer)->{ + byte[] frame=new byte[buffer.remaining()];buffer.get(frame); + try { byte[] message=decoder.decode(frame,reliable);if(message!=null) {assertArrayEquals(payload,message);echoed.countDown();} } + catch(Throwable error){failure.compareAndSet(null,error);} + })); + dc.onOpen.register(d->{ + int chunks=(payload.length+9998)/9999; + for(int i=0;i rejectedPackets=List.of( + StatelessAdmissionValidatorTest.binding(expired.token()+":clientFixtureUf",expired.password()), + StatelessAdmissionValidatorTest.binding(wrongHost.token()+":clientFixtureUf",wrongHost.password()), + StatelessAdmissionValidatorTest.binding(altered+":clientFixtureUf",answer.password()), + StatelessAdmissionValidatorTest.binding(answer.token()+":clientFixtureUf","wrong-stun-integrity-password"), + StatelessAdmissionValidatorTest.binding(answer.token()+":differentClientUfrag",answer.password())); + long beforeNegatives=PeerConnection.nativeCreationAttempts(), rejectedBefore=endpoint.admissionStats().invalid(); + try(var invalid=new DatagramSocket()) { + for(byte[] packet:rejectedPackets) invalid.send(new DatagramPacket(packet,packet.length,loopback,port)); + } + await(()->endpoint.admissionStats().invalid()>=rejectedBefore+rejectedPackets.size()); + assertEquals(beforeNegatives,PeerConnection.nativeCreationAttempts()); + assertEquals(0,endpoint.admissionStats().claims());assertEquals(0,endpoint.nativeStats()[2]);assertEquals(0,endpoint.nativeStats()[3]); + + // Issuing an answer changes NO host state. Host has only its profile and key snapshot. + assertEquals(0,endpoint.admissionStats().claims());assertEquals(0,endpoint.creationAttempts()); + long beforeJoin=PeerConnection.nativeCreationAttempts(); + client.setRemoteDescription(answer.sdp(),SessionDescriptionType.ANSWER); + assertTrue(echoed.await(12,TimeUnit.SECONDS), "both channels echo through Netty"); + assertNull(failure.get());assertEquals(3,inboundMask.get()); + assertEquals(1,endpoint.creationAttempts());assertEquals(beforeJoin+1,PeerConnection.nativeCreationAttempts());rakPing(49191); + assertEquals(1,endpoint.nativeStats()[2]);assertEquals(1,endpoint.nativeStats()[3]); + try(var replay=new DatagramSocket()) { + byte[] packet=StatelessAdmissionValidatorTest.binding(answer.token()+":clientFixtureUf",answer.password()); + replay.send(new DatagramPacket(packet,packet.length,loopback,port)); + await(()->endpoint.admissionStats().replayRejected()>0); + } + assertEquals(1,endpoint.creationAttempts());assertEquals(1,endpoint.nativeStats()[3]); + assertTrue(endpoint.pollEvents().stream().allMatch(e->e.validationToCreationNanos()>0)); + child.get().close().sync(); + await(()->endpoint.admissionStats().sessions()==0); + assertEquals(0,child.get().queuedFrames());assertEquals(0,child.get().retainedAssemblyBytes()); + } + endpoint.close().sync();endpoint.termination().toCompletableFuture().get(5,TimeUnit.SECONDS); + try(var reuse=new DatagramSocket(new InetSocketAddress(loopback,port))) { assertEquals(port,reuse.getLocalPort()); } + System.out.println("native-adapter PASS fixedUdp=49190 hostCreations=1 channels=3 replayRejected=true perJoinControl=0 cleanup=true raknetPong=49191"); + } finally { endpoint.close().awaitUninterruptibly();rak.close().awaitUninterruptibly();group.shutdownGracefully(0,2,TimeUnit.SECONDS).sync();rakGroup.shutdownGracefully(0,2,TimeUnit.SECONDS).sync(); } + } +} diff --git a/warden-signalling/src/test/java/dev/kastle/warden/admission/StatelessAdmissionValidatorTest.java b/warden-signalling/src/test/java/dev/kastle/warden/admission/StatelessAdmissionValidatorTest.java new file mode 100644 index 00000000..cd7a118a --- /dev/null +++ b/warden-signalling/src/test/java/dev/kastle/warden/admission/StatelessAdmissionValidatorTest.java @@ -0,0 +1,119 @@ +package dev.kastle.warden.admission; + +import com.google.gson.*; +import dev.kastle.netty.channel.nethernet.admission.*; +import org.junit.jupiter.api.Test; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.InputStreamReader; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class AdmissionFixture { + static JsonObject fixture(String name) { + try (var in = new InputStreamReader(Objects.requireNonNull(StatelessAdmissionValidatorTest.class.getResourceAsStream("/warden/" + name)), StandardCharsets.UTF_8)) { + return JsonParser.parseReader(in).getAsJsonObject(); + } catch (Exception e) { throw new AssertionError(e); } + } + final JsonObject f = fixture("stateless-admission-v2.fixtures.json"); + final String token = f.getAsJsonObject("expected").get("localUfrag").getAsString(); + final String password = f.getAsJsonObject("expected").get("icePwd").getAsString(); + final String remote = f.get("clientIceUfrag").getAsString(); + final long now = f.get("now").getAsLong(); + StatelessAdmissionValidator validator(String audience) { + var v = new StatelessAdmissionValidator(audience, f.get("maxTtlMs").getAsLong()); + v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", f.getAsJsonObject("context").get("secret").getAsString()))); + return v; + } + StatelessAdmissionValidator validator() { return validator(f.getAsJsonObject("context").get("audience").getAsString()); } + static byte[] binding(String username, String password) { + try { + byte[] u = username.getBytes(StandardCharsets.US_ASCII); + int offset = 24 + ((u.length + 3) & ~3); + ByteBuffer b = ByteBuffer.allocate(offset + 24); + b.putShort((short)1).putShort((short)(b.capacity()-20)).putInt(0x2112a442).put(new byte[12]); + b.putShort((short)6).putShort((short)u.length).put(u);b.position(offset); + b.putShort((short)8).putShort((short)20); + Mac mac = Mac.getInstance("HmacSHA1");mac.init(new SecretKeySpec(password.getBytes(StandardCharsets.UTF_8),"HmacSHA1")); + b.put(mac.doFinal(Arrays.copyOf(b.array(), offset)));return b.array(); + } catch (Exception e) { throw new AssertionError(e); } + } +} + +class StatelessAdmissionValidatorTest extends AdmissionFixture { + @Test void canonicalTypeScriptTokenAndPacketIntegrityAgree() { + byte[] packet = binding(token + ":" + remote, password); + var a = validator().validate(packet, StunBinding.parse(packet), now); + assertNotNull(a); + var c = f.getAsJsonObject("claims"); + assertEquals(c.get("clientIcePwd").getAsString(), a.remotePassword()); + assertEquals(c.get("clientSctpPort").getAsInt(), a.remoteSctpPort()); + assertEquals(c.get("networkId").getAsString(), a.networkId()); + assertEquals(c.get("playerIdentityHashHex").getAsString(), a.playerIdentityHash()); + assertEquals(password, a.localPassword()); + assertEquals(c.get("clientFingerprintHex").getAsString(), a.remoteFingerprint().substring(8).replace(":", "").toLowerCase(Locale.ROOT)); + assertFalse(a.toString().contains(token)); + assertFalse(a.toString().contains(password)); + } + @Test void negativeAdmissionHasNoTrustedOutput() { + byte[] valid = binding(token + ":" + remote, password); + var v = validator(); + assertNull(v.validate(valid, StunBinding.parse(valid), now + 60_000)); + assertNull(v.validate(valid, StunBinding.parse(valid), now - 60_000)); + for (String audience : List.of("sig_fixture/gs_two/profile_boot_001", "sig_fixture/gs_one/profile_boot_002")) + assertNull(validator(audience).validate(valid, StunBinding.parse(valid), now)); + for (byte[] p : List.of(binding(token + ":" + remote, "forgedIntegrityPassword000"), + binding(token.substring(0, 90) + (token.charAt(90)=='A'?'B':'A') + token.substring(91) + ":" + remote, password), + binding(token + ":clientOtherUfrag", password), binding(token + "=:" + remote, password))) + assertNull(v.validate(p, StunBinding.parse(p), now)); + v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", "a-different-secret-that-has-32-characters"))); + assertNull(v.validate(valid, StunBinding.parse(valid), now)); + v.clear();assertFalse(v.ready()); + assertNull(v.validate(valid, StunBinding.parse(valid), now)); + } + @Test void canonicalRfcStunFixtureVerifies() { + var stun = fixture("cloudburst-protocol-vectors.v1.json").getAsJsonObject("stun"); + // The RFC5769 vector independently verifies the header-length/HMAC rule. + var vector = stun.getAsJsonObject("rfc5769"); + assertNotNull(vector, stun.keySet().toString()); + byte[] packet = HexFormat.of().parseHex(vector.get("packetHex").getAsString()); + var parsed = StunBinding.parse(packet);assertNotNull(parsed); + assertTrue(parsed.verify(packet, vector.get("passwordUtf8").getAsString())); + packet[40] ^= 1;assertFalse(parsed.verify(packet, vector.get("passwordUtf8").getAsString())); + } + @Test void keyUpdatesAreBoundedAtomicAndRedacted() { + var v = validator(); + var duplicate = new StatelessAdmissionValidator.TicketKey("K002", "a-valid-background-key-of-at-least-32-bytes"); + assertThrows(IllegalArgumentException.class, () -> v.installKeys(List.of(duplicate, duplicate))); + assertEquals(Set.of("K001"), v.keyIds()); + assertFalse(duplicate.toString().contains(duplicate.secret())); + assertThrows(IllegalArgumentException.class, () -> v.installKeys(Collections.nCopies(9, duplicate))); + } + @Test void fixturesHavePinnedHashesAndCanonicalFrames() throws Exception { + var provenance = fixture("provenance.json"); + assertTrue(provenance.get("revision").getAsString().matches("[0-9a-f]{40}")); + for (var entry : provenance.getAsJsonObject("files").entrySet()) { + try (var in = Objects.requireNonNull(getClass().getResourceAsStream("/warden/" + entry.getKey()))) { + assertEquals(entry.getValue().getAsString(), HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(in.readAllBytes()))); + } + } + for (var entry : fixture("cloudburst-protocol-vectors.v1.json").getAsJsonArray("nethernetFrames")) { + var frame = entry.getAsJsonObject(); var decoder = new NetherNetFrameDecoder(); + byte[] actual = decoder.decode(HexFormat.of().parseHex(frame.get("frameHex").getAsString()), true); + if (frame.getAsJsonObject("decoded").get("complete").getAsBoolean()) + assertArrayEquals(HexFormat.of().parseHex(frame.get("payloadHex").getAsString()), actual); + else { assertNull(actual); decoder.clear(); assertEquals(0, decoder.retainedBytes()); } + } + } + @Test void backgroundKeyValidityBoundsDoNotExtendTokens() { + var v = validator(); byte[] packet = binding(token + ":" + remote, password); + String secret = f.getAsJsonObject("context").get("secret").getAsString(); + v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", secret, now + 1, now + 20_000))); + assertNull(v.validate(packet, StunBinding.parse(packet), now)); + assertNotNull(v.validate(packet, StunBinding.parse(packet), now + 1)); + assertNull(v.validate(packet, StunBinding.parse(packet), now + 20_000)); + } + +} diff --git a/warden-signalling/src/test/resources/warden/cloudburst-protocol-vectors.v1.json b/warden-signalling/src/test/resources/warden/cloudburst-protocol-vectors.v1.json new file mode 100644 index 00000000..e0372320 --- /dev/null +++ b/warden-signalling/src/test/resources/warden/cloudburst-protocol-vectors.v1.json @@ -0,0 +1,674 @@ +{ + "admissionReplay": [ + { + "cacheBefore": [], + "expected": { + "action": "accepted", + "cacheSize": 1, + "ok": true + }, + "input": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "maxEntries": 4, + "now": 1780000001000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + }, + "name": "admission-replay-first-valid-stun-claims-tuple" + }, + { + "cacheBefore": [ + { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "firstSeenAt": 1780000001000, + "lastSeenAt": 1780000001000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + } + ], + "expected": { + "action": "retransmit", + "cacheSize": 1, + "ok": true + }, + "input": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "maxEntries": 4, + "now": 1780000002000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + }, + "name": "admission-replay-same-tuple-retransmit" + }, + { + "cacheBefore": [ + { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "firstSeenAt": 1780000001000, + "lastSeenAt": 1780000002000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + } + ], + "expected": { + "cacheSize": 1, + "ok": false, + "reason": "tuple_conflict" + }, + "input": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "maxEntries": 4, + "now": 1780000003000, + "tuple": { + "address": "203.0.113.44", + "port": 53000 + } + }, + "name": "admission-replay-different-tuple-conflict" + }, + { + "cacheBefore": [], + "expected": { + "cacheSize": 0, + "ok": false, + "reason": "admission_expired" + }, + "input": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "maxEntries": 4, + "now": 1780000030000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + }, + "name": "admission-replay-expired-ticket-rejected" + }, + { + "cacheBefore": [ + { + "admissionKey": "otherAdmission:clientUf", + "expiresAt": 1780000030000, + "firstSeenAt": 1780000001000, + "lastSeenAt": 1780000001000, + "tuple": { + "address": "198.51.100.20", + "port": 54000 + } + } + ], + "expected": { + "cacheSize": 1, + "ok": false, + "reason": "capacity_exceeded" + }, + "input": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "maxEntries": 1, + "now": 1780000002000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + }, + "name": "admission-replay-bounded-cache-capacity" + } + ], + "answerTicketRejections": [ + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32OFFERHASHVECTOR001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "validates": false + }, + "key": { + "keyId": "K001", + "secretUtf8": "cloudburst-vector-ticket-secret" + }, + "localUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "name": "answer-ticket-expired", + "now": 1780000030000, + "reason": "expired" + }, + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32OFFERHASHVECTOR001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "validates": false + }, + "key": { + "keyId": "K002", + "secretUtf8": "cloudburst-vector-other-ticket-secret" + }, + "localUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "name": "answer-ticket-wrong-key", + "now": 1780000001000, + "reason": "key_mismatch" + }, + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32DIFFERENTOFFER001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "validates": false + }, + "key": { + "keyId": "K001", + "secretUtf8": "cloudburst-vector-ticket-secret" + }, + "localUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "name": "answer-ticket-wrong-claims", + "now": 1780000001000, + "reason": "claim_mismatch" + } + ], + "answerTickets": [ + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32OFFERHASHVECTOR001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "expiredAt": 1780000030000, + "icePwd": "4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ", + "localUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "parsed": { + "expiresAt": 1780000030000, + "keyId": "K001", + "nonce": "ABCDEFGHIJKLMNOP", + "tag": "SLCVYJOJR5VY6OUB" + }, + "ticketId": "f8e63e66b8656a15f6a152e8c4c17231", + "validAt": 1780000001000, + "validatesAtExpiredTime": false, + "validatesAtValidTime": true + }, + "key": { + "keyId": "K001", + "secretUtf8": "cloudburst-vector-ticket-secret" + }, + "name": "answer-ticket-v1-fixed-nonce", + "nonce": "ABCDEFGHIJKLMNOP" + } + ], + "encoding": { + "bytes": "lowercase-hex", + "sdpLineEndings": "crlf", + "text": "utf-8" + }, + "kind": "warden-cloudburst-protocol-vectors", + "nativeHandoffs": [ + { + "expected": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "hostState": { + "createNativeSessionOn": "first-valid-stun", + "rejectTupleConflictBeforeSessionPromotion": true + }, + "lifecycle": { + "afterFirstAcceptedStun": [ + "ticket.ice_seen" + ], + "afterNativeSessionConnected": [ + "ticket.ice_connected", + "ticket.dtls_connected", + "ticket.sctp_connected", + "ticket.data_channels_open" + ] + }, + "rawStunMessageIntegrityRequired": true, + "validationSource": "raw-stun-message-integrity" + }, + "firstStun": { + "now": 1780000001000, + "packetHex": "0001005c2112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f7255660000080014baef44d14285cac6167606c6bfbae00d49f6f66c", + "transactionIdHex": "000102030405060708090a0b", + "tuple": { + "address": "198.51.100.10", + "port": 53000 + }, + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + }, + "joinAdmission": { + "answer": { + "icePwd": "4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ", + "iceUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "sdp": "v=0\r\no=- 123456789 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB\r\na=ice-pwd:4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ\r\na=ice-options:trickle\r\na=fingerprint:sha-256 11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00\r\na=identity:fixture.identity.assertion\r\na=setup:active\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\na=candidate:1 1 udp 2130706431 203.0.113.10 19132 typ host\r\na=end-of-candidates\r\n" + }, + "answerTicket": { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32OFFERHASHVECTOR001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expiresAt": 1780000030000, + "key": { + "keyId": "K001", + "secretUtf8": "cloudburst-vector-ticket-secret" + }, + "localUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "ticketId": "f8e63e66b8656a15f6a152e8c4c17231", + "validAt": 1780000001000 + }, + "clientOffer": { + "fingerprints": [ + "sha-256 AA:BB:CC" + ], + "icePwd": "client-password-vector", + "iceUfrag": "clientVectorUf", + "identity": "client.identity.assertion", + "sctpPort": 5000, + "sdp": "v=0\na=fingerprint:sha-256 AA:BB:CC\na=identity:client.identity.assertion\na=ice-ufrag:clientVectorUf\na=ice-pwd:client-password-vector\na=sctp-port:5000\n", + "strippedIdentitySdp": "v=0\r\na=fingerprint:sha-256 AA:BB:CC\r\na=ice-ufrag:clientVectorUf\r\na=ice-pwd:client-password-vector\r\na=sctp-port:5000\r\n" + }, + "commandKind": "join-admission", + "decisionId": "dec_native_handoff_vector", + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "player": { + "identitySource": "minecraft", + "playerKey": "xuid:2535413123456789", + "xuid": "2535413123456789" + }, + "signalServerId": "sig_vector_prod" + }, + "name": "native-handoff-join-admission-first-stun", + "replayChecks": [ + { + "cacheBefore": [], + "expected": { + "action": "accepted", + "cacheSize": 1, + "ok": true + }, + "input": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "maxEntries": 4, + "now": 1780000001000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + }, + "name": "native-handoff-first-valid-stun-claims-tuple" + }, + { + "cacheBefore": [ + { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "firstSeenAt": 1780000001000, + "lastSeenAt": 1780000001000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + } + ], + "expected": { + "action": "retransmit", + "cacheSize": 1, + "ok": true + }, + "input": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "maxEntries": 4, + "now": 1780000002000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + }, + "name": "native-handoff-same-tuple-retransmit" + }, + { + "cacheBefore": [ + { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "firstSeenAt": 1780000001000, + "lastSeenAt": 1780000002000, + "tuple": { + "address": "198.51.100.10", + "port": 53000 + } + } + ], + "expected": { + "cacheSize": 1, + "ok": false, + "reason": "tuple_conflict" + }, + "input": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "expiresAt": 1780000030000, + "maxEntries": 4, + "now": 1780000003000, + "tuple": { + "address": "203.0.113.44", + "port": 53000 + } + }, + "name": "native-handoff-different-tuple-conflict" + } + ] + } + ], + "nethernetFrames": [ + { + "decoded": { + "complete": true, + "payloadHex": "01020304", + "remainingFragments": 0 + }, + "frameHex": "0001020304", + "name": "complete-reliable-payload", + "payloadHex": "01020304", + "remainingFragments": 0 + }, + { + "decoded": { + "complete": false, + "payloadHex": "0a0b", + "remainingFragments": 2 + }, + "frameHex": "020a0b", + "name": "fragment-countdown-payload", + "payloadHex": "0a0b", + "remainingFragments": 2 + } + ], + "sdp": [ + { + "answer": { + "input": { + "candidates": [ + { + "address": "203.0.113.10", + "component": 1, + "foundation": "1", + "port": 19132, + "priority": 2130706431, + "transport": "udp", + "type": "host" + } + ], + "fingerprint": "sha-256 11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00", + "icePwd": "4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ", + "iceUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "identity": "fixture.identity.assertion", + "maxMessageSize": 262144, + "originSessionId": "123456789", + "sctpPort": 5000 + }, + "sdp": "v=0\r\no=- 123456789 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB\r\na=ice-pwd:4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ\r\na=ice-options:trickle\r\na=fingerprint:sha-256 11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00\r\na=identity:fixture.identity.assertion\r\na=setup:active\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\na=candidate:1 1 udp 2130706431 203.0.113.10 19132 typ host\r\na=end-of-candidates\r\n" + }, + "name": "data-channel-offer-answer-normalization", + "offer": { + "parsed": { + "fingerprints": [ + "sha-256 AA:BB:CC" + ], + "icePwd": "client-password-vector", + "iceUfrag": "clientVectorUf", + "identity": "client.identity.assertion", + "sctpPort": 5000 + }, + "sdp": "v=0\na=fingerprint:sha-256 AA:BB:CC\na=identity:client.identity.assertion\na=ice-ufrag:clientVectorUf\na=ice-pwd:client-password-vector\na=sctp-port:5000\n", + "strippedIdentitySdp": "v=0\r\na=fingerprint:sha-256 AA:BB:CC\r\na=ice-ufrag:clientVectorUf\r\na=ice-pwd:client-password-vector\r\na=sctp-port:5000\r\n" + } + } + ], + "stun": { + "iceBindingRequests": [ + { + "expectedLocalUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "expectedRemoteUfrag": "clientVectorUf", + "messageIntegrityHex": "baef44d14285cac6167606c6bfbae00d49f6f66c", + "name": "warden-answer-ticket-binding-request", + "packetHex": "0001005c2112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f7255660000080014baef44d14285cac6167606c6bfbae00d49f6f66c", + "passwordUtf8": "4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + } + ], + "iceBindingRequestRejections": [ + { + "expectedLocalUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "expectedRemoteUfrag": "clientVectorUf", + "name": "warden-answer-ticket-binding-missing-integrity", + "packetHex": "000100442112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f72556600", + "passwordUtf8": "4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ", + "reason": "message_integrity_missing", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + }, + { + "expectedLocalUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "expectedRemoteUfrag": "clientVectorUf", + "messageIntegrityHex": "51d99a091c781485f4e8699331bee1f3f524011d", + "name": "warden-answer-ticket-binding-integrity-failed", + "packetHex": "0001005c2112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f725566000008001451d99a091c781485f4e8699331bee1f3f524011d", + "passwordUtf8": "4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ", + "reason": "message_integrity_failed", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + }, + { + "expectedLocalUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "expectedRemoteUfrag": "clientVectorUf", + "messageIntegrityHex": "ca5f77dedcdaccbe97fe9737bb5c10dc9c69d24a", + "name": "warden-answer-ticket-binding-username-mismatch", + "packetHex": "0001005c2112a442000102030405060708090a0b0006003e57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a6f74686572436c69656e745566000000080014ca5f77dedcdaccbe97fe9737bb5c10dc9c69d24a", + "passwordUtf8": "4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ", + "reason": "username_mismatch", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:otherClientUf" + }, + { + "expectedLocalUfrag": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB", + "expectedRemoteUfrag": "clientVectorUf", + "messageIntegrityHex": "dcb4d5372647e8bb0ee12d1f019cbe727540630d", + "name": "warden-answer-ticket-binding-username-malformed", + "packetHex": "0001004c2112a442000102030405060708090a0b0006003057444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f554200080014dcb4d5372647e8bb0ee12d1f019cbe727540630d", + "passwordUtf8": "4HU323WHG3EB467OBC3LXR5FLXO25W4BCUPYWNQ", + "reason": "username_malformed", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB" + } + ], + "rfc5769": { + "messageIntegrityHex": "9aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a2", + "name": "rfc5769-sample-request", + "packetHex": "000100582112a442b7e7a701bc34d686fa87dfae802200105354554e207465737420636c69656e74002400046e0001ff80290008932ff9b151263b36000600096576746a3a68367659202020000800149aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a280280004e57a3bcf", + "passwordUtf8": "VOkJxbRl1RmTxUk/WvJxBt", + "transactionIdHex": "b7e7a701bc34d686fa87dfae", + "username": "evtj:h6vY" + }, + "wardenAdmissionChecks": [ + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32OFFERHASHVECTOR001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "admissionKey": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf", + "ok": true + }, + "expectedRemoteUfrag": "clientVectorUf", + "key": { + "keyId": "K001", + "secretUtf8": "cloudburst-vector-ticket-secret" + }, + "name": "warden-answer-ticket-admission-valid", + "now": 1780000001000, + "packetHex": "0001005c2112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f7255660000080014baef44d14285cac6167606c6bfbae00d49f6f66c", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + }, + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32DIFFERENTOFFER001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "ok": false, + "reason": "answer_ticket_invalid" + }, + "expectedRemoteUfrag": "clientVectorUf", + "key": { + "keyId": "K001", + "secretUtf8": "cloudburst-vector-ticket-secret" + }, + "name": "warden-answer-ticket-admission-wrong-claims", + "now": 1780000001000, + "packetHex": "0001005c2112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f7255660000080014baef44d14285cac6167606c6bfbae00d49f6f66c", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + }, + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32OFFERHASHVECTOR001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "ok": false, + "reason": "answer_ticket_expired" + }, + "expectedRemoteUfrag": "clientVectorUf", + "key": { + "keyId": "K001", + "secretUtf8": "cloudburst-vector-ticket-secret" + }, + "name": "warden-answer-ticket-admission-expired", + "now": 1780000030000, + "packetHex": "0001005c2112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f7255660000080014baef44d14285cac6167606c6bfbae00d49f6f66c", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + }, + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32OFFERHASHVECTOR001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "ok": false, + "reason": "answer_ticket_invalid" + }, + "expectedRemoteUfrag": "clientVectorUf", + "key": { + "keyId": "K002", + "secretUtf8": "cloudburst-vector-other-ticket-secret" + }, + "name": "warden-answer-ticket-admission-wrong-key", + "now": 1780000001000, + "packetHex": "0001005c2112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f7255660000080014baef44d14285cac6167606c6bfbae00d49f6f66c", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + }, + { + "claims": { + "clientIceUfrag": "clientVectorUf", + "expiresAt": 1780000030000, + "gameServerId": "gs_vector_lon", + "networkId": "9876543210123456789", + "offerFingerprintHash": "BC32OFFERHASHVECTOR001", + "playerIdentityHash": "6N3XMXAIZFDOQOV22QTS7YFGGU", + "signalServerId": "sig_vector_prod" + }, + "expected": { + "ok": false, + "reason": "username_mismatch" + }, + "expectedRemoteUfrag": "otherClientUf", + "key": { + "keyId": "K001", + "secretUtf8": "cloudburst-vector-ticket-secret" + }, + "name": "warden-answer-ticket-admission-client-ufrag-mismatch", + "now": 1780000001000, + "packetHex": "0001005c2112a442000102030405060708090a0b0006003f57444e314b30303130305446524b54414142434445464748494a4b4c4d4e4f50534c4356594a4f4a52355659364f55423a636c69656e74566563746f7255660000080014baef44d14285cac6167606c6bfbae00d49f6f66c", + "transactionIdHex": "000102030405060708090a0b", + "username": "WDN1K00100TFRKTAABCDEFGHIJKLMNOPSLCVYJOJR5VY6OUB:clientVectorUf" + } + ] + }, + "suites": [ + "answer-ticket", + "answer-ticket-rejections", + "stun-message-integrity", + "stun-admission-rejections", + "admission-replay", + "native-handoff", + "sdp-normalization", + "nethernet-frame" + ], + "warning": "Fixture-only protocol vectors. Secrets and credentials in this file are public test material and must never be reused for production Warden resources.", + "version": 1 +} diff --git a/warden-signalling/src/test/resources/warden/provenance.json b/warden-signalling/src/test/resources/warden/provenance.json new file mode 100644 index 00000000..a3af2230 --- /dev/null +++ b/warden-signalling/src/test/resources/warden/provenance.json @@ -0,0 +1,8 @@ +{ + "repository": "https://github.com/teamziax/warden-signalling", + "revision": "d5a5645bea9db232659f27e363b20bc8a39b79a0", + "files": { + "cloudburst-protocol-vectors.v1.json": "19aa5072e93a255b5be339de54c16ef7b35b703d3248feed64b5b21777bfd5c2", + "stateless-admission-v2.fixtures.json": "39006ac30cb8e4c547c74d9641d1c1fb7e845017854300576e21ade4e63736dd" + } +} diff --git a/warden-signalling/src/test/resources/warden/stateless-admission-v2.fixtures.json b/warden-signalling/src/test/resources/warden/stateless-admission-v2.fixtures.json new file mode 100644 index 00000000..98799edb --- /dev/null +++ b/warden-signalling/src/test/resources/warden/stateless-admission-v2.fixtures.json @@ -0,0 +1,47 @@ +{ + "kind": "warden-stateless-admission-vectors", + "version": 2, + "experimental": true, + "warning": "Public deterministic test material only. Never use fixture keys/nonces in production.", + "context": { + "keyId": "K001", + "secret": "stateless-fixture-secret-32-bytes-minimum", + "audience": "sig_fixture/gs_one/profile_boot_001" + }, + "claims": { + "expiresAt": 1788484830000, + "clientFingerprintHex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "clientIcePwd": "clientPassword01234567890", + "clientSctpPort": 5000, + "clientMaxMessageSize": 262144, + "playerIdentityHashHex": "abcdef0123456789abcdef0123456789", + "networkId": "18446744073709551615" + }, + "clientIceUfrag": "clientFixtureUf", + "nonceHex": "000102030405060708090a0b", + "now": 1788484800000, + "maxTtlMs": 60000, + "expected": { + "localUfrag": "WDA2K001AAECAwQFBgcICQoLRB67tUgz59tXYrwtBwzY4DewLieXBCXFXqkaCStTUjeD0N47UPU8GhwnmvM1Mxy7soTgrqdWZM4/wS6/TXOm4L0LoRApZCB/Vv/ROFRvCnKtpLiw2svLPrqof/4cjk9fqwL9SXOLqQMYEKZk", + "icePwd": "unwhe3iOdSU5v4viIOjU0pNpjEQRVYNM", + "ufragLength": 168 + }, + "budget": { + "headerAscii": 8, + "nonceBytes": 12, + "fixedPlaintextBytes": 67, + "tagBytes": 16, + "maxUfragChars": 256, + "maxPasswordBytes": 91 + }, + "rejections": [ + "tampered-ciphertext", + "expired", + "wrong-host", + "wrong-profile-incarnation", + "wrong-key", + "wrong-client-ufrag", + "password-over-budget", + "noncanonical-base64" + ] +} From 62ab56bb63a8a7a077f6214ce84b6cc6f695b950 Mon Sep 17 00:00:00 2001 From: Zulu Date: Fri, 4 Sep 2026 03:30:48 +0100 Subject: [PATCH 2/9] Wait for native ICE teardown before releasing host capacity --- docs/native-admission.md | 17 +++++++++++++++++ gradle.properties | 2 +- .../AdmittedNetherNetChildChannel.java | 7 ++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/native-admission.md b/docs/native-admission.md index 057075ef..610004dd 100644 --- a/docs/native-admission.md +++ b/docs/native-admission.md @@ -86,3 +86,20 @@ the accepted child. DTLS must verify the corresponding token-bound fingerprint. This does not itself prove an authenticated Minecraft game join. Events are bounded to 256 entries with an explicit dropped-event counter. No token, ICE password, SDP, private key or player credentials are logged. + +## Demonstrated native cleanup constraint and remedy + +[Initial CI run](https://github.com/teamziax/NetworkCompatible/actions/runs/33828480051) +failed the immediate zero-agent assertion on endpoint reuse. The old native delete +API schedules transport teardown and can return while its ICE agent still exists. +The pinned dependency now includes `test/admission/teardown.cpp`, which stalls the +teardown worker to reproduce that behavior deterministically. The new bounded +per-peer completion API reports timeout while the agent remains, then succeeds +only after the queued transport teardown releases it. The original zero-agent +assertion remains unchanged. + +Native children use `closeAndAwait` from their Netty owner thread before completing +close/freeing native capacity. Callback-thread calls are rejected. A five-second +teardown timeout is a terminal endpoint failure, never permission to admit more +peers while teardown is unresolved. Endpoint termination propagates close errors. +The old asynchronous close API remains available for existing direct consumers. diff --git a/gradle.properties b/gradle.properties index b3c2421e..25ef9b9b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ # Only update version on publishing to Maven Central version=1.8.0 -wardenNativeVersion=0.24.1.1-warden.70efb59d8459cbe64982a4cb5e60e5362ae5f9e7 +wardenNativeVersion=0.24.1.1-warden.0812c7eb01427ff561e4d589d8724a48e06e0da1 diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java index 9f9c85ef..fb684cdf 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java @@ -133,7 +133,12 @@ private static ByteBuf payload(Object message) { } if (tick != null) { tick.cancel(false); tick = null; } // Native close waits for callbacks. Never hold the monitor used by acceptDataChannel here. - try { if (peer != null) peer.close(); } + try { + if (peer != null && !peer.closeAndAwait(java.time.Duration.ofSeconds(5))) { + peer.close(); + throw new IllegalStateException("Native transport teardown did not complete within its deadline"); + } + } finally { incoming.clear(); decoder.clear(); } } void closeUnregistered() { doClose(); } From cdf919774e1702d44dfe4965439f74c889c722c1 Mon Sep 17 00:00:00 2001 From: Zulu Date: Fri, 4 Sep 2026 03:39:53 +0100 Subject: [PATCH 3/9] Expose background profile and key lifecycle for native admission --- docs/native-admission.md | 14 ++- .../admission/NativeProviderTransport.java | 107 ++++++++++++++++++ .../StatelessAdmissionValidator.java | 8 ++ .../NativeAdmissionIntegrationTest.java | 31 +++++ 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 warden-signalling/src/main/java/dev/kastle/warden/admission/NativeProviderTransport.java diff --git a/docs/native-admission.md b/docs/native-admission.md index 610004dd..8deba41d 100644 --- a/docs/native-admission.md +++ b/docs/native-admission.md @@ -11,8 +11,9 @@ required by the demonstrated native path. The conventional NetherNet server and RakNet implementation are unchanged. This branch adds `NativeAdmissionServerChannel` and a bounded child transport. The -provider SPI is the shared WS2 boundary; the actual registration adapter and -Worker profile/answer route are still separate integration work. This is **not** +provider SPI is the shared WS2 boundary. `NativeProviderTransport` implements +background key installation, profile publication, drain/close and bounded events. +It rejects per-join control commands; registration is supplied by WS2. This is **not** stock-client admission or gameplay evidence. ## Reproduce @@ -64,7 +65,9 @@ eviction to admit new traffic: capacity exhaustion refuses admission. Creation failure is terminal until token expiry. A periodic sweep retires closed claims. Same-tuple consent and retransmissions keep working after token expiry; another tuple never acquires a used token. A fresh profile incarnation on restart is a -required provider contract, not currently supplied by this bare endpoint API. +required provider contract implemented by `NativeProviderTransport`. Its published +candidate uses the explicit bound interface, fixed port and pre-provisioned PEM +identity. Wildcard binds are refused until an advertised-address contract exists. Java receive queue: 128 frames of at most 10,000 bytes. Reliable assembly: at most 262,144 bytes. Java outbound hard bound: 1 MiB including pending write overhead; @@ -103,3 +106,8 @@ close/freeing native capacity. Callback-thread calls are rejected. A five-second teardown timeout is a terminal endpoint failure, never permission to admit more peers while teardown is unresolved. Endpoint termination propagates close errors. The old asynchronous close API remains available for existing direct consumers. + +The cleanup remedy passes [CI run 33829800445](https://github.com/teamziax/NetworkCompatible/actions/runs/33829800445). +The provider boundary test also verifies zero peer creation from background +profile/key setup, key rotation, rejection of join-admission control, and a fresh +incarnation when the same UDP endpoint restarts. diff --git a/warden-signalling/src/main/java/dev/kastle/warden/admission/NativeProviderTransport.java b/warden-signalling/src/main/java/dev/kastle/warden/admission/NativeProviderTransport.java new file mode 100644 index 00000000..c89c8dbc --- /dev/null +++ b/warden-signalling/src/main/java/dev/kastle/warden/admission/NativeProviderTransport.java @@ -0,0 +1,107 @@ +package dev.kastle.warden.admission; + +import com.google.gson.*; +import dev.kastle.netty.channel.nethernet.admission.*; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.util.concurrent.ScheduledFuture; +import org.cloudburstmc.netty.warden.ProviderTransport; +import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; + +/** WS2 adapter: background key/profile lifecycle only; no per-join metadata input is used. */ +public final class NativeProviderTransport implements ProviderTransport { + public static final String CAPABILITY = "dev.warden.stateless-admission.v2"; + private record Epoch(String id, long notBefore, long retireAfter) {} + private final NativeAdmissionServerChannel channel; + private final StatelessAdmissionValidator validator; + private final String incarnation; + private final ScheduledFuture retireTask; + private List epochs = List.of(); + private boolean draining, closed; + + private NativeProviderTransport(NativeAdmissionServerChannel channel, StatelessAdmissionValidator validator, String incarnation) { + this.channel = channel; this.validator = validator; this.incarnation = incarnation; + retireTask = channel.eventLoop().scheduleWithFixedDelay(() -> validator.retireKeys(System.currentTimeMillis()), 1, 1, TimeUnit.SECONDS); + } + /** The caller provisions the host PEM identity before opening/registration. No client state is accepted. */ + public static CompletionStage open(ServerBootstrap bootstrap, InetSocketAddress bind, Path certificate, Path privateKey, AdmissionGate.Limits limits) { + CompletableFuture result = new CompletableFuture<>(); + try { + NativeHostIdentity identity = NativeHostIdentity.load(certificate, privateKey); + byte[] nonce = new byte[16]; new SecureRandom().nextBytes(nonce); + String incarnation = HexFormat.of().formatHex(nonce); + var validator = new StatelessAdmissionValidator(audience(incarnation), 60_000); + var endpoint = new NativeAdmissionServerChannel(identity, validator, limits); + bootstrap.clone().channelFactory(() -> endpoint).bind(bind).addListener(future -> { + if (future.isSuccess()) result.complete(new NativeProviderTransport(endpoint, validator, incarnation)); + else { endpoint.close(); validator.clear(); result.completeExceptionally(future.cause()); } + }); + } catch (Exception failure) { result.completeExceptionally(failure); } + return result; + } + public static String audience(String incarnation) { + if (incarnation == null || !incarnation.matches("[0-9a-f]{32}")) throw new IllegalArgumentException("Invalid endpoint incarnation"); + return "warden-stateless-host-v2/" + incarnation; + } + public NativeAdmissionServerChannel channel() { return channel; } + + @Override public synchronized CompletionStage hostProfile() { + if (closed || draining || !channel.isActive()) return CompletableFuture.failedFuture(new IllegalStateException("Native endpoint unavailable")); + long now = System.currentTimeMillis(); String keyId = null; + Set installed = validator.keyIds(); + // WS2 supplies keys oldest-to-newest and acknowledges its last epoch before publication. + for (Epoch epoch : epochs) if (epoch.notBefore() <= now && epoch.retireAfter() > now && installed.contains(epoch.id())) keyId = epoch.id(); + if (keyId == null) return CompletableFuture.failedFuture(new IllegalStateException("No active background admission key")); + InetSocketAddress bind = (InetSocketAddress)channel.localAddress(); + JsonObject candidate = new JsonObject(); candidate.addProperty("address", bind.getAddress().getHostAddress()); + candidate.addProperty("port", bind.getPort()); candidate.addProperty("component", 1); candidate.addProperty("foundation", "1"); + candidate.addProperty("priority", 2130706431); candidate.addProperty("protocol", "udp"); candidate.addProperty("type", "host"); + JsonArray candidates = new JsonArray(); candidates.add(candidate); + JsonObject capability = new JsonObject(); capability.addProperty("capability", CAPABILITY); capability.addProperty("incarnation", incarnation); + JsonObject profile = new JsonObject(); profile.add("candidates", candidates); profile.add("statelessAdmission", capability); + profile.addProperty("credentialKeyId", keyId); profile.addProperty("dtlsFingerprint", channel.identity().fingerprint()); + profile.addProperty("maxMessageSize", 262144); profile.addProperty("sctpPort", 5000); + return CompletableFuture.completedFuture(profile); + } + @Override public synchronized CompletionStage installTicketKeys(List keys) { + if (closed) return CompletableFuture.failedFuture(new IllegalStateException("Native endpoint closed")); + try { + if (keys == null || keys.size() > 8) throw new IllegalArgumentException("At most eight admission epochs"); + validator.installKeys(keys.stream().map(k -> new StatelessAdmissionValidator.TicketKey(k.keyId(), k.secret(), k.notBefore(), k.retireAfter())).toList()); + epochs = keys.stream().map(k -> new Epoch(k.keyId(), k.notBefore(), k.retireAfter())).toList(); + validator.retireKeys(System.currentTimeMillis()); + return CompletableFuture.completedFuture(null); + } catch (Exception invalid) { return CompletableFuture.failedFuture(invalid); } + } + @Override public CompletionStage applyControl(JsonObject command) { + if (command == null || !command.has("kind") || !command.get("kind").isJsonPrimitive() || !command.getAsJsonPrimitive("kind").isString()) + return CompletableFuture.completedFuture(ApplyResult.REJECTED); + return switch (command.get("kind").getAsString()) { + case "noop" -> CompletableFuture.completedFuture(ApplyResult.APPLIED); + case "drain" -> drain().thenApply(ignored -> ApplyResult.APPLIED); + case "suspend", "revoke" -> close().thenApply(ignored -> ApplyResult.APPLIED); + // Native admission never stages a client from control. Unsupported lifecycle changes are explicit rejections. + default -> CompletableFuture.completedFuture(ApplyResult.REJECTED); + }; + } + @Override public List pollEvents() { + return channel.pollEvents().stream().map(event -> { + JsonObject result = new JsonObject(); result.addProperty("ticketId", event.ticketId()); result.addProperty("stage", event.stage()); + result.addProperty("reason", event.reason()); result.addProperty("occurredAt", Instant.ofEpochMilli(event.occurredAt()).toString()); + return result; + }).toList(); + } + @Override public synchronized CompletionStage drain() { + draining = true; channel.drainAdmissions(); return CompletableFuture.completedFuture(null); + } + @Override public synchronized CompletionStage close() { + if (!closed) { + closed = true; draining = true; retireTask.cancel(false); validator.clear(); epochs = List.of(); channel.close(); + } + return channel.termination(); + } +} diff --git a/warden-signalling/src/main/java/dev/kastle/warden/admission/StatelessAdmissionValidator.java b/warden-signalling/src/main/java/dev/kastle/warden/admission/StatelessAdmissionValidator.java index 42a20bd0..305018f2 100644 --- a/warden-signalling/src/main/java/dev/kastle/warden/admission/StatelessAdmissionValidator.java +++ b/warden-signalling/src/main/java/dev/kastle/warden/admission/StatelessAdmissionValidator.java @@ -45,6 +45,14 @@ public synchronized void installKeys(List snapshot) { previous.values().forEach(Material::erase); } + public synchronized void retireKeys(long nowMillis) { + Map retained = new HashMap<>(); + for (var entry : keys.entrySet()) { + if (entry.getValue().retireAfter() <= nowMillis) entry.getValue().erase(); + else retained.put(entry.getKey(), entry.getValue()); + } + keys = Map.copyOf(retained); + } public boolean ready() { return !keys.isEmpty(); } public Set keyIds() { return keys.keySet(); } public synchronized void clear() { keys.values().forEach(Material::erase); keys = Map.of(); } diff --git a/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java index a65a5307..5a02167d 100644 --- a/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java +++ b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java @@ -138,4 +138,35 @@ static void rakPing(int port) throws Exception { System.out.println("native-adapter PASS fixedUdp=49190 hostCreations=1 channels=3 replayRejected=true perJoinControl=0 cleanup=true raknetPong=49191"); } finally { endpoint.close().awaitUninterruptibly();rak.close().awaitUninterruptibly();group.shutdownGracefully(0,2,TimeUnit.SECONDS).sync();rakGroup.shutdownGracefully(0,2,TimeUnit.SECONDS).sync(); } } + @Test @Timeout(30) void providerBoundaryPublishesFreshBootIdentityWithoutClientState() throws Exception { + var id = identity(); var group = new DefaultEventLoopGroup(1); + ServerBootstrap bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInboundHandlerAdapter()); + NativeProviderTransport transport = null; + try { + long creations = PeerConnection.nativeCreationAttempts(); + transport = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1",49196), id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get(5,TimeUnit.SECONDS); + assertTrue(transport.hostProfile().toCompletableFuture().isCompletedExceptionally()); + transport.installTicketKeys(List.of(new org.cloudburstmc.netty.warden.ProviderTransport.TicketKey("K001",FakeWarden.SECRET))).toCompletableFuture().get(); + var first = transport.hostProfile().toCompletableFuture().get(); + assertEquals(id.fingerprint(),first.get("dtlsFingerprint").getAsString()); + assertEquals(49196,first.getAsJsonArray("candidates").get(0).getAsJsonObject().get("port").getAsInt()); + String incarnation = first.getAsJsonObject("statelessAdmission").get("incarnation").getAsString(); + assertTrue(incarnation.matches("[0-9a-f]{32}")); + var command = new com.google.gson.JsonObject();command.addProperty("kind","join-admission"); + assertEquals(org.cloudburstmc.netty.warden.ProviderTransport.ApplyResult.REJECTED,transport.applyControl(command).toCompletableFuture().get()); + assertEquals(0,transport.channel().admissionStats().claims());assertEquals(0,transport.channel().nativeStats()[2]); + assertEquals(creations,PeerConnection.nativeCreationAttempts()); + transport.installTicketKeys(List.of(new org.cloudburstmc.netty.warden.ProviderTransport.TicketKey("K001",FakeWarden.SECRET,0,System.currentTimeMillis()+60_000), + new org.cloudburstmc.netty.warden.ProviderTransport.TicketKey("K002","next-background-key-of-at-least-32-bytes"))).toCompletableFuture().get(); + assertEquals("K002",transport.hostProfile().toCompletableFuture().get().get("credentialKeyId").getAsString()); + transport.drain().toCompletableFuture().get();assertTrue(transport.hostProfile().toCompletableFuture().isCompletedExceptionally()); + transport.close().toCompletableFuture().get(5,TimeUnit.SECONDS); + transport = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1",49196), id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get(5,TimeUnit.SECONDS); + transport.installTicketKeys(List.of(new org.cloudburstmc.netty.warden.ProviderTransport.TicketKey("K001",FakeWarden.SECRET))).toCompletableFuture().get(); + String restarted = transport.hostProfile().toCompletableFuture().get().getAsJsonObject("statelessAdmission").get("incarnation").getAsString(); + assertNotEquals(incarnation,restarted);assertNotEquals(NativeProviderTransport.audience(incarnation),NativeProviderTransport.audience(restarted)); + assertEquals(creations,PeerConnection.nativeCreationAttempts()); + } finally { if (transport != null) transport.close().toCompletableFuture().get(5,TimeUnit.SECONDS);group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } + } + } From 14590853e1b0d9ccd9e87d37afc555fc111212b1 Mon Sep 17 00:00:00 2001 From: Zulu Date: Fri, 4 Sep 2026 03:47:32 +0100 Subject: [PATCH 4/9] Add isolated native host and client bench processes --- warden-signalling/build.gradle.kts | 4 + .../admission/NativeAdmissionBench.java | 105 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionBench.java diff --git a/warden-signalling/build.gradle.kts b/warden-signalling/build.gradle.kts index e12684e7..05771cb5 100644 --- a/warden-signalling/build.gradle.kts +++ b/warden-signalling/build.gradle.kts @@ -13,6 +13,10 @@ tasks.jar { manifest.attributes["Automatic-Module-Name"] = "org.cloudburstmc.net tasks.test { useJUnitPlatform { excludeTags("native") } } +tasks.register("nativeBenchClasspath") { + dependsOn(tasks.testClasses) + doLast { println(sourceSets.test.get().runtimeClasspath.asPath) } +} tasks.register("nativeAdmissionTest") { description = "Real fixed-UDP stateless host integration against the pinned JNI library" testClassesDirs = sourceSets.test.get().output.classesDirs diff --git a/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionBench.java b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionBench.java new file mode 100644 index 00000000..7c211385 --- /dev/null +++ b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionBench.java @@ -0,0 +1,105 @@ +package dev.kastle.warden.admission; + +import com.google.gson.*; +import dev.kastle.netty.channel.nethernet.admission.*; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; +import org.cloudburstmc.netty.warden.ProviderTransport; +import tel.schich.libdatachannel.*; +import java.io.*; +import java.net.*; +import java.nio.ByteBuffer; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +/** Separate-process loopback bench. Host input is one background key snapshot, before any offer exists. */ +public final class NativeAdmissionBench { + private static final Gson JSON = new Gson(); + private static synchronized void emit(String kind, Object value) { + JsonObject event = new JsonObject(); event.addProperty("kind", kind); event.add("value", JSON.toJsonTree(value)); + System.out.println(JSON.toJson(event)); System.out.flush(); + } + public static void main(String[] args) throws Exception { + if (args[0].equals("host")) host(args); else if (args[0].equals("client")) client(); + else throw new IllegalArgumentException("host or client"); + } + private static void host(String[] args) throws Exception { + // No stdin reader, HTTP client, lookup store or offer input remains after background setup. + JsonObject key; + try (var input = new BufferedReader(new InputStreamReader(System.in))) { + key = JsonParser.parseString(input.readLine()).getAsJsonObject(); + if (input.readLine() != null) throw new IllegalArgumentException("Only one background key snapshot permitted"); + } + var group = new DefaultEventLoopGroup(2); AtomicInteger delivered = new AtomicInteger(); + AtomicReference failure = new AtomicReference<>(); + ServerBootstrap bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInitializer() { + @Override protected void initChannel(AdmittedNetherNetChildChannel child) { + child.pipeline().addLast(new SimpleChannelInboundHandler() { + boolean reliable = true; + @Override public void userEventTriggered(ChannelHandlerContext ctx, Object event) { + if (event instanceof NetherNetPacket.Delivery delivery) reliable = delivery.reliable(); + } + @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf data) { + delivered.getAndUpdate(mask -> mask | (reliable ? 1 : 2)); + ctx.writeAndFlush(new NetherNetPacket(data.retainedDuplicate(), reliable)); + } + @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable error) { failure.set(error); ctx.close(); } + }); + } + }); + NativeProviderTransport host = null; + try { + host = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1", Integer.parseInt(args[1])), Path.of(args[2]), Path.of(args[3]), new AdmissionGate.Limits(4,8,2,10_000)).toCompletableFuture().get(5, TimeUnit.SECONDS); + host.installTicketKeys(List.of(new ProviderTransport.TicketKey(key.get("keyId").getAsString(), key.get("secret").getAsString()))).toCompletableFuture().get(); + key = null; + emit("profile", host.hostProfile().toCompletableFuture().get()); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(100); + Path stop = Path.of(args[4]); + while (!Files.exists(stop)) { + if (System.nanoTime() > deadline) throw new IllegalStateException("Bench host deadline"); + if (failure.get() != null) throw new IllegalStateException("Host pipeline failure", failure.get()); + var endpoint = host.channel(); + emit("stats", Map.of("admission", endpoint.admissionStats(), "native", endpoint.nativeStats(), "nativeCreationAttempts", PeerConnection.nativeCreationAttempts(), "hostCreations", endpoint.creationAttempts(), "deliveredChannels", delivered.get())); + for (var event : endpoint.pollEvents()) emit("stage", Map.of("stage", event.stage(), "validationToCreationNanos", event.validationToCreationNanos())); + Thread.sleep(50); + } + host.close().toCompletableFuture().get(6, TimeUnit.SECONDS); + try (var reuse = new DatagramSocket(new InetSocketAddress("127.0.0.1", Integer.parseInt(args[1])))) { + emit("closed", Map.of("udpReleased", reuse.getLocalPort() == Integer.parseInt(args[1]))); + } + } finally { if (host != null) host.close().toCompletableFuture().get(6, TimeUnit.SECONDS); group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } + } + private static void client() throws Exception { + var configuration = PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(InetAddress.getByName("127.0.0.1")); + PeerConnection peer = PeerConnection.createPeer(configuration, Runnable::run); + try (var input = new BufferedReader(new InputStreamReader(System.in))) { + CountDownLatch echoes = new CountDownLatch(2); AtomicReference failure = new AtomicReference<>(); + for (boolean reliable : new boolean[]{true, false}) { + var channel = peer.createDataChannel(reliable ? "ReliableDataChannel" : "UnreliableDataChannel", DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(!reliable,!reliable,0,0))); + byte[] payload = new byte[reliable ? 20013 : 7]; Arrays.fill(payload, (byte)(reliable ? 31 : 47)); + var decoder = new NetherNetFrameDecoder(); + channel.onMessage.register(DataChannelCallback.Message.handleBinary((dc, bytes) -> { + try { + byte[] frame = new byte[bytes.remaining()]; bytes.get(frame); byte[] message = decoder.decode(frame,reliable); + if (message != null) { if (!Arrays.equals(payload,message)) throw new IllegalStateException("Echo payload mismatch"); echoes.countDown(); } + } catch (Throwable error) { failure.set(error); } + })); + channel.onOpen.register(dc -> { + int chunks = (payload.length+9998)/9999; + for (int i=0;i Date: Fri, 4 Sep 2026 03:52:36 +0100 Subject: [PATCH 5/9] Keep native teardown failures distinct from Netty channel closure --- docs/native-admission.md | 6 +++++ .../AdmittedNetherNetChildChannel.java | 27 +++++++++++++++---- .../NativeAdmissionServerChannel.java | 15 +++++++---- .../admission/NativeAdmissionWriteTest.java | 15 +++++++++++ 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/native-admission.md b/docs/native-admission.md index 8deba41d..39d30a2e 100644 --- a/docs/native-admission.md +++ b/docs/native-admission.md @@ -111,3 +111,9 @@ The cleanup remedy passes [CI run 33829800445](https://github.com/teamziax/Netwo The provider boundary test also verifies zero peer creation from background profile/key setup, key rotation, rejection of join-admission control, and a fresh incarnation when the same UDP endpoint restarts. + +`NativeAdmissionWriteTest.nettyClosureCannotHideNativeTeardownFailure` reproduces +a second cleanup pitfall: Netty's `closeFuture` succeeds even when `doClose` +throws. Capacity now follows a separate native termination result. Failed teardown +retains the native capacity count, drains admission and fails the endpoint; it +cannot turn into apparent successful cleanup through Netty's close notification. diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java index fb684cdf..45301a54 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java @@ -11,6 +11,9 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; /** Native admission child with bounded queues and both NetherNet channel semantics. */ public final class AdmittedNetherNetChildChannel extends NetherNetChildChannel { @@ -19,12 +22,18 @@ private record Incoming(byte[] bytes, boolean reliable) {} private final ArrayBlockingQueue incoming = new ArrayBlockingQueue<>(INBOUND_FRAMES); private final NetherNetFrameDecoder decoder = new NetherNetFrameDecoder(); private final AtomicBoolean failed = new AtomicBoolean(); + private final CompletableFuture nativeTermination = new CompletableFuture<>(); + private final Consumer nativeCloser; private ScheduledFuture tick; private volatile boolean installed; private boolean activated, readDemand; public AdmittedNetherNetChildChannel(Channel parent, PeerConnection peer, InetSocketAddress remote, InetSocketAddress local) { + this(parent, peer, remote, local, AdmittedNetherNetChildChannel::closeNativePeer); + } + AdmittedNetherNetChildChannel(Channel parent, PeerConnection peer, InetSocketAddress remote, InetSocketAddress local, Consumer nativeCloser) { super(parent, peer, remote, local); + this.nativeCloser = nativeCloser; config().setWriteBufferWaterMark(new WriteBufferWaterMark(WRITE_LIMIT / 4, WRITE_LIMIT / 2)); } @Override protected void doRegister() { @@ -134,13 +143,21 @@ private static ByteBuf payload(Object message) { if (tick != null) { tick.cancel(false); tick = null; } // Native close waits for callbacks. Never hold the monitor used by acceptDataChannel here. try { - if (peer != null && !peer.closeAndAwait(java.time.Duration.ofSeconds(5))) { - peer.close(); - throw new IllegalStateException("Native transport teardown did not complete within its deadline"); - } + nativeCloser.accept(peer); + } catch (RuntimeException | Error failure) { + nativeTermination.completeExceptionally(failure); throw failure; + } finally { + incoming.clear(); decoder.clear(); + } + nativeTermination.complete(null); + } + private static void closeNativePeer(PeerConnection peer) { + if (peer != null && !peer.closeAndAwait(java.time.Duration.ofSeconds(5))) { + peer.close(); + throw new IllegalStateException("Native transport teardown did not complete within its deadline"); } - finally { incoming.clear(); decoder.clear(); } } + public CompletionStage nativeTermination() { return nativeTermination; } void closeUnregistered() { doClose(); } public int queuedFrames() { return incoming.size(); } public int retainedAssemblyBytes() { return decoder.retainedBytes(); } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java index e5a90fd0..21ef4f5d 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java @@ -65,7 +65,7 @@ private void pump() { if (mux.failure() != null || nativeCloseFailure.get() != null) { close(); return; } for (AdmissionGate.Reservation r : gate.sweep(System.currentTimeMillis(), System.nanoTime())) finish(r, "timeout"); // Limit creation work per tick, independent of packet rate and native callback rate. - for (int i = 0; i < 4 && liveNativePeers.get() < maxNativePeers; i++) { var r = pending.poll(); if (r == null) break; create(r); } + for (int i = 0; i < 4 && nativeCloseFailure.get() == null && liveNativePeers.get() < maxNativePeers; i++) { var r = pending.poll(); if (r == null) break; create(r); } for (Session session : new ArrayList<>(sessions.values())) { if (session.failed || !session.child.isOpen()) { finish(session.reservation, "closed"); continue; } if (!session.reported && session.child.isActive()) { @@ -91,10 +91,12 @@ private void create(AdmissionGate.Reservation reservation) { Session session = new Session(reservation, child); allocated = session; liveNativePeers.incrementAndGet(); nativeClosures.add(session.closed); session.closed.whenComplete((ignored, failure) -> { - if (failure != null) nativeCloseFailure.compareAndSet(null, failure); - nativeClosures.remove(session.closed); liveNativePeers.decrementAndGet(); + if (failure != null) { nativeCloseFailure.compareAndSet(null, failure); gate.drain(); } + else liveNativePeers.decrementAndGet(); + nativeClosures.remove(session.closed); }); - child.closeFuture().addListener(future -> { if (future.isSuccess()) session.closed.complete(null); else session.closed.completeExceptionally(future.cause()); }); + // Netty closeFuture signals channel closure even when doClose failed. + child.nativeTermination().whenComplete((ignored, failure) -> { if (failure == null) session.closed.complete(null); else session.closed.completeExceptionally(failure); }); peer.onStateChange.register((p, state) -> { if (state == PeerState.RTC_FAILED || state == PeerState.RTC_CLOSED) session.failed = true; }); peer.onDataChannel.register((p, dc) -> { if (session.failed) return; @@ -114,7 +116,10 @@ private void create(AdmissionGate.Reservation reservation) { } catch (Exception failure) { gate.finish(reservation); sessions.remove(reservation); if (allocated != null) closeChild(allocated); - else if (peer != null) peer.close(); + else if (peer != null) { + try { if (!peer.closeAndAwait(java.time.Duration.ofSeconds(5))) throw new IllegalStateException("Unregistered native cleanup timeout"); } + catch (Exception failedClose) { nativeCloseFailure.compareAndSet(null, failedClose); gate.drain(); peer.close(); } + } emit(reservation, "ticket.failed", "native_creation_failed", System.nanoTime()); } } diff --git a/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java index 4c959e7b..d8da8904 100644 --- a/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java +++ b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java @@ -9,6 +9,21 @@ import static org.junit.jupiter.api.Assertions.*; class NativeAdmissionWriteTest { + @Test void nettyClosureCannotHideNativeTeardownFailure() throws Exception { + var group = new DefaultEventLoopGroup(1); + var failure = new IllegalStateException("deterministically stalled native teardown"); + var channel = new AdmittedNetherNetChildChannel(null,null,new InetSocketAddress(1),new InetSocketAddress(2), peer -> { throw failure; }); + try { + group.register(channel).sync(); + ChannelFuture close = channel.close().await(); + assertSame(failure,close.cause()); + assertTrue(channel.closeFuture().await().isSuccess(),"Netty closure alone conceals teardown failure"); + var terminal = channel.nativeTermination().toCompletableFuture(); + assertTrue(terminal.isCompletedExceptionally()); + assertSame(failure,assertThrows(java.util.concurrent.CompletionException.class,terminal::join).getCause()); + assertEquals(0,channel.queuedFrames());assertEquals(0,channel.retainedAssemblyBytes()); + } finally { group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } + } @Test void preHandshakeWritesAreBoundedPromisesFailAndBuffersReleaseOnClose() throws Exception { var group = new DefaultEventLoopGroup(1); var channel = new AdmittedNetherNetChildChannel(null,null,new InetSocketAddress(1),new InetSocketAddress(2)); From 3c346c681396e0d7743467ac816a798f331dfb71 Mon Sep 17 00:00:00 2001 From: Zulu Date: Fri, 4 Sep 2026 03:59:50 +0100 Subject: [PATCH 6/9] Pin native completion after final transport destruction --- docs/native-admission.md | 5 +++++ gradle.properties | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/native-admission.md b/docs/native-admission.md index 39d30a2e..6a789c7a 100644 --- a/docs/native-admission.md +++ b/docs/native-admission.md @@ -117,3 +117,8 @@ a second cleanup pitfall: Netty's `closeFuture` succeeds even when `doClose` throws. Capacity now follows a separate native termination result. Failed teardown retains the native capacity count, drains admission and fails the endpoint; it cannot turn into apparent successful cleanup through Netty's close notification. + +Worker CI run 33830909678 additionally retained an ICE agent through a transport +reference outside the teardown task. The native pinned regression now holds that +reference explicitly. Completion follows actual destruction of all transports, +not just release of the teardown task references, with zero-agent reuse retained. diff --git a/gradle.properties b/gradle.properties index 25ef9b9b..c37b943b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ # Only update version on publishing to Maven Central version=1.8.0 -wardenNativeVersion=0.24.1.1-warden.0812c7eb01427ff561e4d589d8724a48e06e0da1 +wardenNativeVersion=0.24.1.1-warden.5544964002162d184bacfcd0cb8d70d86ec3f271 From b24f37354b6b4b23573a38ad5334f0941ad754db Mon Sep 17 00:00:00 2001 From: Zulu Date: Fri, 4 Sep 2026 04:08:06 +0100 Subject: [PATCH 7/9] Build runtime project jars before emitting native bench classpath --- warden-signalling/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/warden-signalling/build.gradle.kts b/warden-signalling/build.gradle.kts index 05771cb5..b3ec0c82 100644 --- a/warden-signalling/build.gradle.kts +++ b/warden-signalling/build.gradle.kts @@ -15,6 +15,8 @@ tasks.jar { manifest.attributes["Automatic-Module-Name"] = "org.cloudburstmc.net tasks.test { useJUnitPlatform { excludeTags("native") } } tasks.register("nativeBenchClasspath") { dependsOn(tasks.testClasses) + // Printing a classpath does not otherwise make Gradle build its project JARs. + dependsOn(sourceSets.test.get().runtimeClasspath) doLast { println(sourceSets.test.get().runtimeClasspath.asPath) } } tasks.register("nativeAdmissionTest") { From 81bda580ff4de034b34783e97687fdbfa61589f9 Mon Sep 17 00:00:00 2001 From: Zulu Date: Fri, 4 Sep 2026 04:10:41 +0100 Subject: [PATCH 8/9] Verify concurrent native clients respect capacity through teardown --- .../NativeAdmissionIntegrationTest.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java index 5a02167d..489ae908 100644 --- a/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java +++ b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionIntegrationTest.java @@ -169,4 +169,47 @@ static void rakPing(int port) throws Exception { } finally { if (transport != null) transport.close().toCompletableFuture().get(5,TimeUnit.SECONDS);group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } } + @Test @Timeout(40) void simultaneousClientsRespectNativeCapacityUntilActualTeardown() throws Exception { + var id=identity();var group=new DefaultEventLoopGroup(2);var clients=new ArrayList(); + var children=new CopyOnWriteArrayList(); + var validator=new StatelessAdmissionValidator(FakeWarden.AUDIENCE,60000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001",FakeWarden.SECRET))); + var endpoint=new NativeAdmissionServerChannel(id,validator,new AdmissionGate.Limits(2,4,2,10000)); + try { + new ServerBootstrap().group(group).channelFactory(()->endpoint).childHandler(new ChannelInitializer() { + @Override protected void initChannel(AdmittedNetherNetChildChannel child) { children.add(child); } + }).bind("127.0.0.1",49198).sync(); + var answers=new ArrayList();var opens=new AtomicIntegerArray(3); + for(int i=0;i<3;i++) { + final int index=i; + var client=PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(InetAddress.getByName("127.0.0.1")),Runnable::run); + clients.add(client); + for(boolean reliable:new boolean[]{true,false}) { + var dc=client.createDataChannel(reliable?"ReliableDataChannel":"UnreliableDataChannel",DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(!reliable,!reliable,0,0))); + dc.onOpen.register(ignored->opens.incrementAndGet(index)); + } + client.setLocalDescription("offer","multiClient"+i,"p".repeat(32)); + answers.add(FakeWarden.answer(client.localDescription(),id.fingerprint(),49198,System.currentTimeMillis()+30000,FakeWarden.AUDIENCE,false)); + } + long before=PeerConnection.nativeCreationAttempts(); + clients.get(0).setRemoteDescription(answers.get(0).sdp(),SessionDescriptionType.ANSWER); + clients.get(1).setRemoteDescription(answers.get(1).sdp(),SessionDescriptionType.ANSWER); + await(()->opens.get(0)==2 && opens.get(1)==2); + assertEquals(2,endpoint.liveNativePeers());assertEquals(2,endpoint.nativeStats()[2]); + clients.get(2).setRemoteDescription(answers.get(2).sdp(),SessionDescriptionType.ANSWER); + await(()->endpoint.admissionStats().capacityRejected()>0); + assertEquals(2,endpoint.creationAttempts());assertEquals(before+2,PeerConnection.nativeCreationAttempts());assertEquals(2,endpoint.admissionStats().claims()); + var closing=children.get(0);closing.close().sync();closing.nativeTermination().toCompletableFuture().get(5,TimeUnit.SECONDS); + // Third client's normal ICE retries can claim the released slot; used tokens remain tombstoned. + await(()->{assertTrue(endpoint.nativeStats()[2]<=2);assertTrue(endpoint.liveNativePeers()<=2);return opens.get(2)==2;}); + assertEquals(3,endpoint.creationAttempts());assertEquals(before+3,PeerConnection.nativeCreationAttempts()); + assertEquals(2,endpoint.liveNativePeers());assertEquals(2,endpoint.nativeStats()[2]);assertEquals(3,endpoint.admissionStats().claims()); + System.out.println("native-capacity PASS simultaneousClients=2 thirdRetriesAfterTeardown=true maxNativePeers=2"); + } finally { + for(var client:clients) assertTrue(client.closeAndAwait(Duration.ofSeconds(5))); + endpoint.close().awaitUninterruptibly();endpoint.termination().toCompletableFuture().get(6,TimeUnit.SECONDS); + group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); + } + } + } From d3b8bf7aa483a70441045d0ec4c9bab652904fb4 Mon Sep 17 00:00:00 2001 From: Zulu Date: Fri, 4 Sep 2026 04:52:04 +0100 Subject: [PATCH 9/9] Expose redacted native event IDs for Worker outcome conformance --- .../dev/kastle/warden/admission/NativeAdmissionBench.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionBench.java b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionBench.java index 7c211385..ab239638 100644 --- a/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionBench.java +++ b/warden-signalling/src/test/java/dev/kastle/warden/admission/NativeAdmissionBench.java @@ -64,7 +64,9 @@ private static void host(String[] args) throws Exception { if (failure.get() != null) throw new IllegalStateException("Host pipeline failure", failure.get()); var endpoint = host.channel(); emit("stats", Map.of("admission", endpoint.admissionStats(), "native", endpoint.nativeStats(), "nativeCreationAttempts", PeerConnection.nativeCreationAttempts(), "hostCreations", endpoint.creationAttempts(), "deliveredChannels", delivered.get())); - for (var event : endpoint.pollEvents()) emit("stage", Map.of("stage", event.stage(), "validationToCreationNanos", event.validationToCreationNanos())); + for (var event : endpoint.pollEvents()) emit("stage", Map.of("stage", event.stage(), "ticketId", event.ticketId(), + "occurredAt", java.time.Instant.ofEpochMilli(event.occurredAt()).toString(), "reason", event.reason(), + "validationToCreationNanos", event.validationToCreationNanos())); Thread.sleep(50); } host.close().toCompletableFuture().get(6, TimeUnit.SECONDS);