Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
124 changes: 124 additions & 0 deletions docs/native-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 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. `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

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 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;
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.

## 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.

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.

`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.

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.
3 changes: 2 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Only update version on publishing to Maven Central
version=1.8.0
version=1.8.0
wardenNativeVersion=0.24.1.1-warden.5544964002162d184bacfcd0cb8d70d86ec3f271
18 changes: 18 additions & 0 deletions scripts/bootstrap-native-admission.sh
Original file line number Diff line number Diff line change
@@ -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}"
3 changes: 2 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ plugins {
}

include("transport-raknet")
include("transport-nethernet")
include("transport-nethernet")
include("warden-signalling")
3 changes: 2 additions & 1 deletion transport-nethernet/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Reservation> claims = new HashMap<>();
private final Map<InetSocketAddress, Reservation> 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<Reservation> 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<Reservation> sweep(long nowMillis, long nowNanos) {
List<Reservation> 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<Reservation> close() {
closed = true;
List<Reservation> 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); }
}
Original file line number Diff line number Diff line change
@@ -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<AdmissionPrincipal> KEY = AttributeKey.valueOf(AdmissionPrincipal.class, "principal");
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading