From 04ac4abb4d4e9d6f369c38de78ef2a40952ddecc Mon Sep 17 00:00:00 2001 From: Zulu Date: Sat, 5 Sep 2026 21:09:22 +0100 Subject: [PATCH 01/15] Add attributed NetherNet transport prerequisites Port transport-nethernet at Kas-tle/NetworkCompatible 9f3c0b7e72fb6f8934a7d36a518c74afe02c8a6d onto Cloudburst develop. Preserve Cloudburst codecs, RakNet and publishing configuration. The module build uses the existing upstream plugins. Co-authored-by: Kas-tle <26531652+Kas-tle@users.noreply.github.com> --- gradle/libs.versions.toml | 5 +- settings.gradle.kts | 2 + transport-nethernet/README.md | 45 +++ transport-nethernet/build.gradle.kts | 30 ++ .../channel/nethernet/NetherNetChannel.java | 273 +++++++++++++ .../nethernet/NetherNetChannelFactory.java | 45 +++ .../nethernet/NetherNetChildChannel.java | 32 ++ .../nethernet/NetherNetClientChannel.java | 367 ++++++++++++++++++ .../channel/nethernet/NetherNetConstants.java | 180 +++++++++ .../nethernet/NetherNetServerChannel.java | 298 ++++++++++++++ .../config/DefaultNetherChannelConfig.java | 66 ++++ .../DefaultNetherClientChannelConfig.java | 59 +++ .../DefaultNetherServerChannelConfig.java | 47 +++ .../nethernet/config/NetherChannelOption.java | 36 ++ .../nethernet/config/NetherNetAddress.java | 54 +++ .../nethernet/config/package-info.java | 1 + .../netty/channel/nethernet/package-info.java | 1 + .../AbstractNetherNetXboxSignaling.java | 265 +++++++++++++ .../signaling/NetherNetClientSignaling.java | 34 ++ .../signaling/NetherNetDiscovery.java | 322 +++++++++++++++ .../NetherNetDiscoverySignaling.java | 171 ++++++++ .../signaling/NetherNetServerSignaling.java | 130 +++++++ .../signaling/NetherNetSignaling.java | 88 +++++ .../signaling/NetherNetXboxRpcSignaling.java | 212 ++++++++++ .../signaling/NetherNetXboxSignaling.java | 108 ++++++ .../nethernet/signaling/package-info.java | 1 + .../util/nethernet/NetherNetScanner.java | 80 ++++ .../netty/util/nethernet/package-info.java | 1 + 28 files changed, 2952 insertions(+), 1 deletion(-) create mode 100644 transport-nethernet/README.md create mode 100644 transport-nethernet/build.gradle.kts create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92ffd9cb..2482e7a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,13 @@ [versions] netty = "4.1.101.Final" junit = "5.9.2" +gson = "2.13.2" [libraries] +netty-codec-http = { group = "io.netty", name = "netty-codec-http", version.ref = "netty" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +webrtc-java = { group = "dev.kastle.webrtc", name = "webrtc-java", version = "1.0.3" } netty-common = { group = "io.netty", name = "netty-common", version.ref = "netty" } netty-buffer = { group = "io.netty", name = "netty-buffer", version.ref = "netty" } netty-codec = { group = "io.netty", name = "netty-codec", version.ref = "netty" } @@ -26,4 +30,3 @@ junit = [ "junit-jupiter-engine", "junit-jupiter-api", "junit-jupiter-params" ] [plugins] - diff --git a/settings.gradle.kts b/settings.gradle.kts index 6b2129ce..b4fb593e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,3 +21,5 @@ plugins { } include("transport-raknet") + +include("transport-nethernet") diff --git a/transport-nethernet/README.md b/transport-nethernet/README.md new file mode 100644 index 00000000..788e5b65 --- /dev/null +++ b/transport-nethernet/README.md @@ -0,0 +1,45 @@ +# netty-transport-nethernet + +## Downloads + +### Releases ![Maven Central Version](https://img.shields.io/maven-central/v/dev.kastle.netty/netty-transport-nethernet?label=Maven%20Central&color=%233fb950) + +The library is published to Maven Central. See the [latest release](https://github.com/Kas-tle/NetworkCompatible/releases/latest) for the latest version. + +### Snapshots [![](https://jitpack.io/v/dev.kastle/NetworkCompatible.svg)](https://jitpack.io/#dev.kastle/NetworkCompatible) + +Snapshots are available from [jitpack](https://jitpack.io/#dev.kastle/NetworkCompatible). Note the package group for jitpack is `dev.kastle.NetworkCompatible` witht the name `netty-transport-nethernet`. + +## Usage + +> [!IMPORTANT] +> This library requires the platform-specific WebRTC native libraries at runtime. See [Kas-tle/webrtc-java](https://github.com/Kas-tle/webrtc-java?tab=readme-ov-file#usage) for instructions on how to include the native libraries in your project. + +### Examples + +These projects use this library to provide Nethernet support. You can see their source code for examples of how to use this library: + +- [Kas-tle/ProxyPass](https://github.com/Kas-tle/ProxyPass): Uses server and client to debug game packets over various connection types. +- [MCXboxBroadcast/Broadcaster](https://github.com/MCXboxBroadcast/Broadcaster): Uses server to allow Bedrock clients to transfer to other Bedrock servers via Xbox Live. +- [ViaVersion/ViaFabricPlus](https://github.com/ViaVersion/ViaFabricPlus): Uses client to connect to LAN games and Realms. +- [ViaVersion/ViaProxy](https://github.com/ViaVersion/ViaProxy): Uses client to connect to LAN games and Realms. + +## Packet Flow + +### Client + +--- + + + + + + +### Server + +--- + + + + + \ No newline at end of file diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts new file mode 100644 index 00000000..f0838485 --- /dev/null +++ b/transport-nethernet/build.gradle.kts @@ -0,0 +1,30 @@ +description = "NetherNet transport for Netty" + +dependencies { + api(libs.bundles.netty) + api(libs.netty.codec.http) + api(libs.expiringmap) + api(libs.webrtc.java) + + implementation(libs.gson) + + testImplementation(libs.bundles.junit) + testRuntimeOnly(libs.junit.platform.launcher) +} + +configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + withJavadocJar() + withSourcesJar() +} + +tasks.jar { + manifest.attributes["Automatic-Module-Name"] = "dev.kastle.netty.transport.nethernet" +} + +tasks.register("runDiscovery") { + mainClass.set("dev.kastle.netty.util.nethernet.NetherNetScanner") + classpath = sourceSets["main"].runtimeClasspath +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java new file mode 100644 index 00000000..3318d4d9 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -0,0 +1,273 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; +import dev.kastle.webrtc.RTCDataChannel; +import dev.kastle.webrtc.RTCDataChannelBuffer; +import dev.kastle.webrtc.RTCDataChannelObserver; +import dev.kastle.webrtc.RTCDataChannelState; +import dev.kastle.webrtc.RTCPeerConnection; +import io.netty.buffer.ByteBuf; +import io.netty.channel.AbstractChannel; +import io.netty.channel.Channel; +import io.netty.channel.ChannelConfig; +import io.netty.channel.ChannelMetadata; +import io.netty.channel.ChannelOutboundBuffer; +import io.netty.channel.EventLoop; +import io.netty.util.ReferenceCountUtil; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public abstract class NetherNetChannel extends AbstractChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetChannel.class); + protected static final ChannelMetadata METADATA = new ChannelMetadata(false); + + protected DefaultNetherChannelConfig config; + protected volatile RTCPeerConnection peerConnection; + protected volatile SocketAddress remoteAddress; + protected volatile SocketAddress localAddress; + + protected RTCDataChannel reliableChannel; + protected RTCDataChannel unreliableChannel; + + protected final Queue pendingWrites = new ConcurrentLinkedQueue<>(); + + protected volatile boolean open = true; + + protected NetherNetChannel(Channel parent, InetSocketAddress remote, InetSocketAddress local) { + super(parent); + this.remoteAddress = remote; + this.localAddress = local; + } + + public void setDataChannels(RTCDataChannel reliable, RTCDataChannel unreliable) { + this.reliableChannel = reliable; + this.unreliableChannel = unreliable; + + RTCDataChannelObserver observer = new RTCDataChannelObserver() { + private final ByteBuf assemblyBuf = config.getAllocator().buffer(); + private int currentSegmentCount = -1; + + @Override + public void onBufferedAmountChange(long previousAmount) { + } + + @Override + public void onStateChange() { + eventLoop().execute(() -> onDataChannelStateChange()); + } + + @Override + public void onMessage(RTCDataChannelBuffer buffer) { + ByteBuffer data = buffer.data; + if (!data.hasRemaining()) + return; + + int segments = data.get() & 0xFF; + + if (currentSegmentCount == -1) { + currentSegmentCount = segments; + } else { + if (segments != currentSegmentCount - 1) { + assemblyBuf.clear(); + currentSegmentCount = -1; + return; + } + currentSegmentCount = segments; + } + + if (data.hasRemaining()) { + byte[] payload = new byte[data.remaining()]; + data.get(payload); + assemblyBuf.writeBytes(payload); + } + + if (segments == 0) { + try { + if (assemblyBuf.isReadable()) { + ByteBuf packet = assemblyBuf.copy(); + assemblyBuf.skipBytes(assemblyBuf.readableBytes()); + + eventLoop().execute(() -> { + pipeline().fireChannelRead(packet); + pipeline().fireChannelReadComplete(); + }); + } + } catch (Exception e) { + log.error("Error processing packet", e); + } finally { + assemblyBuf.clear(); + currentSegmentCount = -1; + } + } + } + }; + + this.reliableChannel.registerObserver(observer); + + if (reliableChannel.getState() == RTCDataChannelState.OPEN) { + eventLoop().execute(this::onDataChannelStateChange); + } + } + + private void onDataChannelStateChange() { + if (isActive()) { + if (!pendingWrites.isEmpty()) { + pipeline().fireChannelWritabilityChanged(); + unsafe().flush(); + } + } else if (reliableChannel.getState() == RTCDataChannelState.CLOSED) { + close(); + } + } + + @Override + protected void doWrite(ChannelOutboundBuffer in) throws Exception { + if (!isActive()) { + Object msg; + while ((msg = in.current()) != null) { + ReferenceCountUtil.retain(msg); + pendingWrites.add(msg); + in.remove(); + } + return; + } + + while (!pendingWrites.isEmpty()) { + Object msg = pendingWrites.poll(); + try { + writeInternal(msg); + } finally { + ReferenceCountUtil.release(msg); + } + } + + Object msg; + while ((msg = in.current()) != null) { + writeInternal(msg); + in.remove(); + } + } + + private void writeInternal(Object msg) { + if (!(msg instanceof ByteBuf)) + return; + + ByteBuf payload = (ByteBuf) msg; + + ByteBuf framed = payload.retainedDuplicate(); + + int totalLength = framed.readableBytes(); + int maxPayload = NetherNetConstants.MAX_SCTP_MESSAGE_SIZE - 1; + + int segments = (totalLength / maxPayload); + if (totalLength % maxPayload != 0) + segments++; + + try { + int offset = 0; + for (int i = 0; i < segments; i++) { + int remaining = segments - 1 - i; + int chunkSize = Math.min(maxPayload, framed.readableBytes() - offset); + + ByteBuffer chunk = ByteBuffer.allocateDirect(1 + chunkSize); + chunk.put((byte) remaining); + + framed.getBytes(offset, chunk); + chunk.position(chunk.limit()); + chunk.flip(); + + reliableChannel.send(new RTCDataChannelBuffer(chunk, true)); + offset += chunkSize; + } + } catch (Exception e) { + pipeline().fireExceptionCaught(e); + } finally { + framed.release(); + } + } + + @Override + protected void doRegister() throws Exception { + } + + @Override + protected void doDeregister() throws Exception { + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + throw new UnsupportedOperationException("NetherNetChannel cannot be bound directly"); + } + + @Override + protected void doDisconnect() throws Exception { + doClose(); + } + + @Override + protected void doClose() throws Exception { + this.open = false; + + if (reliableChannel != null) { + reliableChannel.unregisterObserver(); + reliableChannel.close(); + } + if (unreliableChannel != null) { + unreliableChannel.unregisterObserver(); + unreliableChannel.close(); + } + if (peerConnection != null) { + peerConnection.close(); + } + + Object msg; + while ((msg = pendingWrites.poll()) != null) { + ReferenceCountUtil.release(msg); + } + } + + @Override + protected void doBeginRead() throws Exception { + } + + @Override + protected boolean isCompatible(EventLoop loop) { + return true; + } + + @Override + protected SocketAddress localAddress0() { + return this.localAddress; + } + + @Override + protected SocketAddress remoteAddress0() { + return this.remoteAddress; + } + + @Override + public ChannelConfig config() { + return this.config; + } + + @Override + public boolean isOpen() { + return this.open; + } + + @Override + public boolean isActive() { + return isOpen() && this.reliableChannel != null && this.reliableChannel.getState() == RTCDataChannelState.OPEN; + } + + @Override + public ChannelMetadata metadata() { + return METADATA; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java new file mode 100644 index 00000000..82c3cc04 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -0,0 +1,45 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; +import dev.kastle.webrtc.PeerConnectionFactory; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFactory; + +import java.util.function.Supplier; + +public class NetherNetChannelFactory implements ChannelFactory { + + private final Supplier channelCreator; + + private NetherNetChannelFactory(Supplier channelCreator) { + this.channelCreator = channelCreator; + } + + @Override + public T newChannel() { + return channelCreator.get(); + } + + /** + * Creates a NetherNet Server Channel Factory. + * + * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * @param signaling The NetherNetServerSignaling instance for signaling. + * @return A ChannelFactory for NetherNetServerChannel. + */ + public static ChannelFactory server(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { + return new NetherNetChannelFactory<>(() -> new NetherNetServerChannel(factory, signaling)); + } + + /** + * Creates a NetherNet Client Channel Factory. + * + * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * @param signaling The NetherNetClientSignaling instance for signaling. + * @return A ChannelFactory for NetherNetClientChannel. + */ + public static ChannelFactory client(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { + return new NetherNetChannelFactory<>(() -> new NetherNetClientChannel(factory, signaling)); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java new file mode 100644 index 00000000..c324e4ee --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java @@ -0,0 +1,32 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; +import dev.kastle.webrtc.RTCPeerConnection; +import io.netty.channel.Channel; +import io.netty.channel.ChannelPromise; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; + +public class NetherNetChildChannel extends NetherNetChannel { + public NetherNetChildChannel(Channel parent, RTCPeerConnection peerConnection, InetSocketAddress remote, InetSocketAddress local) { + super(parent, remote, local); + this.peerConnection = peerConnection; + this.config = new DefaultNetherChannelConfig(this); + } + + @Override + protected AbstractUnsafe newUnsafe() { + return new AbstractUnsafe() { + @Override + public void connect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) { + promise.setFailure(new UnsupportedOperationException("Child channel cannot connect")); + } + }; + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + throw new UnsupportedOperationException("Child channel cannot be bound"); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java new file mode 100644 index 00000000..87493233 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -0,0 +1,367 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherClientChannelConfig; +import dev.kastle.netty.channel.nethernet.config.NetherChannelOption; +import dev.kastle.netty.channel.nethernet.config.NetherNetAddress; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling; +import dev.kastle.webrtc.CreateSessionDescriptionObserver; +import dev.kastle.webrtc.PeerConnectionFactory; +import dev.kastle.webrtc.PeerConnectionObserver; +import dev.kastle.webrtc.RTCBundlePolicy; +import dev.kastle.webrtc.RTCConfiguration; +import dev.kastle.webrtc.RTCDataChannel; +import dev.kastle.webrtc.RTCDataChannelBuffer; +import dev.kastle.webrtc.RTCDataChannelInit; +import dev.kastle.webrtc.RTCDataChannelObserver; +import dev.kastle.webrtc.RTCDataChannelState; +import dev.kastle.webrtc.RTCIceCandidate; +import dev.kastle.webrtc.RTCIceServer; +import dev.kastle.webrtc.RTCOfferOptions; +import dev.kastle.webrtc.RTCPeerConnectionState; +import dev.kastle.webrtc.RTCSdpType; +import dev.kastle.webrtc.RTCSessionDescription; +import dev.kastle.webrtc.SetSessionDescriptionObserver; +import io.netty.channel.ChannelPromise; +import io.netty.util.ReferenceCountUtil; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.channels.ClosedChannelException; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +public class NetherNetClientChannel extends NetherNetChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetClientChannel.class); + + private final PeerConnectionFactory factory; + private final NetherNetClientSignaling signaling; + + private volatile long connectionId; // Session ID (Long) + private volatile String targetNetworkId; // Peer ID (String, for Realms) + + private volatile boolean handshakeComplete = false; + + private ChannelPromise connectPromise; + + private volatile ScheduledFuture handshakeTimeoutTask; + + private int retryCount = 0; + + /** + * Creates a NetherNetClientChannel with a new PeerConnectionFactory. + * + * @param signaling The NetherNetClientSignaling instance for signaling. + */ + public NetherNetClientChannel(NetherNetClientSignaling signaling) { + this(new PeerConnectionFactory(), signaling); + } + + /** + * Creates a NetherNetClientChannel. + * + * @param factory The PeerConnectionFactory to use. Should be reused where possible. + * @param signaling The NetherNetClientSignaling instance for signaling. + */ + public NetherNetClientChannel(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { + super(null, null, null); + this.factory = factory; + this.signaling = signaling; + this.connectionId = this.cycleConnectionId(); + this.config = new DefaultNetherClientChannelConfig(this); + } + + public void setTargetNetworkId(String id) { + this.targetNetworkId = id; + } + + @Override + public boolean isActive() { + return super.isActive() && handshakeComplete; + } + + @Override + protected void doClose() throws Exception { + super.doClose(); + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); + } + if (signaling != null) { + signaling.removeSignalHandler(this.connectionId); + signaling.close(); + } + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ClosedChannelException()); + } + } + + @Override + protected AbstractUnsafe newUnsafe() { + return new NetherNetClientUnsafe(); + } + + private class NetherNetClientUnsafe extends AbstractUnsafe { + @Override + public void connect(SocketAddress remote, SocketAddress local, ChannelPromise promise) { + if (!promise.setUncancellable() || !ensureOpen(promise)) return; + NetherNetClientChannel.this.connectPromise = promise; + + if (remote instanceof NetherNetAddress) { + String targetId = ((NetherNetAddress) remote).getNetworkId(); + NetherNetClientChannel.this.setTargetNetworkId(targetId); + NetherNetClientChannel.this.remoteAddress = remote; + } else if (remote instanceof InetSocketAddress) { + NetherNetClientChannel.this.remoteAddress = (InetSocketAddress) remote; + NetherNetClientChannel.this.setTargetNetworkId("0"); // "0" triggers auto-discovery in signaling + } else { + promise.setFailure(new IllegalArgumentException("Unsupported address: " + remote.getClass())); + return; + } + + eventLoop().execute(() -> startHandshake()); + } + } + + private void startHandshake() { + if (!isOpen() || handshakeComplete) return; + + log.debug("Starting Handshake with Connection ID: {}", Long.toUnsignedString(this.connectionId)); + + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + + signaling.setNotFoundHandler(reason -> { + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Target Network ID " + this.targetNetworkId + " not found or offline.")); + } + close(); + }); + + int handshakeTimeout = this.config().getOption(NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS); + handshakeTimeoutTask = eventLoop().schedule(() -> { + resetAndRetryHandshake(); + }, handshakeTimeout, TimeUnit.MILLISECONDS); + + signaling.setSignalHandler(this.connectionId, this::handleSignal); + + signaling.connect(remoteAddress).thenAcceptAsync(iceServers -> { + if (handshakeComplete) return; + try { + // If this is a retry, peerConnection might be null, so we recreate it + if (peerConnection == null) { + initWebRTC(iceServers); + createAndSendOffer(); + } + } catch (Exception e) { + ConnectException ce = new ConnectException("Failed to start WebRTC handshake: " + e.getMessage()); + ce.initCause(e); + if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(ce); + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + close(); + } + }, eventLoop()).exceptionally(e -> { + ConnectException ce = new ConnectException("Signaling connection failed: " + e.getMessage()); + ce.initCause(e); + if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(ce); + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + close(); + return null; + }); + } + + private void resetAndRetryHandshake() { + if (!isOpen()) return; + if (connectPromise != null && connectPromise.isDone() && !connectPromise.isSuccess()) return; + if (handshakeComplete) return; + + // fail exceptionally if max retries reached + int maxRetries = this.config().getOption(NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS); + if (retryCount >= maxRetries) { + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Connection timed out after " + retryCount + " retries")); + } + close(); + return; + } + + retryCount++; + + if (peerConnection != null) { + peerConnection.close(); + peerConnection = null; + } + + signaling.removeSignalHandler(this.connectionId); + this.cycleConnectionId(); + startHandshake(); + } + + private void initWebRTC(List iceServers) { + RTCConfiguration rtcConfig = new RTCConfiguration(); + rtcConfig.portAllocatorConfig = this.config.getOption(NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG); + rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; + + if (iceServers != null) { + for (NetherNetSignaling.IceServerInfo info : iceServers) { + RTCIceServer iceServer = new RTCIceServer(); + iceServer.urls = info.urls(); + iceServer.username = info.username(); + iceServer.password = info.password(); + rtcConfig.iceServers.add(iceServer); + } + } + + peerConnection = factory.createPeerConnection(rtcConfig, new PeerConnectionObserver() { + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + try { + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate.sdp) + ); + } catch (Exception e) { + log.error("Failed to send ICE candidate", e); + eventLoop().execute(() -> resetAndRetryHandshake()); + } + } + + @Override + public void onConnectionChange(RTCPeerConnectionState state) { + if (state == RTCPeerConnectionState.FAILED) { + // Fast fail trigger: retry immediately instead of waiting for timeout + log.warn("PeerConnection entered FAILED state, resetting and retrying handshake."); + eventLoop().execute(() -> resetAndRetryHandshake()); + } else { + log.trace("PeerConnection state changed to {}", state); + } + } + + @Override public void onDataChannel(RTCDataChannel dataChannel) { } + }); + + setupDataChannels(); + } + + private void createAndSendOffer() { + if (peerConnection == null) return; + peerConnection.createOffer(new RTCOfferOptions(), new CreateSessionDescriptionObserver() { + @Override + public void onSuccess(RTCSessionDescription description) { + if (peerConnection == null) return; + peerConnection.setLocalDescription(description, new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + try { + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalConnectRequest(connectionId, description.sdp) + ); + } catch (Exception e) { + log.error("Failed to send Connect Request", e); + eventLoop().execute(() -> resetAndRetryHandshake()); + } + } + @Override public void onFailure(String error) { /* Retry handled by timeout */ } + }); + } + @Override public void onFailure(String error) { /* Retry handled by timeout */ } + }); + } + + private void handleSignal(String signal) { + String[] parts = signal.split(" ", 3); + if (parts.length < 2) return; // Allow length 2 for ERROR packets without payload + String type = parts[0]; + String idStr = parts[1].trim(); + String data = parts.length > 2 ? parts[2] : ""; + + // Verify this signal belongs to the current attempt + try { + long signalId = Long.parseUnsignedLong(idStr); + if (signalId != this.connectionId) { + log.debug("Ignored stale signal for ID {}", idStr); + return; + } + } catch (NumberFormatException e) { + return; + } + + eventLoop().execute(() -> { + if (peerConnection == null) return; + if (!isOpen() || handshakeComplete) return; + + switch (type) { + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_RESPONSE -> { + peerConnection.setRemoteDescription(new RTCSessionDescription(RTCSdpType.ANSWER, data), new SetSessionDescriptionObserver() { + @Override public void onSuccess() {} + @Override public void onFailure(String e) { /* Retry handled by timeout */ } + }); + } + case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { + peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); + } + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_ERROR -> { + log.error("Received SIGNAL_CONNECT_ERROR for {}.", Long.toUnsignedString(this.connectionId)); + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Remote peer sent connect error.")); + } + close(); + } + default -> { + log.debug("Received unknown signal type: {}", type); + } + } + }); + } + + private void setupDataChannels() { + RTCDataChannelInit reliableInit = new RTCDataChannelInit(); + reliableInit.ordered = true; + reliableInit.protocol = NetherNetConstants.RELIABLE_CHANNEL_LABEL; + + RTCDataChannelInit unreliableInit = new RTCDataChannelInit(); + unreliableInit.ordered = false; + unreliableInit.maxRetransmits = 0; + + RTCDataChannel reliable = peerConnection.createDataChannel(NetherNetConstants.RELIABLE_CHANNEL_LABEL, reliableInit); + RTCDataChannel unreliable = peerConnection.createDataChannel(NetherNetConstants.UNRELIABLE_CHANNEL_LABEL, unreliableInit); + + reliable.registerObserver(new RTCDataChannelObserver() { + @Override + public void onStateChange() { + if (reliable.getState() == RTCDataChannelState.OPEN) { + eventLoop().execute(() -> { + if (!handshakeComplete) { + log.debug("NetherNet Connection Established!"); + handshakeComplete = true; + + // Cancel timeout now that we are done + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); + } + + setDataChannels(reliable, unreliable); + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.trySuccess(); + } + pipeline().fireChannelActive(); + } + }); + } + } + @Override public void onBufferedAmountChange(long previousAmount) {} + @Override public void onMessage(RTCDataChannelBuffer buffer) { + ReferenceCountUtil.release(buffer); + } + }); + } + + private long cycleConnectionId() { + this.connectionId = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE); + return this.connectionId; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java new file mode 100644 index 00000000..dcc6a0f3 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -0,0 +1,180 @@ +package dev.kastle.netty.channel.nethernet; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.MessageDigest; + +public class NetherNetConstants { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetConstants.class); + + public static final int DISCOVERY_PORT = 7551; + public static final long APPLICATION_ID = 0xDEADBEEFL; + + // Packet IDs + public static final int ID_DISCOVERY_REQUEST = 0x00; + public static final int ID_DISCOVERY_RESPONSE = 0x01; + public static final int ID_DISCOVERY_MESSAGE = 0x02; + + // WebRTC Negotiation Message Types + public static final String RTC_NEGOTIATION_CONNECT_REQUEST = "CONNECTREQUEST"; + public static final String RTC_NEGOTIATION_CONNECT_RESPONSE = "CONNECTRESPONSE"; + public static final String RTC_NEGOTIATION_CANDIDATE_ADD = "CANDIDATEADD"; + public static final String RTC_NEGOTIATION_CONNECT_ERROR = "CONNECTERROR"; + + // Signaling User Agent String + public static final String SIGNALING_USER_AGENT = "libHttpClient/1.0.0.0"; + + // Xbox Signaling Message Types + public static final int XBOX_SIGNAL_NOT_FOUND = 0; + public static final int XBOX_SIGNAL_SIGNAL = 1; + public static final int XBOX_SIGNAL_CREDENTIALS = 2; + public static final int XBOX_SIGNAL_ACCEPTED = 3; + public static final int XBOX_SIGNAL_ACK = 4; + + // Xbox JSON-RPC Signaling Method Names + public static final String XBOX_RPC_METHOD_TURN_AUTH = "Signaling_TurnAuth_v1_0"; + public static final String XBOX_RPC_METHOD_SEND_MESSAGE = "Signaling_SendClientMessage_v1_0"; + public static final String XBOX_RPC_METHOD_RECEIVE_MESSAGE = "Signaling_ReceiveMessage_v1_0"; + public static final String XBOX_RPC_METHOD_PING = "System_Ping_v1_0"; + public static final String XBOX_RPC_METHOD_PONG = "System_Pong_v1_0"; + public static final String XBOX_RPC_INNER_METHOD_WEBRTC = "Signaling_WebRtc_v1_0"; + public static final String XBOX_RPC_INNER_METHOD_DELIVERY = "Signaling_DeliveryNotification_V1_0"; + + // SCTP Constants + public static final int MAX_SCTP_MESSAGE_SIZE = 10000; + public static final String RELIABLE_CHANNEL_LABEL = "ReliableDataChannel"; + public static final String UNRELIABLE_CHANNEL_LABEL = "UnreliableDataChannel"; + + private static final byte[] KEY_BYTES; + + static { + try { + ByteBuf buf = Unpooled.buffer(8); + buf.writeLongLE(APPLICATION_ID); + byte[] input = new byte[8]; + buf.readBytes(input); + buf.release(); + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + KEY_BYTES = digest.digest(input); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Encrypts a discovery packet using AES encryption and HMAC-SHA256 for integrity. + * + * @param packet The ByteBuf containing the discovery packet to encrypt. + * @return The encrypted byte array ready for transmission. + * @throws Exception if encryption fails. + */ + public static byte[] encryptDiscoveryPacket(ByteBuf packet) throws Exception { + int len = packet.readableBytes() + 2; + ByteBuf payload = Unpooled.buffer(len); + payload.writeShortLE(len); + payload.writeBytes(packet); + + byte[] payloadBytes = new byte[payload.readableBytes()]; + payload.readBytes(payloadBytes); + payload.release(); + + SecretKeySpec secretKey = new SecretKeySpec(KEY_BYTES, "AES"); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + byte[] encrypted = cipher.doFinal(payloadBytes); + + Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); + SecretKeySpec secret_key = new SecretKeySpec(KEY_BYTES, "HmacSHA256"); + sha256_HMAC.init(secret_key); + byte[] signature = sha256_HMAC.doFinal(payloadBytes); + + ByteBuf result = Unpooled.buffer(signature.length + encrypted.length); + result.writeBytes(signature); + result.writeBytes(encrypted); + + byte[] out = new byte[result.readableBytes()]; + result.readBytes(out); + result.release(); + return out; + } + + /** + * Decrypts a discovery packet and verifies its integrity. + * + * @param input The ByteBuf containing the received discovery packet. + * @return A ByteBuf with the decrypted payload, or null if verification fails. + * @throws Exception if decryption fails. + */ + public static ByteBuf decryptDiscoveryPacket(ByteBuf input) throws Exception { + if (input.readableBytes() < 32) { + log.debug("Discovery packet too short to contain valid signature"); + return null; + }; + + byte[] signature = new byte[32]; + input.readBytes(signature); + + byte[] encrypted = new byte[input.readableBytes()]; + input.readBytes(encrypted); + + SecretKeySpec secretKey = new SecretKeySpec(KEY_BYTES, "AES"); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + byte[] payloadBytes = cipher.doFinal(encrypted); + + Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); + SecretKeySpec secret_key = new SecretKeySpec(KEY_BYTES, "HmacSHA256"); + sha256_HMAC.init(secret_key); + byte[] calculatedSignature = sha256_HMAC.doFinal(payloadBytes); + + if (!MessageDigest.isEqual(signature, calculatedSignature)) { + log.debug("Invalid discovery packet signature"); + return null; + } + + ByteBuf payload = Unpooled.wrappedBuffer(payloadBytes); + payload.readUnsignedShortLE(); // Length prefix + + return payload; + } + + /** + * Builds a signaling message for a CONNECTREQUEST. + * + * @param connectionId The unique connection ID. + * @param sdp The SDP payload. + * @return The formatted signaling message. + */ + public static String buildSignalConnectRequest(long connectionId, String sdp) { + return RTC_NEGOTIATION_CONNECT_REQUEST + " " + Long.toUnsignedString(connectionId) + " " + sdp; + } + + /** + * Builds a signaling message for a CONNECTRESPONSE. + * + * @param connectionId The unique connection ID. + * @param sdp The SDP payload. + * @return The formatted signaling message. + */ + public static String buildSignalConnectResponse(long connectionId, String sdp) { + return RTC_NEGOTIATION_CONNECT_RESPONSE + " " + Long.toUnsignedString(connectionId) + " " + sdp; + } + + /** + * Builds a signaling message for a CANDIDATEADD. + * + * @param connectionId The unique connection ID. + * @param candidateSdp The candidate SDP string. + * @return The formatted signaling message. + */ + public static String buildSignalCandidateAdd(long connectionId, String candidateSdp) { + return RTC_NEGOTIATION_CANDIDATE_ADD + " " + Long.toUnsignedString(connectionId) + " " + candidateSdp; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java new file mode 100644 index 00000000..1eef7f7b --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -0,0 +1,298 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherServerChannelConfig; +import dev.kastle.netty.channel.nethernet.config.NetherChannelOption; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo; +import dev.kastle.webrtc.CreateSessionDescriptionObserver; +import dev.kastle.webrtc.PeerConnectionFactory; +import dev.kastle.webrtc.PeerConnectionObserver; +import dev.kastle.webrtc.RTCAnswerOptions; +import dev.kastle.webrtc.RTCBundlePolicy; +import dev.kastle.webrtc.RTCConfiguration; +import dev.kastle.webrtc.RTCDataChannel; +import dev.kastle.webrtc.RTCIceCandidate; +import dev.kastle.webrtc.RTCIceServer; +import dev.kastle.webrtc.RTCPeerConnection; +import dev.kastle.webrtc.RTCPeerConnectionState; +import dev.kastle.webrtc.RTCSdpType; +import dev.kastle.webrtc.RTCSessionDescription; +import dev.kastle.webrtc.SetSessionDescriptionObserver; +import io.netty.channel.AbstractServerChannel; +import io.netty.channel.ChannelConfig; +import io.netty.channel.ChannelMetadata; +import io.netty.channel.EventLoop; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class NetherNetServerChannel extends AbstractServerChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetServerChannel.class); + private static final ChannelMetadata METADATA = new ChannelMetadata(false, 16); + + private final DefaultNetherServerChannelConfig config; + private final PeerConnectionFactory factory; + private final NetherNetServerSignaling signaling; + + private InetSocketAddress localAddress; + private volatile boolean open = true; + + /** + * Creates a NetherNetServerChannel with a new PeerConnectionFactory. + * + * @param signaling The NetherNetServerSignaling instance for signaling. + */ + public NetherNetServerChannel(NetherNetServerSignaling signaling) { + this(new PeerConnectionFactory(), signaling); + } + + /** + * Creates a NetherNetServerChannel. + * + * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * @param signaling The NetherNetServerSignaling instance for signaling. + */ + public NetherNetServerChannel(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { + this.factory = factory; + this.signaling = signaling; + this.config = new DefaultNetherServerChannelConfig(this); + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + if (!(localAddress instanceof InetSocketAddress)) throw new IllegalArgumentException("Unsupported address type"); + this.localAddress = (InetSocketAddress) localAddress; + + this.signaling.setNewConnectionHandler((connectionId, remoteNetworkId, offerSdp) -> { + acceptConnection(connectionId, offerSdp, remoteNetworkId); + }); + + this.signaling.bind(localAddress); + } + + public void acceptConnection(long connectionId, String offerSdp, String remoteNetworkId) { + RTCConfiguration rtcConfig = new RTCConfiguration(); + rtcConfig.portAllocatorConfig = this.config.getOption(NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG); + rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; + + // Inject ICE servers if the signaling implementation supports it + List iceServers = this.signaling.getIceServers(); + if (iceServers != null && !iceServers.isEmpty()) { + log.trace("Injecting {} ICE Servers into PeerConnection for {}", iceServers.size(), Long.toUnsignedString(connectionId)); + for (IceServerInfo info : iceServers) { + RTCIceServer iceServer = new RTCIceServer(); + iceServer.urls = info.urls(); + iceServer.username = info.username(); + iceServer.password = info.password(); + rtcConfig.iceServers.add(iceServer); + } + } + + ServerPeerConnectionObserver observer = new ServerPeerConnectionObserver(connectionId, remoteNetworkId); + RTCPeerConnection pc = factory.createPeerConnection(rtcConfig, observer); + + NetherNetChildChannel child = new NetherNetChildChannel(this, pc, new InetSocketAddress(0), localAddress); + observer.setChildChannel(child); + + child.closeFuture().addListener(future -> signaling.removeSignalHandler(connectionId)); + + int handshakeTimeoutSeconds = this.config.getOption(NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS); + ScheduledFuture timeoutTask = eventLoop().schedule(() -> { + if (!child.isActive()) { + log.warn("Connection {} timed out during handshake ({}s)", Long.toUnsignedString(connectionId), handshakeTimeoutSeconds); + child.close(); + pc.close(); + } + }, handshakeTimeoutSeconds, TimeUnit.SECONDS); + observer.setHandshakeTimeout(timeoutTask); + + // Register Signal Handler + signaling.setSignalHandler(connectionId, (signal) -> { + String[] parts = signal.split(" ", 3); + if (parts.length < 3) return; + String type = parts[0]; + String data = parts[2]; + + switch (type) { + case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { + log.trace("Applying Remote Candidate for {}: {}", Long.toUnsignedString(connectionId), data); + try { + pc.addIceCandidate(new RTCIceCandidate("0", 0, data)); + } catch (Exception e) { + log.debug("Failed to apply ICE candidate for {} (Connection likely closed): {}", Long.toUnsignedString(connectionId), e.toString()); + } + } + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_ERROR -> { + log.debug("Received CONNECT_ERROR for {}", Long.toUnsignedString(connectionId)); + child.close(); + } + } + }); + + // Handle Offer + pc.setRemoteDescription(new RTCSessionDescription(RTCSdpType.OFFER, offerSdp), new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + log.trace("Remote description set for {}", Long.toUnsignedString(connectionId)); + pc.createAnswer(new RTCAnswerOptions(), new CreateSessionDescriptionObserver() { + @Override + public void onSuccess(RTCSessionDescription description) { + pc.setLocalDescription(description, new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + log.trace("Sending Answer SDP for {}", Long.toUnsignedString(connectionId)); + signaling.sendSignal( + remoteNetworkId, + NetherNetConstants.buildSignalConnectResponse(connectionId, description.sdp) + ); + pipeline().fireChannelRead(child); + } + @Override public void onFailure(String error) { log.error("SetLocalDesc failed: {}", error); } + }); + } + @Override public void onFailure(String error) { log.error("CreateAnswer failed: {}", error); } + }); + } + @Override public void onFailure(String error) { log.error("SetRemoteDesc failed: {}", error); } + }); + } + + /** + * Observer to handle Data Channel creation from the client. + */ + private class ServerPeerConnectionObserver implements PeerConnectionObserver { + private final long connectionId; + private final String remoteNetworkId; + private NetherNetChildChannel child; + + private RTCDataChannel reliable; + private RTCDataChannel unreliable; + + private ScheduledFuture handshakeTimeout; + + public ServerPeerConnectionObserver(long connectionId, String remoteNetworkId) { + this.connectionId = connectionId; + this.remoteNetworkId = remoteNetworkId; + } + + public void setHandshakeTimeout(ScheduledFuture handshakeTimeout) { + this.handshakeTimeout = handshakeTimeout; + } + + public void setChildChannel(NetherNetChildChannel child) { + this.child = child; + checkDataChannels(); + } + + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + if (log.isTraceEnabled()) { + log.trace("Generated ICE Candidate for {}: {} (Type: {})", + Long.toUnsignedString(this.connectionId), candidate.sdp, extractCandidateType(candidate.sdp)); + } + signaling.sendSignal( + remoteNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate.sdp) + ); + } + + private String extractCandidateType(String sdp) { + if (sdp.contains(" typ host ")) return "host"; + if (sdp.contains(" typ srflx ")) return "srflx"; + if (sdp.contains(" typ relay ")) return "relay"; + return "unknown"; + } + + @Override + public void onConnectionChange(RTCPeerConnectionState state) { + log.debug("Connection {} state changed: {}", Long.toUnsignedString(this.connectionId), state); + if (state == RTCPeerConnectionState.FAILED || state == RTCPeerConnectionState.CLOSED) { + if (child != null && child.isOpen()) { + log.debug("Closing connection {} due to state change: {}", Long.toUnsignedString(this.connectionId), state); + child.close(); + } + if (handshakeTimeout != null) { + handshakeTimeout.cancel(false); + } + } + } + + @Override + public void onDataChannel(RTCDataChannel dataChannel) { + String label = dataChannel.getLabel(); + log.debug("Received Data Channel: {}", label); + + if (NetherNetConstants.RELIABLE_CHANNEL_LABEL.equals(label)) { + this.reliable = dataChannel; + } else if (NetherNetConstants.UNRELIABLE_CHANNEL_LABEL.equals(label)) { + this.unreliable = dataChannel; + } + + checkDataChannels(); + } + + private void checkDataChannels() { + if (child != null && reliable != null && unreliable != null) { + if (handshakeTimeout != null) { + handshakeTimeout.cancel(false); + } + + log.debug("Data Channels established for {}", Long.toUnsignedString(this.connectionId)); + child.setDataChannels(reliable, unreliable); + + if (child.pipeline() != null) { + child.pipeline().fireChannelActive(); + } + } + } + } + + @Override + protected void doClose() throws Exception { + this.open = false; + + try { + signaling.close(); + } finally { + factory.dispose(); + } + } + + @Override + protected void doBeginRead() throws Exception { + // Server channel doesn't read data directly + } + + @Override + protected SocketAddress localAddress0() { + return this.localAddress; + } + + @Override + protected boolean isCompatible(EventLoop loop) { + return true; + } + + @Override + public ChannelConfig config() { return config; } + + @Override + public boolean isOpen() { + return this.open; + } + + @Override + public boolean isActive() { + return isOpen() && localAddress0() != null; + } + + @Override + public ChannelMetadata metadata() { + return METADATA; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java new file mode 100644 index 00000000..74cb0f92 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java @@ -0,0 +1,66 @@ +package dev.kastle.netty.channel.nethernet.config; + +import dev.kastle.webrtc.PortAllocatorConfig; +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; +import io.netty.channel.DefaultChannelConfig; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class DefaultNetherChannelConfig extends DefaultChannelConfig { + private final Map, Object> options = new ConcurrentHashMap<>(); + + private volatile PortAllocatorConfig portAllocatorConfig = new PortAllocatorConfig() + .setDisableTcp(true) + .setEnableIpv6(true) + .setEnableIpv6OnWifi(true) + .setEnableAnyAddressPorts(true) + .setDisableAdapterEnumeration(false) + .setEnableSharedSocket(true) + .setEnableAnyAddressPorts(true) + .setDisableCostlyNetworks(true) + .setDisableLinkLocalNetworks(true); + + public DefaultNetherChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), + NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG + ); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + + if (option == NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG) { + return (T) this.portAllocatorConfig; + } else if (options.containsKey(option)) { + return (T) options.get(option); + } + + return super.getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + if (option == NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG) { + this.setPortAllocatorConfig((PortAllocatorConfig) value); + return true; + } else if (super.setOption(option, value)) { + return true; + } else { + options.put(option, value); + return true; + } + } + + void setPortAllocatorConfig(PortAllocatorConfig portAllocatorConfig) { + this.portAllocatorConfig = portAllocatorConfig; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java new file mode 100644 index 00000000..7c5456cb --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java @@ -0,0 +1,59 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; + +import java.util.Map; + +public class DefaultNetherClientChannelConfig extends DefaultNetherChannelConfig { + private volatile int clientHandshakeTimeoutMs = 3000; + private volatile int maxHandshakeAttempts = 3; + + public DefaultNetherClientChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), + NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS, + NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS + ); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + if (option == NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS) { + return (T) Integer.valueOf(this.clientHandshakeTimeoutMs); + } else if (option == NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS) { + return (T) Integer.valueOf(this.maxHandshakeAttempts); + } + + return super.getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + this.validate(option, value); + + if (option == NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS) { + this.setClientHandshakeTimeoutMs((Integer) value); + return true; + } else if (option == NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS) { + this.setMaxHandshakeAttempts((Integer) value); + return true; + } else { + return super.setOption(option, value); + } + } + + void setClientHandshakeTimeoutMs(int clientHandshakeTimeoutMs) { + this.clientHandshakeTimeoutMs = clientHandshakeTimeoutMs; + } + + void setMaxHandshakeAttempts(int maxHandshakeAttempts) { + this.maxHandshakeAttempts = maxHandshakeAttempts; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java new file mode 100644 index 00000000..f683068c --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java @@ -0,0 +1,47 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; + +import java.util.Map; + +public class DefaultNetherServerChannelConfig extends DefaultNetherChannelConfig { + private volatile int serverRtcHandshakeTimeoutSeconds = 30; + + public DefaultNetherServerChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS + ); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + if (option == NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS) { + return (T) Integer.valueOf(this.serverRtcHandshakeTimeoutSeconds); + } + + return super.getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + this.validate(option, value); + + if (option == NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS) { + this.setServerRtcHandshakeTimeoutSeconds((Integer) value); + return true; + } else { + return super.setOption(option, value); + } + } + + void setServerRtcHandshakeTimeoutSeconds(int serverRtcHandshakeTimeoutSeconds) { + this.serverRtcHandshakeTimeoutSeconds = serverRtcHandshakeTimeoutSeconds; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java new file mode 100644 index 00000000..3aedcc14 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java @@ -0,0 +1,36 @@ +package dev.kastle.netty.channel.nethernet.config; + +import dev.kastle.webrtc.PortAllocatorConfig; +import io.netty.channel.ChannelOption; + +public class NetherChannelOption extends ChannelOption { + + /** + * The PortAllocatorConfig used for WebRTC connections. + */ + public static final ChannelOption NETHER_PORT_ALLOCATOR_CONFIG = + valueOf(NetherChannelOption.class, "NETHER_PORT_ALLOCATOR_CONFIG"); + + /** + * The timeout in seconds for completing the WebRTC handshake on the client before retrying. + */ + public static final ChannelOption NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS = + valueOf(NetherChannelOption.class, "NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS"); + + /** + * The maximum number of handshake attempts before giving up on connecting. + */ + public static final ChannelOption NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS = + valueOf(NetherChannelOption.class, "NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS"); + + /** + * The timeout in seconds for completing the WebRTC handshake on the server side before automatically closing the connection. + */ + public static final ChannelOption NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS = + valueOf(NetherChannelOption.class, "NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS"); + + @SuppressWarnings("deprecation") + protected NetherChannelOption(String name) { + super(name); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java new file mode 100644 index 00000000..a64fb9cc --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java @@ -0,0 +1,54 @@ +package dev.kastle.netty.channel.nethernet.config; + +import java.net.SocketAddress; + +public class NetherNetAddress extends SocketAddress { + private final String networkId; + + /** + * Creates a NetherNetAddress from a numeric Network ID. + * + * @param networkId The numeric Network ID. + */ + public NetherNetAddress(long networkId) { + this.networkId = Long.toUnsignedString(networkId); + } + + /** + * Creates a NetherNetAddress from a string Network ID. + * + * @param networkId The string Network ID. + */ + public NetherNetAddress(String networkId) { + this.networkId = networkId; + } + + /** + * Gets the Network ID as a String. + * + * @return the Network ID + */ + public String getNetworkId() { + return networkId; + } + + /** + * Tries to parse the Network ID as a long. + * + * @return the long value + * @throws NumberFormatException if the ID is not a valid unsigned long string (e.g. Realms ID). + */ + public long getNetworkIdAsLong() { + return Long.parseUnsignedLong(networkId); + } + + /** + * Returns the string representation of the Network ID. + * + * @return the Network ID as a string + */ + @Override + public String toString() { + return networkId; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java new file mode 100644 index 00000000..ae2ea099 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet.config; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java new file mode 100644 index 00000000..00aa908b --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java new file mode 100644 index 00000000..782d66c7 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java @@ -0,0 +1,265 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; +import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.ConnectException; +import java.net.SocketAddress; +import java.net.URI; +import java.nio.channels.ClosedChannelException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public abstract class AbstractNetherNetXboxSignaling extends SimpleChannelInboundHandler + implements NetherNetClientSignaling, NetherNetServerSignaling { + + protected final InternalLogger log = InternalLoggerFactory.getInstance(getClass()); + + protected final String xboxToken; + protected final String localNetworkId; + protected final URI uri; + protected final EventLoopGroup eventLoopGroup; + + protected Channel channel; + protected CompletableFuture> connectFuture; + protected volatile List iceServers = new ArrayList<>(); + + protected final Map handlers = new ConcurrentHashMap<>(); + protected NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; + protected volatile NetherNetClientSignaling.NotFoundHandler notFoundHandler; + + protected AbstractNetherNetXboxSignaling(String localNetworkId, String xboxToken, URI uri) { + this.localNetworkId = localNetworkId; + this.xboxToken = xboxToken; + this.uri = uri; + this.eventLoopGroup = new NioEventLoopGroup(1); + } + + @Override + public String getLocalNetworkId() { + return this.localNetworkId; + } + + @Override + public synchronized CompletableFuture> connect(SocketAddress remoteAddress) { + return connectInternal(); + } + + @Override + public void bind(SocketAddress localAddress) throws ConnectException { + try { + connectInternal().join(); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + close(); + if (cause instanceof ConnectException) throw (ConnectException) cause; + ConnectException ce = new ConnectException("Failed to connect to Xbox Signaling: " + cause.getMessage()); + ce.initCause(cause); + throw ce; + } + } + + protected synchronized CompletableFuture> connectInternal() { + if (connectFuture != null) return connectFuture; + + connectFuture = new CompletableFuture<>(); + connectFuture.thenAccept(servers -> this.iceServers = servers); + + try { + SslContext sslCtx = SslContextBuilder.forClient().build(); + WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory.newHandshaker( + uri, WebSocketVersion.V13, null, false, + new DefaultHttpHeaders() + .add("Authorization", xboxToken) + .add("User-Agent", NetherNetConstants.SIGNALING_USER_AGENT) + .add("session-id", UUID.randomUUID().toString()) + .add("request-id", UUID.randomUUID().toString()) + ); + + Bootstrap b = new Bootstrap(); + b.group(eventLoopGroup) + .channel(NioSocketChannel.class) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ChannelPipeline p = ch.pipeline(); + p.addLast(sslCtx.newHandler(ch.alloc(), uri.getHost(), 443)); + p.addLast(new HttpClientCodec(), new HttpObjectAggregator(8192)); + p.addLast("ws-handshake", new WebSocketClientProtocolHandler(handshaker)); + p.addLast("handler", AbstractNetherNetXboxSignaling.this); + } + }); + + this.channel = b.connect(uri.getHost(), 443).sync().channel(); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (connectFuture != null) connectFuture.completeExceptionally(cause); + } + return connectFuture; + } + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) { + log.debug("{} WebSocket Connected", getClass().getSimpleName()); + onConnected(ctx); + } else { + super.userEventTriggered(ctx, evt); + } + } + + /** + * Called when the WebSocket handshake is complete. + */ + protected abstract void onConnected(ChannelHandlerContext ctx); + + @Override + public List getIceServers() { + return this.iceServers; + } + + @Override + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.newConnectionHandler = handler; + } + + @Override + public void setNotFoundHandler(NotFoundHandler handler) { + this.notFoundHandler = handler; + } + + @Override + public void setSignalHandler(long connectionId, SignalHandler handler) { + this.handlers.put(connectionId, handler); + } + + @Override + public void removeSignalHandler(long connectionId) { + this.handlers.remove(connectionId); + } + + @Override + public void setAdvertisementData(PongData pongData) { + // No-op for Xbox Signaling. + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + if (connectFuture != null && !connectFuture.isDone()) { + connectFuture.completeExceptionally(cause); + } + log.error("Signaling Exception: {}", cause.getMessage(), cause); + ctx.close(); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + synchronized (this) { + if (connectFuture != null && !connectFuture.isDone()) { + connectFuture.completeExceptionally(new ClosedChannelException()); + } + connectFuture = null; + this.channel = null; + } + super.channelInactive(ctx); + } + + @Override + public void close() { + if (channel != null) channel.close(); + eventLoopGroup.shutdownGracefully(); + } + + protected void dispatchSignalToPipeline(String sender, String rawMsg) { + try { + // Signal Format: + String[] parts = rawMsg.split(" ", 3); + if (parts.length < 2) return; + + long connectionId = Long.parseUnsignedLong(parts[1]); + + SignalHandler handler = handlers.get(connectionId); + if (handler != null) { + handler.onSignal(rawMsg); + return; + } + + if (NetherNetConstants.RTC_NEGOTIATION_CONNECT_REQUEST.equals(parts[0]) && newConnectionHandler != null) { + String payload = parts.length > 2 ? parts[2] : ""; + newConnectionHandler.onConnect(connectionId, sender, payload); + } else { + log.debug("No handler found for connection ID: {} (Type: {})", connectionId, parts[0]); + } + } catch (Exception e) { + log.error("Failed to dispatch signal: {}", rawMsg, e); + } + } + + protected List parseTurnServers(JsonObject json) { + List result = new ArrayList<>(); + try { + JsonArray servers = null; + if (json.has("TurnAuthServers")) servers = json.getAsJsonArray("TurnAuthServers"); + else if (json.has("turnAuthServers")) servers = json.getAsJsonArray("turnAuthServers"); + + if (servers != null) { + for (JsonElement el : servers) { + JsonObject server = el.getAsJsonObject(); + List urls = new ArrayList<>(); + + JsonArray urlsArray = null; + if (server.has("Urls")) urlsArray = server.getAsJsonArray("Urls"); + else if (server.has("urls")) urlsArray = server.getAsJsonArray("urls"); + + if (urlsArray != null) { + urlsArray.forEach(u -> urls.add(u.getAsString())); + + IceServerInfo.Builder info = new IceServerInfo.Builder().setUrls(urls); + + if (server.has("Username")) info.setUsername(server.get("Username").getAsString()); + else if (server.has("username")) info.setUsername(server.get("username").getAsString()); + + if (server.has("Password")) info.setPassword(server.get("Password").getAsString()); + else if (server.has("password")) info.setPassword(server.get("password").getAsString()); + else if (server.has("Credential")) info.setPassword(server.get("Credential").getAsString()); + else if (server.has("credential")) info.setPassword(server.get("credential").getAsString()); + + result.add(info.build()); + } + } + } + } catch (Exception e) { + log.error("Failed to parse TURN servers", e); + } + log.debug("Successfully parsed {} ICE servers.", result.size()); + return result; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java new file mode 100644 index 00000000..6640e238 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java @@ -0,0 +1,34 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import java.net.SocketAddress; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface NetherNetClientSignaling extends NetherNetSignaling { + /** + * Connects to the signaling medium (Client mode). + * + * @param remoteAddress The address of the signaling server to connect to. + */ + CompletableFuture> connect(SocketAddress remoteAddress); + + /** + * Sets a handler to be called when a signaling message is received for an unknown connection ID. + * + * @param handler The handler to process incoming signaling messages for unknown connection IDs. + */ + void setNotFoundHandler(NotFoundHandler handler); + + /** + * Functional interface for handling "Not Found" signals. + */ + @FunctionalInterface + interface NotFoundHandler { + /** + * Called when the signaling service indicates the target peer was not found. + * + * @param reason The reason or raw message payload regarding the failure. + */ + void onNotFound(String reason); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java new file mode 100644 index 00000000..58ca7b2c --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java @@ -0,0 +1,322 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling.PongData; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.SignalHandler; +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.DatagramPacket; +import io.netty.channel.socket.nio.NioDatagramChannel; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; + +public class NetherNetDiscovery extends SimpleChannelInboundHandler { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscovery.class); + + private final long networkId; + private final Map signalHandlers = new ConcurrentHashMap<>(); + private final Map peerAddresses = new ConcurrentHashMap<>(); + private Channel channel; + private byte[] pongData; + private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; + private BiConsumer discoveryCallback; + + /** + * Creates a NetherNetDiscovery instance with the specified Network ID. + * + * @param networkId The Network ID to use for discovery. + */ + public NetherNetDiscovery(long networkId) { + this.networkId = networkId; + } + + public void bind() { + bind(NetherNetConstants.DISCOVERY_PORT); + } + + public void bind(int port) { + EventLoopGroup group = new NioEventLoopGroup(1); + try { + Bootstrap bootstrap = new Bootstrap(); + bootstrap.group(group) + .channel(NioDatagramChannel.class) + .option(ChannelOption.SO_BROADCAST, true) + .handler(this); + + this.channel = bootstrap.bind(port).sync().channel(); + log.info("NetherNet Discovery listening on port {}", port); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public void bind(InetSocketAddress address) { + EventLoopGroup group = new NioEventLoopGroup(1); + try { + Bootstrap bootstrap = new Bootstrap(); + bootstrap.group(group) + .channel(NioDatagramChannel.class) + .option(ChannelOption.SO_BROADCAST, true) + .handler(this); + + this.channel = bootstrap.bind(address).sync().channel(); + log.info("NetherNet Discovery listening on {}", address); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public void sendDiscoveryRequest(InetSocketAddress target, BiConsumer onServerFound) { + this.discoveryCallback = onServerFound; + + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_REQUEST); + buf.writeLongLE(this.networkId); + buf.writeZero(8); // Padding + + sendPacket(buf, target); + } + + public void setPongData(PongData data) { + ByteBuf buf = Unpooled.buffer(); + buf.writeByte(4); // Version + writeString(buf, data.serverName()); + writeString(buf, data.levelName()); + buf.writeByte(data.gameType() << 1); + buf.writeIntLE(data.playerCount()); + buf.writeIntLE(data.maxPlayerCount()); + buf.writeBoolean(data.isEditorWorld()); + buf.writeBoolean(data.isHardcore()); + buf.writeByte(data.transportLayer() << 1); + buf.writeByte(data.connectionType() << 1); + byte[] binaryData = new byte[buf.readableBytes()]; + buf.readBytes(binaryData); + buf.release(); + + String hex = HexFormat.of().formatHex(binaryData); + byte[] hexBytes = hex.getBytes(StandardCharsets.UTF_8); + + ByteBuf response = Unpooled.buffer(); + response.writeIntLE(hexBytes.length); + response.writeBytes(hexBytes); + + this.pongData = new byte[response.readableBytes()]; + response.readBytes(this.pongData); + response.release(); + } + + public void registerSignalHandler(long connectionId, SignalHandler handler) { + this.signalHandlers.put(connectionId, handler); + } + + public void unregisterSignalHandler(long connectionId) { + this.signalHandlers.remove(connectionId); + } + + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.newConnectionHandler = handler; + } + + /** + * Sends a signal immediately and schedules it to be resent periodically + * until the returned ScheduledFuture is cancelled. + */ + public ScheduledFuture sendSignalRetrying(InetSocketAddress recipient, long targetNetworkId, String data, long delayMs) { + return channel.eventLoop().scheduleAtFixedRate(() -> { + log.debug("Resending signal to {}: {}", recipient, data); + sendSignal(recipient, targetNetworkId, data); + }, 0, delayMs, TimeUnit.MILLISECONDS); + } + + public void sendSignal(InetSocketAddress recipient, long targetNetworkId, String data) { + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_MESSAGE); + buf.writeLongLE(this.networkId); // Sender ID + buf.writeZero(8); // Padding + + buf.writeLongLE(targetNetworkId); // Recipient ID + byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8); + buf.writeIntLE(dataBytes.length); + buf.writeBytes(dataBytes); + + sendPacket(buf, recipient); + } + + // New sendSignal looking up Address from ID + public void sendSignal(long targetNetworkId, String data) { + InetSocketAddress recipient = peerAddresses.get(targetNetworkId); + if (recipient != null) { + sendSignal(recipient, targetNetworkId, data); + } else { + throw new IllegalArgumentException("Attempted to send signal to unknown peer: " + targetNetworkId); + } + } + + private void sendPacket(ByteBuf packetData, InetSocketAddress target) { + try { + byte[] encrypted = NetherNetConstants.encryptDiscoveryPacket(packetData); + channel.writeAndFlush(new DatagramPacket(Unpooled.wrappedBuffer(encrypted), target)); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt discovery packet", e); + } finally { + packetData.release(); + } + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) throws Exception { + ByteBuf content = packet.content(); + ByteBuf decrypted = null; + try { + decrypted = NetherNetConstants.decryptDiscoveryPacket(content); + } catch (Exception e) { + log.debug("Failed to decrypt discovery packet from {}", packet.sender(), e); + return; + } + + if (decrypted == null) { + log.debug("Received invalid discovery packet from {}", packet.sender()); + return; + } + + try { + int packetId = decrypted.readUnsignedShortLE(); + long senderId = decrypted.readLongLE(); + + decrypted.skipBytes(8); // Padding + + if (senderId == this.networkId) { + log.debug("Ignoring own discovery packet"); + return; + } + + peerAddresses.put(senderId, packet.sender()); + + switch (packetId) { + case NetherNetConstants.ID_DISCOVERY_REQUEST -> { + log.trace("Handled discovery request from {}", packet.sender()); + handleRequest(senderId, packet.sender()); + } + case NetherNetConstants.ID_DISCOVERY_MESSAGE -> { + log.trace("Handled discovery message from {}", packet.sender()); + log.trace("Message Data: {}", decrypted.toString(StandardCharsets.UTF_8)); + handleMessage(decrypted, senderId); + } + case NetherNetConstants.ID_DISCOVERY_RESPONSE -> { + log.trace("Handled discovery response from {}", packet.sender()); + if (discoveryCallback != null) { + log.trace("Response Data: {}", decrypted.toString(StandardCharsets.UTF_8)); + // Pass the payload (decrypted buffer) to the callback + // We retain it because we are passing it out of the pipeline handler + discoveryCallback.accept(senderId, decrypted.retain()); + } + } + default -> { + log.debug("Received unknown discovery packet ID {} from {}", packetId, packet.sender()); + } + } + } catch (Exception e) { + log.debug("Error processing discovery packet from {}", packet.sender(), e); + } finally { + decrypted.release(); + } + } + + private void handleRequest(long senderId, InetSocketAddress sender) { + if (this.pongData == null) return; + + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_RESPONSE); + buf.writeLongLE(this.networkId); + buf.writeZero(8); + buf.writeBytes(this.pongData); + + sendPacket(buf, sender); + } + + private void handleMessage(ByteBuf data, long senderId) { + long recipientId = data.readLongLE(); + + if (recipientId != this.networkId && recipientId != 0) { + log.trace("Ignoring message intended for {}, but I am {}", recipientId, this.networkId); + return; + } + + int len = data.readIntLE(); + if (data.readableBytes() < len) { + log.trace("Malformed message: claimed length {} but only has {}", len, data.readableBytes()); + return; + } + + String messageData = data.readCharSequence(len, StandardCharsets.UTF_8).toString(); + if ("Ping".equals(messageData)) { + return; + } + + String[] parts = messageData.split(" ", 3); + if (parts.length < 2) return; + + try { + String type = parts[0]; + long connectionId = Long.parseUnsignedLong(parts[1]); + + SignalHandler handler = signalHandlers.get(connectionId); + + if (handler != null) { + handler.onSignal(messageData); + } else if (NetherNetConstants.RTC_NEGOTIATION_CONNECT_REQUEST.equals(type)) { + if (newConnectionHandler != null) { + String payload = parts.length > 2 ? parts[2] : ""; + log.trace("Dispatching New Connection: ID={} Sender={}", Long.toUnsignedString(connectionId), Long.toUnsignedString(senderId)); + newConnectionHandler.onConnect(connectionId, Long.toUnsignedString(senderId), payload); + } else { + log.debug("Received CONNECT_REQUEST but no NewConnectionHandler is set!"); + } + } else { + log.debug("Unhandled signal type: {}", type); + } + } catch (NumberFormatException e) { + log.debug("Invalid connection ID format in message: {}", messageData); + } + } + + public void close() { + if (channel != null) { + channel.close(); + } + } + + public boolean isActive() { + return channel != null && channel.isActive(); + } + + private void writeString(ByteBuf buf, String s) { + byte[] b = s.getBytes(StandardCharsets.UTF_8); + this.writeUnsignedVarInt(buf, b.length); + buf.writeBytes(b); + } + + private void writeUnsignedVarInt(ByteBuf buf, int value) { + while ((value & 0xFFFFFF80) != 0) { + buf.writeByte((byte) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + buf.writeByte((byte) value); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java new file mode 100644 index 00000000..16479d08 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -0,0 +1,171 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import io.netty.util.ReferenceCountUtil; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicReference; + +public class NetherNetDiscoverySignaling implements NetherNetClientSignaling, NetherNetServerSignaling { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscoverySignaling.class); + + private final NetherNetDiscovery discovery; + private final InetSocketAddress bindAddress; + private final String localNetworkId; + + // State captured after connect + private volatile InetSocketAddress remoteAddress; + private final AtomicReference discoveredServerId = new AtomicReference<>(null); + + /** + * Creates a NetherNetDiscoverySignaling with a random local Network ID and binds to an ephemeral port. * + */ + public NetherNetDiscoverySignaling() { + this(ThreadLocalRandom.current().nextLong(), new InetSocketAddress(0)); + } + + /** + * Creates a NetherNetDiscoverySignaling with the specified local Network ID. + * + * @param localNetworkId The local Network ID to use. + */ + public NetherNetDiscoverySignaling(long localNetworkId) { + this(localNetworkId, new InetSocketAddress(0)); + } + + /** + * Creates a NetherNetDiscoverySignaling with the specified local Network ID and bind address. + * + * @param localNetworkId The local Network ID to use. + * @param bindAddress The address to bind the discovery socket to. + */ + public NetherNetDiscoverySignaling(long localNetworkId, InetSocketAddress bindAddress) { + this.localNetworkId = Long.toUnsignedString(localNetworkId); + this.discovery = new NetherNetDiscovery(localNetworkId); + this.bindAddress = bindAddress; + } + + @Override + public String getLocalNetworkId() { + return this.localNetworkId; + } + + @Override + public CompletableFuture> connect(SocketAddress remote) { + CompletableFuture> future = new CompletableFuture<>(); + + if (!(remote instanceof InetSocketAddress)) { + future.completeExceptionally(new IllegalArgumentException("Discovery requires InetSocketAddress")); + return future; + } + + this.remoteAddress = (InetSocketAddress) remote; + + try { + if (!this.discovery.isActive()) { + log.info("Binding NetherNet Discovery to {}", bindAddress); + this.discovery.bind(bindAddress); + } + + log.debug("Sending Discovery Request to {}", remote); + + // Send request and register the callback to capture the ID + this.discovery.sendDiscoveryRequest(this.remoteAddress, (serverNetworkId, payload) -> { + try { + log.info("Discovery Response Received! Server NetworkID: {}", serverNetworkId); + + // Capture the ID so we can use it for signaling later + discoveredServerId.set(Long.toUnsignedString(serverNetworkId)); + + future.complete(Collections.emptyList()); + } catch (Exception e) { + log.error("Error processing discovery response", e); + future.completeExceptionally(e); + } finally { + ReferenceCountUtil.release(payload); + } + }); + } catch (Exception e) { + log.error("Failed to send discovery request", e); + future.completeExceptionally(e); + } + + return future; + } + + @Override + public void bind(SocketAddress localAddress) { + if (!this.discovery.isActive()) { + if (localAddress instanceof InetSocketAddress) { + this.discovery.bind((InetSocketAddress) localAddress); + } else { + this.discovery.bind(bindAddress); + } + } + } + + @Override + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.discovery.setNewConnectionHandler(handler); + } + + @Override + public void setAdvertisementData(PongData pongData) { + this.discovery.setPongData(pongData); + } + + @Override + public void sendSignal(String targetNetworkId, String data) { + String actualIdStr = targetNetworkId; + + // If '0' is passed, try to use the discovered ID (Client Mode) + if (actualIdStr == null || actualIdStr.equals("0")) { + actualIdStr = discoveredServerId.get(); + } + + if (actualIdStr == null) { + log.warn("Cannot send signal: Unknown Network ID."); + return; + } + + try { + long id = Long.parseUnsignedLong(actualIdStr); + + // If we have an explicit remote address (Client Mode), use it directly + if (remoteAddress != null) { + this.discovery.sendSignal(remoteAddress, id, data); + } else { + // Server Mode: Use the ID to find the address in the Discovery map + this.discovery.sendSignal(id, data); + } + } catch (NumberFormatException e) { + log.error("Cannot send LAN signal to non-numeric Network ID: {}", actualIdStr); + } + } + + @Override + public void setSignalHandler(long connectionId, SignalHandler handler) { + this.discovery.registerSignalHandler(connectionId, handler); + } + + @Override + public void removeSignalHandler(long connectionId) { + this.discovery.unregisterSignalHandler(connectionId); + } + + @Override + public void setNotFoundHandler(NetherNetClientSignaling.NotFoundHandler handler) { + // Not implemented for Discovery signaling + } + + @Override + public void close() { + this.discovery.close(); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java new file mode 100644 index 00000000..a15b53ad --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -0,0 +1,130 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import java.net.ConnectException; +import java.net.SocketAddress; +import java.util.List; + +public interface NetherNetServerSignaling extends NetherNetSignaling { + /** + * Binds the signaling medium to listen for incoming connections (Server mode). + * + * @param localAddress The local address to bind to. + * @throws ConnectException + */ + void bind(SocketAddress localAddress) throws ConnectException; + + /** + * Handler for new connections. + * + * @param handler Functional interface receiving (ConnectionID, RemoteNetworkID, Payload) + */ + void setNewConnectionHandler(NewConnectionHandler handler); + + /** + * Sets the advertisement data for the discovery mechanism (e.g. LAN Pong). + * + * @param pongData The Pong advertisement data. + */ + void setAdvertisementData(PongData pongData); + + /** + * Functional interface for new connection handling. + */ + @FunctionalInterface + interface NewConnectionHandler { + /** + * Called when a new connection is initiated by a remote peer. + * + * @param connectionId The unique connection ID for this session. + * @param remoteNetworkId The Network ID of the remote peer. + * @param payload The initial signaling payload from the remote peer. + */ + void onConnect(long connectionId, String remoteNetworkId, String payload); + } + + /** + * Returns the ICE servers (STUN/TURN) obtained from the signaling handshake. + * Returns empty list if none available or not applicable. + */ + default List getIceServers() { + return java.util.Collections.emptyList(); + } + + /** + * Data structure for Pong advertisement data. + * + * @param serverName The name of the server. + * @param levelName The name of the level/world. + * @param gameType The game type (e.g. Survival, Creative). + * @param playerCount The current number of players. + * @param maxPlayerCount The maximum number of players allowed. + * @param isEditorWorld Whether the world is an editor world. + * @param isHardcore Whether the world is in hardcore mode. + * @param transportLayer The transport layer identifier (e.g. NetherNet). + * @param connectionType The connection type identifier (e.g. LAN, Online). + */ + public record PongData(String serverName, String levelName, int gameType, int playerCount, int maxPlayerCount, + boolean isEditorWorld, boolean isHardcore, int transportLayer, int connectionType) { + public static class Builder { + private String serverName = "Server"; + private String levelName = "World"; + private int gameType = 0; // Default to Survival + private int playerCount = 0; + private int maxPlayerCount = 10; + private boolean isEditorWorld = false; + private boolean isHardcore = false; + private int transportLayer = 2; // Default to NetherNet + private int connectionType = 4; // Default to LAN + + public Builder setServerName(String serverName) { + this.serverName = serverName; + return this; + } + + public Builder setLevelName(String levelName) { + this.levelName = levelName; + return this; + } + + public Builder setGameType(int gameType) { + this.gameType = gameType; + return this; + } + + public Builder setPlayerCount(int playerCount) { + this.playerCount = playerCount; + return this; + } + + public Builder setMaxPlayerCount(int maxPlayerCount) { + this.maxPlayerCount = maxPlayerCount; + return this; + } + + public Builder setIsEditorWorld(boolean isEditorWorld) { + this.isEditorWorld = isEditorWorld; + return this; + } + + public Builder setIsHardcore(boolean isHardcore) { + this.isHardcore = isHardcore; + return this; + } + + public Builder setTransportLayer(int transportLayer) { + this.transportLayer = transportLayer; + return this; + } + + public Builder setConnectionType(int connectionType) { + this.connectionType = connectionType; + return this; + } + + public PongData build() { + return new PongData(serverName, levelName, gameType, playerCount, maxPlayerCount, + isEditorWorld, isHardcore, transportLayer, connectionType); + } + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java new file mode 100644 index 00000000..fe63d3bb --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java @@ -0,0 +1,88 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import java.util.List; + +public interface NetherNetSignaling extends AutoCloseable { + + /** + * Sends a signaling message to the remote peer. + * + * @param targetNetworkId The Network ID of the destination (String to support Realms). + * @param data The raw signaling payload. + */ + void sendSignal(String targetNetworkId, String data); + + /** + * Sets a handler to receive signaling messages for a specific connection ID. + * + * @param connectionId The connection ID to listen for. + * @param handler The handler to process incoming signaling messages. + */ + void setSignalHandler(long connectionId, SignalHandler handler); + + /** + * Removes the signaling handler for a specific connection ID. + * + * @param connectionId The connection ID whose handler should be removed. + */ + void removeSignalHandler(long connectionId); + + /** + * Returns the Local Network ID of this client as a String. + * This is required for formatting the 'candidate:' string in SDP. + */ + String getLocalNetworkId(); + + /** + * Closes the signaling channel and releases any associated resources. + */ + @Override + void close(); + + /** + * Functional interface for handling incoming signals. + */ + @FunctionalInterface + interface SignalHandler { + /** + * Called when a signal is received for the registered connection ID. + * + * @param signal The raw signal payload. + */ + void onSignal(String signal); + } + + /** + * Data structure for ICE server information. + * + * @param username The username for the ICE server (if applicable). + * @param password The password for the ICE server (if applicable). + * @param urls The list of URLs for the ICE server. + */ + public record IceServerInfo(String username, String password, List urls) { + public static class Builder { + private String username = ""; + private String password = ""; + private List urls = List.of(); + + public Builder setUsername(String username) { + this.username = username; + return this; + } + + public Builder setPassword(String password) { + this.password = password; + return this; + } + + public Builder setUrls(List urls) { + this.urls = urls; + return this; + } + + public IceServerInfo build() { + return new IceServerInfo(username, password, urls); + } + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java new file mode 100644 index 00000000..4345a77f --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java @@ -0,0 +1,212 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; + +import java.net.URI; +import java.nio.channels.ClosedChannelException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +public class NetherNetXboxRpcSignaling extends AbstractNetherNetXboxSignaling { + private static final Gson gson = new GsonBuilder().serializeNulls().create(); + private final Map> pendingRequests = new ConcurrentHashMap<>(); + + /** + * Creates a NetherNetXboxRpcSignaling instance. + * + * @param networkId The Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxRpcSignaling(String networkId, String xboxToken) { + super(networkId, xboxToken, URI.create("wss://signal.franchise.minecraft-services.net/ws/v1.0/messaging/connect")); + } + + /** + * Creates a NetherNetXboxRpcSignaling instance. + * + * @param localNetworkId The local Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxRpcSignaling(long localNetworkId, String xboxToken) { + this(Long.toUnsignedString(localNetworkId), xboxToken); + } + + /** + * Creates a NetherNetXboxRpcSignaling instance with a random local Network ID. + * + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxRpcSignaling(String xboxToken) { + this(Long.toUnsignedString(ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE)), xboxToken); + } + + @Override + protected void onConnected(ChannelHandlerContext ctx) { + ctx.executor().scheduleAtFixedRate(() -> { + if (channel != null && channel.isActive()) { + sendJsonRpcRequest(NetherNetConstants.XBOX_RPC_METHOD_PING, new JsonObject()); + } + }, 30, 50, TimeUnit.SECONDS); + + sendJsonRpcRequest(NetherNetConstants.XBOX_RPC_METHOD_TURN_AUTH, new JsonObject()) + .thenAccept(response -> { + List servers = parseTurnServers(response); + if (connectFuture != null && !connectFuture.isDone()) connectFuture.complete(servers); + }) + .exceptionally(t -> { + log.error("Failed to fetch TURN credentials", t); + if (connectFuture != null && !connectFuture.isDone()) connectFuture.completeExceptionally(t); + return null; + }); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { + String text = frame.text(); + try { + JsonObject json = JsonParser.parseString(text).getAsJsonObject(); + + if (json.has("result") || (json.has("error") && json.has("id"))) { + handleResponse(json); + } else if (json.has("method")) { + handleRequest(json); + } + } catch (Exception e) { + log.error("Error processing signaling frame: " + text, e); + } + } + + private void handleResponse(JsonObject json) { + if (!json.has("id") || json.get("id").isJsonNull()) return; + String id = json.get("id").getAsString(); + CompletableFuture future = pendingRequests.remove(id); + + if (future != null) { + if (json.has("error") && !json.get("error").isJsonNull()) { + JsonObject error = json.getAsJsonObject("error"); + String msg = error.has("message") ? error.get("message").getAsString() : error.toString(); + + boolean isNotFound = msg.contains("Player not registered"); + if (!isNotFound && error.has("data") && error.get("data").isJsonObject()) { + JsonObject data = error.getAsJsonObject("data"); + if (data.has("Code") && "MissingOrExpiredIdentity".equals(data.get("Code").getAsString())) { + isNotFound = true; + } + } + + if (isNotFound && notFoundHandler != null) { + notFoundHandler.onNotFound(msg); + } + future.completeExceptionally(new RuntimeException(msg)); + } else { + future.complete(json.has("result") && !json.get("result").isJsonNull() ? json.getAsJsonObject("result") : new JsonObject()); + } + } + } + + private void handleRequest(JsonObject json) { + String method = json.get("method").getAsString(); + JsonElement id = json.get("id"); + + switch (method) { + case NetherNetConstants.XBOX_RPC_METHOD_RECEIVE_MESSAGE -> { + if (id != null) sendJsonRpcResult(id, null); + JsonArray params = json.getAsJsonArray("params"); + if (params != null) { + for (JsonElement el : params) processIncomingMessage(el.getAsJsonObject()); + } + } + case NetherNetConstants.XBOX_RPC_METHOD_PONG, NetherNetConstants.XBOX_RPC_METHOD_PING -> { + if (id != null) sendJsonRpcResult(id, null); + } + } + } + + private void processIncomingMessage(JsonObject msgObj) { + String from = msgObj.get("From").getAsString(); + String rawInner = msgObj.get("Message").getAsString(); + String msgId = msgObj.has("Id") ? msgObj.get("Id").getAsString() : UUID.randomUUID().toString(); + + JsonObject innerParams = new JsonObject(); + innerParams.addProperty("messageId", msgId); + JsonObject innerMsg = new JsonObject(); + innerMsg.add("params", innerParams); + innerMsg.addProperty("jsonrpc", "2.0"); + innerMsg.addProperty("method", NetherNetConstants.XBOX_RPC_INNER_METHOD_DELIVERY); + sendJsonRpcRequest(NetherNetConstants.XBOX_RPC_METHOD_SEND_MESSAGE, createSendParams(from, innerMsg.toString())); + + try { + JsonObject innerJson = JsonParser.parseString(rawInner).getAsJsonObject(); + if (innerJson.has("method") && NetherNetConstants.XBOX_RPC_INNER_METHOD_WEBRTC.equals(innerJson.get("method").getAsString())) { + String payload = innerJson.getAsJsonObject("params").get("message").getAsString(); + dispatchSignalToPipeline(from, payload); + } + } catch (Exception e) { + log.error("Failed to parse inner signaling message from " + from, e); + } + } + + @Override + public void sendSignal(String targetNetworkId, String data) { + if (channel == null || !channel.isActive()) throw new IllegalStateException("Signaling channel is not active"); + + JsonObject innerParams = new JsonObject(); + innerParams.addProperty("netherNetId", localNetworkId); + innerParams.addProperty("message", data); + + JsonObject innerMsg = new JsonObject(); + innerMsg.add("params", innerParams); + innerMsg.addProperty("jsonrpc", "2.0"); + innerMsg.addProperty("method", NetherNetConstants.XBOX_RPC_INNER_METHOD_WEBRTC); + + sendJsonRpcRequest(NetherNetConstants.XBOX_RPC_METHOD_SEND_MESSAGE, createSendParams(targetNetworkId, innerMsg.toString())); + } + + private JsonObject createSendParams(String toPlayerId, String message) { + JsonObject params = new JsonObject(); + params.addProperty("toPlayerId", toPlayerId); + params.addProperty("messageId", UUID.randomUUID().toString()); + params.addProperty("message", message); + return params; + } + + private CompletableFuture sendJsonRpcRequest(String method, JsonObject params) { + String id = UUID.randomUUID().toString(); + JsonObject rpc = new JsonObject(); + rpc.add("params", params); + rpc.addProperty("jsonrpc", "2.0"); + rpc.addProperty("method", method); + rpc.addProperty("id", id); + + CompletableFuture future = new CompletableFuture<>(); + pendingRequests.put(id, future); + + if (channel != null && channel.isActive()) { + channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(rpc))); + } else { + future.completeExceptionally(new ClosedChannelException()); + } + return future; + } + + private void sendJsonRpcResult(JsonElement id, JsonElement result) { + JsonObject response = new JsonObject(); + response.add("id", id); + response.add("result", result); + response.addProperty("jsonrpc", "2.0"); + if (channel != null && channel.isActive()) channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(response))); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java new file mode 100644 index 00000000..8e00d65a --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -0,0 +1,108 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; + +import java.net.URI; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +@Sharable +public class NetherNetXboxSignaling extends AbstractNetherNetXboxSignaling { + private static final Gson gson = new Gson(); + + /** + * Creates a NetherNetXboxSignaling instance. + * + * @param networkId The Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxSignaling(String networkId, String xboxToken) { + super(networkId, xboxToken, URI.create("wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling/" + networkId)); + } + + /** + * Creates a NetherNetXboxSignaling instance. + * + * @param localNetworkId The local Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxSignaling(long localNetworkId, String xboxToken) { + this(Long.toUnsignedString(localNetworkId), xboxToken); + } + + /** + * Creates a NetherNetXboxSignaling instance with a random local Network ID. + * + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxSignaling(String xboxToken) { + this(Long.toUnsignedString(ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE)), xboxToken); + } + + @Override + protected void onConnected(ChannelHandlerContext ctx) { + ctx.executor().scheduleAtFixedRate(() -> { + JsonObject ping = new JsonObject(); + ping.addProperty("Type", 0); + ctx.writeAndFlush(new TextWebSocketFrame(gson.toJson(ping))); + }, 5, 5, TimeUnit.SECONDS); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { + String text = frame.text(); + try { + JsonObject json = gson.fromJson(text, JsonObject.class); + if (!json.has("Type")) return; + + int type = json.get("Type").getAsInt(); + switch (type) { + case NetherNetConstants.XBOX_SIGNAL_NOT_FOUND -> { + log.debug("Peer Not Found: {}", text); + if (notFoundHandler != null) { + String reason = json.has("Message") ? json.get("Message").getAsString() : text; + notFoundHandler.onNotFound(reason); + } + } + case NetherNetConstants.XBOX_SIGNAL_SIGNAL -> { + String sender = json.has("From") ? json.get("From").getAsString() : "0"; + if (json.has("Message")) { + dispatchSignalToPipeline(sender, json.get("Message").getAsString()); + } + } + case NetherNetConstants.XBOX_SIGNAL_CREDENTIALS -> { + log.trace("Received Credentials"); + if (json.has("Message") && connectFuture != null && !connectFuture.isDone()) { + String rawMsg = json.get("Message").getAsString(); + JsonObject credentials = JsonParser.parseString(rawMsg).getAsJsonObject(); + + connectFuture.complete(parseTurnServers(credentials)); + } + } + case NetherNetConstants.XBOX_SIGNAL_ACCEPTED, NetherNetConstants.XBOX_SIGNAL_ACK -> log.trace("Signal Ack: {}", text); + default -> log.debug("Unknown message type {}: {}", type, text); + } + } catch (Exception e) { + log.error("Error processing signaling frame: " + text, e); + } + } + + @Override + public void sendSignal(String targetNetworkId, String data) { + if (channel != null && channel.isActive()) { + JsonObject msg = new JsonObject(); + msg.addProperty("Type", 1); + msg.addProperty("To", targetNetworkId); + msg.addProperty("Message", data); + channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(msg))); + } else { + throw new IllegalStateException("Attempted to send signal to " + targetNetworkId + " but WebSocket is closed!"); + } + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java new file mode 100644 index 00000000..6bc5ae85 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet.signaling; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java new file mode 100644 index 00000000..b3349334 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java @@ -0,0 +1,80 @@ +package dev.kastle.netty.util.nethernet; + +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetDiscovery; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ThreadLocalRandom; + +/** + * A simple scanner example for discovering NetherNet servers on the local network. + */ +public class NetherNetScanner { + public static void main(String[] args) throws Exception { + long myNetworkId = ThreadLocalRandom.current().nextLong(); + NetherNetDiscovery discovery = new NetherNetDiscovery(myNetworkId); + + discovery.bind(new InetSocketAddress("::", 0)); + + System.out.println("Scanning for NetherNet servers on port 7551..."); + + InetSocketAddress broadcastTarget = new InetSocketAddress("255.255.255.255", NetherNetConstants.DISCOVERY_PORT); + + discovery.sendDiscoveryRequest(broadcastTarget, (senderId, payload) -> { + try { + if (payload.readableBytes() < 4) return; + + int length = payload.readIntLE(); + if (payload.readableBytes() < length) return; + + String hexString = payload.readCharSequence(length, StandardCharsets.UTF_8).toString(); + + byte[] binaryData = ByteBufUtil.decodeHexDump(hexString); + ByteBuf data = Unpooled.wrappedBuffer(binaryData); + + try { + int version = data.readUnsignedByte(); + String serverName = readString(data); + String levelName = readString(data); + int gameType = data.readUnsignedByte() >> 1; + int playerCount = data.readIntLE(); + int maxPlayers = data.readIntLE(); + boolean isEditor = data.readBoolean(); + boolean isHardcore = data.readBoolean(); + + System.out.println("--------------------------------"); + System.out.println("Found Server: " + senderId); + System.out.println("MOTD: " + serverName); + System.out.println("Level: " + levelName); + System.out.println("Players: " + playerCount + "/" + maxPlayers); + System.out.println("Game Mode: " + gameType); + System.out.println("Editor World: " + isEditor); + System.out.println("Hardcore: " + isHardcore); + System.out.println("Version: " + version); + System.out.println("--------------------------------"); + + } finally { + data.release(); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + payload.release(); + } + }); + + Thread.sleep(10000); + discovery.close(); + } + + private static String readString(ByteBuf buf) { + if (!buf.isReadable()) return ""; + int len = buf.readUnsignedByte(); + if (buf.readableBytes() < len) return ""; + return buf.readCharSequence(len, StandardCharsets.UTF_8).toString(); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java new file mode 100644 index 00000000..b7355d67 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.util.nethernet; From b96f3094ad1139848cd94f1984bf4122b5136348 Mon Sep 17 00:00:00 2001 From: Zulu Date: Sat, 5 Sep 2026 21:09:22 +0100 Subject: [PATCH 02/15] Integrate attributed HTTP signalling and libdatachannel backend Port the transport subtree at rtm516/NetworkCompatible 8d1c989ee6fb5eb7a28a9130573d30634f39cba4, including HTTP signalling introduced in 37f82650d6268a3470459d758ad89c3cc2026f2e and the libdatachannel backend in f4ba1e7b826e932e12974d5a6f329647eab6ee05. Co-authored-by: rtm516 Co-authored-by: Kas-tle <26531652+Kas-tle@users.noreply.github.com> --- gradle/libs.versions.toml | 6 +- transport-nethernet/README.md | 19 +- transport-nethernet/build.gradle.kts | 5 +- .../channel/nethernet/NetherNetChannel.java | 91 ++-- .../nethernet/NetherNetChannelFactory.java | 15 +- .../nethernet/NetherNetChildChannel.java | 4 +- .../nethernet/NetherNetClientChannel.java | 220 +++----- .../channel/nethernet/NetherNetConstants.java | 2 + .../nethernet/NetherNetServerChannel.java | 291 ++++++---- .../config/DefaultNetherChannelConfig.java | 29 +- .../nethernet/config/NetherChannelOption.java | 8 +- .../AbstractNetherNetXboxSignaling.java | 5 +- .../NetherNetDiscoverySignaling.java | 8 +- .../signaling/NetherNetHTTPSignaling.java | 497 ++++++++++++++++++ .../signaling/NetherNetServerSignaling.java | 76 ++- .../signaling/NetherNetSignaling.java | 72 ++- .../signaling/NetherNetXboxRpcSignaling.java | 14 +- .../netty/util/http/HttpLoggingHandler.java | 41 ++ .../netty/util/http/TlsRejectingHandler.java | 28 + .../kastle/netty/util/nethernet/Identity.java | 47 ++ .../netty/util/nethernet/IdentityUtils.java | 119 +++++ .../util/nethernet/NetherNetLogging.java | 83 +++ .../netty/util/nethernet/PlayerInfo.java | 17 + .../netty/util/nethernet/ServerIdentity.java | 220 ++++++++ 24 files changed, 1581 insertions(+), 336 deletions(-) create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetHTTPSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/http/HttpLoggingHandler.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/http/TlsRejectingHandler.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/Identity.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/IdentityUtils.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/PlayerInfo.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/ServerIdentity.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2482e7a5..336f358f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,12 +2,15 @@ netty = "4.1.101.Final" junit = "5.9.2" gson = "2.13.2" +libdatachannel = "0.24.1.1" +jose4j = "0.9.6" [libraries] netty-codec-http = { group = "io.netty", name = "netty-codec-http", version.ref = "netty" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } -webrtc-java = { group = "dev.kastle.webrtc", name = "webrtc-java", version = "1.0.3" } +libdatachannel-java = { group = "tel.schich", name = "libdatachannel-java", version.ref = "libdatachannel" } +jose4j = { group = "org.bitbucket.b_c", name = "jose4j", version.ref = "jose4j" } netty-common = { group = "io.netty", name = "netty-common", version.ref = "netty" } netty-buffer = { group = "io.netty", name = "netty-buffer", version.ref = "netty" } netty-codec = { group = "io.netty", name = "netty-codec", version.ref = "netty" } @@ -30,3 +33,4 @@ junit = [ "junit-jupiter-engine", "junit-jupiter-api", "junit-jupiter-params" ] [plugins] + diff --git a/transport-nethernet/README.md b/transport-nethernet/README.md index 788e5b65..78c510cc 100644 --- a/transport-nethernet/README.md +++ b/transport-nethernet/README.md @@ -13,7 +13,24 @@ Snapshots are available from [jitpack](https://jitpack.io/#dev.kastle/NetworkCom ## Usage > [!IMPORTANT] -> This library requires the platform-specific WebRTC native libraries at runtime. See [Kas-tle/webrtc-java](https://github.com/Kas-tle/webrtc-java?tab=readme-ov-file#usage) for instructions on how to include the native libraries in your project. +> This library uses [libdatachannel-java](https://github.com/pschichtel/libdatachannel-java) and needs its platform-specific native library at runtime. The main artifact contains no natives, so you have to add the classifier(s) for the platforms you ship yourself. + +```kotlin +val nativePlatforms = listOf( + "windows-x86_64", + "x86_64", // linux x86_64 + "aarch64", // linux aarch64 + "macos-x86_64", + "macos-arm64" +) + +dependencies { + implementation("dev.kastle.netty:netty-transport-nethernet:$netherNetVersion") + nativePlatforms.forEach { platform -> + runtimeOnly("tel.schich:libdatachannel-java:$libdatachannelVersion:$platform") + } +} +``` ### Examples diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts index f0838485..144f8910 100644 --- a/transport-nethernet/build.gradle.kts +++ b/transport-nethernet/build.gradle.kts @@ -4,12 +4,13 @@ dependencies { api(libs.bundles.netty) api(libs.netty.codec.http) api(libs.expiringmap) - api(libs.webrtc.java) + api(libs.libdatachannel.java) implementation(libs.gson) + implementation(libs.jose4j) testImplementation(libs.bundles.junit) - testRuntimeOnly(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.platform.launcher) } configure { diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java index 3318d4d9..079aac26 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -1,11 +1,6 @@ package dev.kastle.netty.channel.nethernet; import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; -import dev.kastle.webrtc.RTCDataChannel; -import dev.kastle.webrtc.RTCDataChannelBuffer; -import dev.kastle.webrtc.RTCDataChannelObserver; -import dev.kastle.webrtc.RTCDataChannelState; -import dev.kastle.webrtc.RTCPeerConnection; import io.netty.buffer.ByteBuf; import io.netty.channel.AbstractChannel; import io.netty.channel.Channel; @@ -16,6 +11,9 @@ import io.netty.util.ReferenceCountUtil; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; +import tel.schich.libdatachannel.DataChannel; +import tel.schich.libdatachannel.DataChannelCallback; +import tel.schich.libdatachannel.PeerConnection; import java.net.InetSocketAddress; import java.net.SocketAddress; @@ -28,12 +26,12 @@ public abstract class NetherNetChannel extends AbstractChannel { protected static final ChannelMetadata METADATA = new ChannelMetadata(false); protected DefaultNetherChannelConfig config; - protected volatile RTCPeerConnection peerConnection; + protected volatile PeerConnection peerConnection; protected volatile SocketAddress remoteAddress; protected volatile SocketAddress localAddress; - protected RTCDataChannel reliableChannel; - protected RTCDataChannel unreliableChannel; + protected DataChannel reliableChannel; + protected DataChannel unreliableChannel; protected final Queue pendingWrites = new ConcurrentLinkedQueue<>(); @@ -45,26 +43,19 @@ protected NetherNetChannel(Channel parent, InetSocketAddress remote, InetSocketA this.localAddress = local; } - public void setDataChannels(RTCDataChannel reliable, RTCDataChannel unreliable) { + public void setDataChannels(DataChannel reliable, DataChannel unreliable) { this.reliableChannel = reliable; this.unreliableChannel = unreliable; - RTCDataChannelObserver observer = new RTCDataChannelObserver() { + this.reliableChannel.onOpen.register(channel -> eventLoop().execute(this::onDataChannelStateChange)); + this.reliableChannel.onClosed.register(channel -> eventLoop().execute(this::onDataChannelStateChange)); + + this.reliableChannel.onMessage.register(DataChannelCallback.Message.handleBinary(new DataChannelCallback.BinaryMessage() { private final ByteBuf assemblyBuf = config.getAllocator().buffer(); private int currentSegmentCount = -1; @Override - public void onBufferedAmountChange(long previousAmount) { - } - - @Override - public void onStateChange() { - eventLoop().execute(() -> onDataChannelStateChange()); - } - - @Override - public void onMessage(RTCDataChannelBuffer buffer) { - ByteBuffer data = buffer.data; + public void onBinary(DataChannel channel, ByteBuffer data) { if (!data.hasRemaining()) return; @@ -106,11 +97,9 @@ public void onMessage(RTCDataChannelBuffer buffer) { } } } - }; - - this.reliableChannel.registerObserver(observer); + })); - if (reliableChannel.getState() == RTCDataChannelState.OPEN) { + if (reliableChannel.isOpen()) { eventLoop().execute(this::onDataChannelStateChange); } } @@ -121,7 +110,7 @@ private void onDataChannelStateChange() { pipeline().fireChannelWritabilityChanged(); unsafe().flush(); } - } else if (reliableChannel.getState() == RTCDataChannelState.CLOSED) { + } else if (reliableChannel != null && reliableChannel.isClosed()) { close(); } } @@ -182,7 +171,7 @@ private void writeInternal(Object msg) { chunk.position(chunk.limit()); chunk.flip(); - reliableChannel.send(new RTCDataChannelBuffer(chunk, true)); + reliableChannel.sendMessage(chunk); offset += chunkSize; } } catch (Exception e) { @@ -213,23 +202,53 @@ protected void doDisconnect() throws Exception { @Override protected void doClose() throws Exception { this.open = false; + closeWebRTC(); + Object msg; + while ((msg = pendingWrites.poll()) != null) { + ReferenceCountUtil.release(msg); + } + } + + /** + * Closes the data channels and peer connection, dropping their listeners first. + */ + protected void closeWebRTC() { if (reliableChannel != null) { - reliableChannel.unregisterObserver(); + deregisterAll(reliableChannel); reliableChannel.close(); + reliableChannel = null; } if (unreliableChannel != null) { - unreliableChannel.unregisterObserver(); + deregisterAll(unreliableChannel); unreliableChannel.close(); + unreliableChannel = null; } if (peerConnection != null) { + deregisterAll(peerConnection); peerConnection.close(); + peerConnection = null; } + } - Object msg; - while ((msg = pendingWrites.poll()) != null) { - ReferenceCountUtil.release(msg); - } + static void deregisterAll(PeerConnection peer) { + peer.onLocalDescription.deregisterAll(); + peer.onLocalCandidate.deregisterAll(); + peer.onStateChange.deregisterAll(); + peer.onIceStateChange.deregisterAll(); + peer.onGatheringStateChange.deregisterAll(); + peer.onSignalingStateChange.deregisterAll(); + peer.onDataChannel.deregisterAll(); + peer.onTrack.deregisterAll(); + } + + static void deregisterAll(DataChannel channel) { + channel.onOpen.deregisterAll(); + channel.onClosed.deregisterAll(); + channel.onError.deregisterAll(); + channel.onMessage.deregisterAll(); + channel.onBufferedAmountLow.deregisterAll(); + channel.onAvailable.deregisterAll(); } @Override @@ -263,11 +282,15 @@ public boolean isOpen() { @Override public boolean isActive() { - return isOpen() && this.reliableChannel != null && this.reliableChannel.getState() == RTCDataChannelState.OPEN; + return isOpen() && this.reliableChannel != null && this.reliableChannel.isOpen(); } @Override public ChannelMetadata metadata() { return METADATA; } + + public void setRemoteAddress(SocketAddress remoteAddress) { + this.remoteAddress = remoteAddress; + } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java index 82c3cc04..c74d04c7 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -2,7 +2,6 @@ import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; -import dev.kastle.webrtc.PeerConnectionFactory; import io.netty.channel.Channel; import io.netty.channel.ChannelFactory; @@ -23,23 +22,21 @@ public T newChannel() { /** * Creates a NetherNet Server Channel Factory. - * - * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * * @param signaling The NetherNetServerSignaling instance for signaling. * @return A ChannelFactory for NetherNetServerChannel. */ - public static ChannelFactory server(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { - return new NetherNetChannelFactory<>(() -> new NetherNetServerChannel(factory, signaling)); + public static ChannelFactory server(NetherNetServerSignaling signaling) { + return new NetherNetChannelFactory<>(() -> new NetherNetServerChannel(signaling)); } /** * Creates a NetherNet Client Channel Factory. - * - * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * * @param signaling The NetherNetClientSignaling instance for signaling. * @return A ChannelFactory for NetherNetClientChannel. */ - public static ChannelFactory client(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { - return new NetherNetChannelFactory<>(() -> new NetherNetClientChannel(factory, signaling)); + public static ChannelFactory client(NetherNetClientSignaling signaling) { + return new NetherNetChannelFactory<>(() -> new NetherNetClientChannel(signaling)); } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java index c324e4ee..afc2e176 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java @@ -1,15 +1,15 @@ package dev.kastle.netty.channel.nethernet; import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; -import dev.kastle.webrtc.RTCPeerConnection; import io.netty.channel.Channel; import io.netty.channel.ChannelPromise; +import tel.schich.libdatachannel.PeerConnection; import java.net.InetSocketAddress; import java.net.SocketAddress; public class NetherNetChildChannel extends NetherNetChannel { - public NetherNetChildChannel(Channel parent, RTCPeerConnection peerConnection, InetSocketAddress remote, InetSocketAddress local) { + public NetherNetChildChannel(Channel parent, PeerConnection peerConnection, InetSocketAddress remote, InetSocketAddress local) { super(parent, remote, local); this.peerConnection = peerConnection; this.config = new DefaultNetherChannelConfig(this); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index 87493233..88ca62ed 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -5,28 +5,18 @@ import dev.kastle.netty.channel.nethernet.config.NetherNetAddress; import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling; -import dev.kastle.webrtc.CreateSessionDescriptionObserver; -import dev.kastle.webrtc.PeerConnectionFactory; -import dev.kastle.webrtc.PeerConnectionObserver; -import dev.kastle.webrtc.RTCBundlePolicy; -import dev.kastle.webrtc.RTCConfiguration; -import dev.kastle.webrtc.RTCDataChannel; -import dev.kastle.webrtc.RTCDataChannelBuffer; -import dev.kastle.webrtc.RTCDataChannelInit; -import dev.kastle.webrtc.RTCDataChannelObserver; -import dev.kastle.webrtc.RTCDataChannelState; -import dev.kastle.webrtc.RTCIceCandidate; -import dev.kastle.webrtc.RTCIceServer; -import dev.kastle.webrtc.RTCOfferOptions; -import dev.kastle.webrtc.RTCPeerConnectionState; -import dev.kastle.webrtc.RTCSdpType; -import dev.kastle.webrtc.RTCSessionDescription; -import dev.kastle.webrtc.SetSessionDescriptionObserver; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo; import io.netty.channel.ChannelPromise; -import io.netty.util.ReferenceCountUtil; import io.netty.util.concurrent.ScheduledFuture; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; +import tel.schich.libdatachannel.DataChannel; +import tel.schich.libdatachannel.DataChannelInitSettings; +import tel.schich.libdatachannel.DataChannelReliability; +import tel.schich.libdatachannel.PeerConnection; +import tel.schich.libdatachannel.PeerConnectionConfiguration; +import tel.schich.libdatachannel.PeerState; +import tel.schich.libdatachannel.SessionDescriptionType; import java.net.ConnectException; import java.net.InetSocketAddress; @@ -39,12 +29,11 @@ public class NetherNetClientChannel extends NetherNetChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetClientChannel.class); - private final PeerConnectionFactory factory; private final NetherNetClientSignaling signaling; private volatile long connectionId; // Session ID (Long) private volatile String targetNetworkId; // Peer ID (String, for Realms) - + private volatile boolean handshakeComplete = false; private ChannelPromise connectPromise; @@ -53,24 +42,13 @@ public class NetherNetClientChannel extends NetherNetChannel { private int retryCount = 0; - /** - * Creates a NetherNetClientChannel with a new PeerConnectionFactory. - * - * @param signaling The NetherNetClientSignaling instance for signaling. - */ - public NetherNetClientChannel(NetherNetClientSignaling signaling) { - this(new PeerConnectionFactory(), signaling); - } - /** * Creates a NetherNetClientChannel. - * - * @param factory The PeerConnectionFactory to use. Should be reused where possible. + * * @param signaling The NetherNetClientSignaling instance for signaling. */ - public NetherNetClientChannel(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { + public NetherNetClientChannel(NetherNetClientSignaling signaling) { super(null, null, null); - this.factory = factory; this.signaling = signaling; this.connectionId = this.cycleConnectionId(); this.config = new DefaultNetherClientChannelConfig(this); @@ -149,7 +127,7 @@ private void startHandshake() { signaling.setSignalHandler(this.connectionId, this::handleSignal); signaling.connect(remoteAddress).thenAcceptAsync(iceServers -> { - if (handshakeComplete) return; + if (handshakeComplete) return; try { // If this is a retry, peerConnection might be null, so we recreate it if (peerConnection == null) { @@ -189,11 +167,7 @@ private void resetAndRetryHandshake() { } retryCount++; - - if (peerConnection != null) { - peerConnection.close(); - peerConnection = null; - } + closeWebRTC(); signaling.removeSignalHandler(this.connectionId); this.cycleConnectionId(); @@ -201,46 +175,33 @@ private void resetAndRetryHandshake() { } private void initWebRTC(List iceServers) { - RTCConfiguration rtcConfig = new RTCConfiguration(); - rtcConfig.portAllocatorConfig = this.config.getOption(NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG); - rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; - - if (iceServers != null) { - for (NetherNetSignaling.IceServerInfo info : iceServers) { - RTCIceServer iceServer = new RTCIceServer(); - iceServer.urls = info.urls(); - iceServer.username = info.username(); - iceServer.password = info.password(); - rtcConfig.iceServers.add(iceServer); - } - } + PeerConnectionConfiguration rtcConfig = this.config.getOption(NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG) + .withDisableAutoNegotiation(true) + .withIceServers(iceServers.stream().map(IceServerInfo::toUris).flatMap(List::stream).toList()); - peerConnection = factory.createPeerConnection(rtcConfig, new PeerConnectionObserver() { - @Override - public void onIceCandidate(RTCIceCandidate candidate) { - try { - signaling.sendSignal( - targetNetworkId, - NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate.sdp) - ); - } catch (Exception e) { - log.error("Failed to send ICE candidate", e); - eventLoop().execute(() -> resetAndRetryHandshake()); - } - } + peerConnection = PeerConnection.createPeer(rtcConfig); - @Override - public void onConnectionChange(RTCPeerConnectionState state) { - if (state == RTCPeerConnectionState.FAILED) { - // Fast fail trigger: retry immediately instead of waiting for timeout - log.warn("PeerConnection entered FAILED state, resetting and retrying handshake."); - eventLoop().execute(() -> resetAndRetryHandshake()); - } else { - log.trace("PeerConnection state changed to {}", state); - } + // Registering is what arms the native callback, so it must happen before anything can fire it + peerConnection.onLocalCandidate.register((peer, candidate, mediaId) -> { + try { + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate) + ); + } catch (Exception e) { + log.error("Failed to send ICE candidate", e); + eventLoop().execute(() -> resetAndRetryHandshake()); } + }); - @Override public void onDataChannel(RTCDataChannel dataChannel) { } + peerConnection.onStateChange.register((peer, state) -> { + if (state == PeerState.RTC_FAILED) { + // Fast fail trigger: retry immediately instead of waiting for timeout + log.warn("PeerConnection entered FAILED state, resetting and retrying handshake."); + eventLoop().execute(() -> resetAndRetryHandshake()); + } else { + log.trace("PeerConnection state changed to {}", state); + } }); setupDataChannels(); @@ -248,28 +209,18 @@ public void onConnectionChange(RTCPeerConnectionState state) { private void createAndSendOffer() { if (peerConnection == null) return; - peerConnection.createOffer(new RTCOfferOptions(), new CreateSessionDescriptionObserver() { - @Override - public void onSuccess(RTCSessionDescription description) { - if (peerConnection == null) return; - peerConnection.setLocalDescription(description, new SetSessionDescriptionObserver() { - @Override - public void onSuccess() { - try { - signaling.sendSignal( - targetNetworkId, - NetherNetConstants.buildSignalConnectRequest(connectionId, description.sdp) - ); - } catch (Exception e) { - log.error("Failed to send Connect Request", e); - eventLoop().execute(() -> resetAndRetryHandshake()); - } - } - @Override public void onFailure(String error) { /* Retry handled by timeout */ } - }); - } - @Override public void onFailure(String error) { /* Retry handled by timeout */ } - }); + + // Not null for autodetection, that path releases an unset string in JNI and crashes the JVM + peerConnection.setLocalDescription("offer"); + try { + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalConnectRequest(connectionId, peerConnection.localDescription()) + ); + } catch (Exception e) { + log.error("Failed to send Connect Request", e); + eventLoop().execute(() -> resetAndRetryHandshake()); + } } private void handleSignal(String signal) { @@ -296,13 +247,18 @@ private void handleSignal(String signal) { switch (type) { case NetherNetConstants.RTC_NEGOTIATION_CONNECT_RESPONSE -> { - peerConnection.setRemoteDescription(new RTCSessionDescription(RTCSdpType.ANSWER, data), new SetSessionDescriptionObserver() { - @Override public void onSuccess() {} - @Override public void onFailure(String e) { /* Retry handled by timeout */ } - }); + try { + peerConnection.setRemoteDescription(data, SessionDescriptionType.ANSWER); + } catch (Exception e) { + log.debug("Failed to apply answer for {}: {}", Long.toUnsignedString(connectionId), e.toString()); + } } case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { - peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); + try { + peerConnection.addRemoteCandidate(data); + } catch (Exception e) { + log.debug("Failed to apply ICE candidate for {}: {}", Long.toUnsignedString(connectionId), e.toString()); + } } case NetherNetConstants.RTC_NEGOTIATION_CONNECT_ERROR -> { log.error("Received SIGNAL_CONNECT_ERROR for {}.", Long.toUnsignedString(this.connectionId)); @@ -319,45 +275,31 @@ private void handleSignal(String signal) { } private void setupDataChannels() { - RTCDataChannelInit reliableInit = new RTCDataChannelInit(); - reliableInit.ordered = true; - reliableInit.protocol = NetherNetConstants.RELIABLE_CHANNEL_LABEL; - - RTCDataChannelInit unreliableInit = new RTCDataChannelInit(); - unreliableInit.ordered = false; - unreliableInit.maxRetransmits = 0; - - RTCDataChannel reliable = peerConnection.createDataChannel(NetherNetConstants.RELIABLE_CHANNEL_LABEL, reliableInit); - RTCDataChannel unreliable = peerConnection.createDataChannel(NetherNetConstants.UNRELIABLE_CHANNEL_LABEL, unreliableInit); - - reliable.registerObserver(new RTCDataChannelObserver() { - @Override - public void onStateChange() { - if (reliable.getState() == RTCDataChannelState.OPEN) { - eventLoop().execute(() -> { - if (!handshakeComplete) { - log.debug("NetherNet Connection Established!"); - handshakeComplete = true; - - // Cancel timeout now that we are done - if (handshakeTimeoutTask != null) { - handshakeTimeoutTask.cancel(false); - } - - setDataChannels(reliable, unreliable); - if (connectPromise != null && !connectPromise.isDone()) { - connectPromise.trySuccess(); - } - pipeline().fireChannelActive(); - } - }); - } + DataChannelInitSettings reliableInit = DataChannelInitSettings.DEFAULT; + + DataChannelInitSettings unreliableInit = DataChannelInitSettings.DEFAULT + .withReliability(new DataChannelReliability(true, true, 0L, 0)); + + DataChannel reliable = peerConnection.createDataChannel(NetherNetConstants.RELIABLE_CHANNEL_LABEL, reliableInit); + DataChannel unreliable = peerConnection.createDataChannel(NetherNetConstants.UNRELIABLE_CHANNEL_LABEL, unreliableInit); + + reliable.onOpen.register(channel -> eventLoop().execute(() -> { + if (handshakeComplete) return; + + log.debug("NetherNet Connection Established!"); + handshakeComplete = true; + + // Cancel timeout now that we are done + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); } - @Override public void onBufferedAmountChange(long previousAmount) {} - @Override public void onMessage(RTCDataChannelBuffer buffer) { - ReferenceCountUtil.release(buffer); + + setDataChannels(reliable, unreliable); + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.trySuccess(); } - }); + pipeline().fireChannelActive(); + })); } private long cycleConnectionId() { diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java index dcc6a0f3..9a38665e 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -48,6 +48,8 @@ public class NetherNetConstants { // SCTP Constants public static final int MAX_SCTP_MESSAGE_SIZE = 10000; + public static final int MAX_ADVERTISED_MESSAGE_SIZE = 256 * 1024; // 256 KB + public static final String RELIABLE_CHANNEL_LABEL = "ReliableDataChannel"; public static final String UNRELIABLE_CHANNEL_LABEL = "UnreliableDataChannel"; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index 1eef7f7b..117a5a53 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -4,20 +4,7 @@ import dev.kastle.netty.channel.nethernet.config.NetherChannelOption; import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo; -import dev.kastle.webrtc.CreateSessionDescriptionObserver; -import dev.kastle.webrtc.PeerConnectionFactory; -import dev.kastle.webrtc.PeerConnectionObserver; -import dev.kastle.webrtc.RTCAnswerOptions; -import dev.kastle.webrtc.RTCBundlePolicy; -import dev.kastle.webrtc.RTCConfiguration; -import dev.kastle.webrtc.RTCDataChannel; -import dev.kastle.webrtc.RTCIceCandidate; -import dev.kastle.webrtc.RTCIceServer; -import dev.kastle.webrtc.RTCPeerConnection; -import dev.kastle.webrtc.RTCPeerConnectionState; -import dev.kastle.webrtc.RTCSdpType; -import dev.kastle.webrtc.RTCSessionDescription; -import dev.kastle.webrtc.SetSessionDescriptionObserver; +import dev.kastle.netty.util.nethernet.ServerIdentity; import io.netty.channel.AbstractServerChannel; import io.netty.channel.ChannelConfig; import io.netty.channel.ChannelMetadata; @@ -25,7 +12,15 @@ import io.netty.util.concurrent.ScheduledFuture; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; +import org.jose4j.lang.JoseException; +import tel.schich.libdatachannel.DataChannel; +import tel.schich.libdatachannel.GatheringState; +import tel.schich.libdatachannel.PeerConnection; +import tel.schich.libdatachannel.PeerConnectionConfiguration; +import tel.schich.libdatachannel.PeerState; +import tel.schich.libdatachannel.SessionDescriptionType; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.util.List; @@ -36,65 +31,79 @@ public class NetherNetServerChannel extends AbstractServerChannel { private static final ChannelMetadata METADATA = new ChannelMetadata(false, 16); private final DefaultNetherServerChannelConfig config; - private final PeerConnectionFactory factory; private final NetherNetServerSignaling signaling; - + private InetSocketAddress localAddress; private volatile boolean open = true; - /** - * Creates a NetherNetServerChannel with a new PeerConnectionFactory. - * - * @param signaling The NetherNetServerSignaling instance for signaling. - */ - public NetherNetServerChannel(NetherNetServerSignaling signaling) { - this(new PeerConnectionFactory(), signaling); - } + private ServerIdentity serverIdentity; /** * Creates a NetherNetServerChannel. - * - * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * * @param signaling The NetherNetServerSignaling instance for signaling. */ - public NetherNetServerChannel(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { - this.factory = factory; + public NetherNetServerChannel(NetherNetServerSignaling signaling) { this.signaling = signaling; this.config = new DefaultNetherServerChannelConfig(this); + + // Prefer the signaling identity so answers are signed with a key clients can attribute to us + this.serverIdentity = signaling.serverIdentity(); + if (this.serverIdentity == null) { + try { + this.serverIdentity = ServerIdentity.generate("self"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } @Override protected void doBind(SocketAddress localAddress) throws Exception { if (!(localAddress instanceof InetSocketAddress)) throw new IllegalArgumentException("Unsupported address type"); this.localAddress = (InetSocketAddress) localAddress; - + this.signaling.setNewConnectionHandler((connectionId, remoteNetworkId, offerSdp) -> { acceptConnection(connectionId, offerSdp, remoteNetworkId); }); - this.signaling.bind(localAddress); + this.signaling.bind(localAddress, eventLoop()); } - public void acceptConnection(long connectionId, String offerSdp, String remoteNetworkId) { - RTCConfiguration rtcConfig = new RTCConfiguration(); - rtcConfig.portAllocatorConfig = this.config.getOption(NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG); - rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; - - // Inject ICE servers if the signaling implementation supports it - List iceServers = this.signaling.getIceServers(); - if (iceServers != null && !iceServers.isEmpty()) { - log.trace("Injecting {} ICE Servers into PeerConnection for {}", iceServers.size(), Long.toUnsignedString(connectionId)); - for (IceServerInfo info : iceServers) { - RTCIceServer iceServer = new RTCIceServer(); - iceServer.urls = info.urls(); - iceServer.username = info.username(); - iceServer.password = info.password(); - rtcConfig.iceServers.add(iceServer); - } + /** + * Pins ICE to the bound address, so the transport uses one predictable port rather than an + * ephemeral one per connection. Skipped when the signaling holds that UDP port itself. + * + * @param config The configuration to derive from. + * @return The configuration with the bound address applied. + */ + private PeerConnectionConfiguration bindIce(PeerConnectionConfiguration config) { + if (localAddress == null || !signaling.allowsIceOnLocalPort()) return config; + + // A wildcard bind is left unset so ICE keeps gathering on every interface + InetAddress host = localAddress.getAddress(); + if (host != null && !host.isAnyLocalAddress()) { + config = config.withBindAddress(host); } + int port = localAddress.getPort(); + if (port <= 0) return config; + + // Enable multiplexing and set the port + return config + .withEnableIceUdpMux(true) + .withPortRangeBegin((short) port) + .withPortRangeEnd((short) port); + } + + public void acceptConnection(long connectionId, String offerSdp, String remoteNetworkId) { + PeerConnectionConfiguration rtcConfig = bindIce(this.config.getOption(NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG)) + .withDisableAutoNegotiation(true) + .withIceServers(this.signaling.getIceServers().stream().map(IceServerInfo::toUris).flatMap(List::stream).toList()); + ServerPeerConnectionObserver observer = new ServerPeerConnectionObserver(connectionId, remoteNetworkId); - RTCPeerConnection pc = factory.createPeerConnection(rtcConfig, observer); + PeerConnection pc = PeerConnection.createPeer(rtcConfig); + observer.setPeerConnection(pc); NetherNetChildChannel child = new NetherNetChildChannel(this, pc, new InetSocketAddress(0), localAddress); observer.setChildChannel(child); @@ -110,7 +119,9 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe } }, handshakeTimeoutSeconds, TimeUnit.SECONDS); observer.setHandshakeTimeout(timeoutTask); - + + observer.register(pc); + // Register Signal Handler signaling.setSignalHandler(connectionId, (signal) -> { String[] parts = signal.split(" ", 3); @@ -122,7 +133,7 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { log.trace("Applying Remote Candidate for {}: {}", Long.toUnsignedString(connectionId), data); try { - pc.addIceCandidate(new RTCIceCandidate("0", 0, data)); + pc.addRemoteCandidate(data); } catch (Exception e) { log.debug("Failed to apply ICE candidate for {} (Connection likely closed): {}", Long.toUnsignedString(connectionId), e.toString()); } @@ -135,51 +146,77 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe }); // Handle Offer - pc.setRemoteDescription(new RTCSessionDescription(RTCSdpType.OFFER, offerSdp), new SetSessionDescriptionObserver() { - @Override - public void onSuccess() { - log.trace("Remote description set for {}", Long.toUnsignedString(connectionId)); - pc.createAnswer(new RTCAnswerOptions(), new CreateSessionDescriptionObserver() { - @Override - public void onSuccess(RTCSessionDescription description) { - pc.setLocalDescription(description, new SetSessionDescriptionObserver() { - @Override - public void onSuccess() { - log.trace("Sending Answer SDP for {}", Long.toUnsignedString(connectionId)); - signaling.sendSignal( - remoteNetworkId, - NetherNetConstants.buildSignalConnectResponse(connectionId, description.sdp) - ); - pipeline().fireChannelRead(child); - } - @Override public void onFailure(String error) { log.error("SetLocalDesc failed: {}", error); } - }); - } - @Override public void onFailure(String error) { log.error("CreateAnswer failed: {}", error); } - }); + try { + pc.setRemoteDescription(offerSdp, SessionDescriptionType.OFFER); + log.trace("Remote description set for {}", Long.toUnsignedString(connectionId)); + pc.setLocalDescription("answer"); + } catch (Exception e) { + log.error("Failed to negotiate answer for {}", Long.toUnsignedString(connectionId), e); + abandon(connectionId, timeoutTask, pc); + return; + } + + // Anything without trickle answers once from onGatheringStateChange instead + if (signaling.usesTrickleIce()) { + log.trace("Sending Answer SDP for {}", Long.toUnsignedString(connectionId)); + try { + signaling.sendSignal( + remoteNetworkId, + NetherNetConstants.buildSignalConnectResponse(connectionId, serverIdentity.augmentAnswer(pc.localDescription())) + ); + } catch (JoseException e) { + log.error("Failed to send Answer SDP for {}", Long.toUnsignedString(connectionId), e); + abandon(connectionId, timeoutTask, pc); + return; } - @Override public void onFailure(String error) { log.error("SetRemoteDesc failed: {}", error); } - }); + } + + pipeline().fireChannelRead(child); + } + + /** + * Tears down a connection that failed before its child channel reached the pipeline. The child is + * left alone as it was never registered with an event loop, so closing it would throw. + * + * @param connectionId The connection being abandoned. + * @param timeoutTask The handshake timeout to cancel. + * @param pc The peer connection to close. + */ + private void abandon(long connectionId, ScheduledFuture timeoutTask, PeerConnection pc) { + timeoutTask.cancel(false); + signaling.removeSignalHandler(connectionId); + NetherNetChannel.deregisterAll(pc); + pc.close(); } /** * Observer to handle Data Channel creation from the client. */ - private class ServerPeerConnectionObserver implements PeerConnectionObserver { + private class ServerPeerConnectionObserver { private final long connectionId; private final String remoteNetworkId; private NetherNetChildChannel child; - - private RTCDataChannel reliable; - private RTCDataChannel unreliable; + + private DataChannel reliable; + private DataChannel unreliable; private ScheduledFuture handshakeTimeout; + private PeerConnection peerConnection; + private volatile boolean fullSdpSent = false; + public ServerPeerConnectionObserver(long connectionId, String remoteNetworkId) { this.connectionId = connectionId; this.remoteNetworkId = remoteNetworkId; } + public void register(PeerConnection pc) { + pc.onDataChannel.register((peer, dataChannel) -> onDataChannel(dataChannel)); + pc.onLocalCandidate.register((peer, candidate, mediaId) -> onLocalCandidate(candidate)); + pc.onStateChange.register((peer, state) -> onConnectionChange(state)); + pc.onGatheringStateChange.register((peer, state) -> onGatheringStateChange(state)); + } + public void setHandshakeTimeout(ScheduledFuture handshakeTimeout) { this.handshakeTimeout = handshakeTimeout; } @@ -189,29 +226,42 @@ public void setChildChannel(NetherNetChildChannel child) { checkDataChannels(); } - @Override - public void onIceCandidate(RTCIceCandidate candidate) { + public void setPeerConnection(PeerConnection pc) { + this.peerConnection = pc; + } + + private void onLocalCandidate(String candidate) { if (log.isTraceEnabled()) { - log.trace("Generated ICE Candidate for {}: {} (Type: {})", - Long.toUnsignedString(this.connectionId), candidate.sdp, extractCandidateType(candidate.sdp)); + log.trace("Generated ICE Candidate for {}: {} (Type: {})", + Long.toUnsignedString(this.connectionId), candidate, extractCandidateType(candidate)); } + + // Skip sending candidate if the signaling doesn't support trickle ICE + if (!signaling.usesTrickleIce()) { + return; + } + signaling.sendSignal( - remoteNetworkId, - NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate.sdp) + remoteNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate) ); } private String extractCandidateType(String sdp) { - if (sdp.contains(" typ host ")) return "host"; - if (sdp.contains(" typ srflx ")) return "srflx"; - if (sdp.contains(" typ relay ")) return "relay"; + if (sdp.contains(" typ host")) return "host"; + if (sdp.contains(" typ srflx")) return "srflx"; + if (sdp.contains(" typ relay")) return "relay"; return "unknown"; } - @Override - public void onConnectionChange(RTCPeerConnectionState state) { + private void onConnectionChange(PeerState state) { log.debug("Connection {} state changed: {}", Long.toUnsignedString(this.connectionId), state); - if (state == RTCPeerConnectionState.FAILED || state == RTCPeerConnectionState.CLOSED) { + if (state == PeerState.RTC_CONNECTED) { + // Resolve the real client address from the selected ICE candidate pair and store it on the child channel. + InetSocketAddress raw = this.peerConnection.remoteAddress(); + this.child.setRemoteAddress(new InetSocketAddress(raw.getHostString(), raw.getPort())); + } + if (state == PeerState.RTC_FAILED || state == PeerState.RTC_CLOSED) { if (child != null && child.isOpen()) { log.debug("Closing connection {} due to state change: {}", Long.toUnsignedString(this.connectionId), state); child.close(); @@ -222,20 +272,19 @@ public void onConnectionChange(RTCPeerConnectionState state) { } } - @Override - public void onDataChannel(RTCDataChannel dataChannel) { - String label = dataChannel.getLabel(); + private void onDataChannel(DataChannel dataChannel) { + String label = dataChannel.label(); log.debug("Received Data Channel: {}", label); - + if (NetherNetConstants.RELIABLE_CHANNEL_LABEL.equals(label)) { this.reliable = dataChannel; } else if (NetherNetConstants.UNRELIABLE_CHANNEL_LABEL.equals(label)) { this.unreliable = dataChannel; } - + checkDataChannels(); } - + private void checkDataChannels() { if (child != null && reliable != null && unreliable != null) { if (handshakeTimeout != null) { @@ -244,23 +293,39 @@ private void checkDataChannels() { log.debug("Data Channels established for {}", Long.toUnsignedString(this.connectionId)); child.setDataChannels(reliable, unreliable); - + if (child.pipeline() != null) { child.pipeline().fireChannelActive(); } } } + + private void onGatheringStateChange(GatheringState state) { + if (state != GatheringState.RTC_GATHERING_COMPLETE || fullSdpSent || signaling.usesTrickleIce()) return; + + String local; + try { + local = peerConnection.localDescription(); + } catch (Exception e) { + log.warn("Gathering complete for {} but the local description is unavailable: {}", Long.toUnsignedString(connectionId), e.toString()); + return; + } + + fullSdpSent = true; + + log.trace("Sending full SDP (with gathered candidates) for {}", Long.toUnsignedString(connectionId)); + try { + signaling.sendFullSdp(remoteNetworkId, serverIdentity.augmentAnswer(local)); + } catch (Exception e) { + log.error("Failed to sign the full SDP for {}", Long.toUnsignedString(connectionId), e); + } + } } @Override protected void doClose() throws Exception { this.open = false; - - try { - signaling.close(); - } finally { - factory.dispose(); - } + signaling.close(); } @Override @@ -275,24 +340,24 @@ protected SocketAddress localAddress0() { @Override protected boolean isCompatible(EventLoop loop) { - return true; + return true; } @Override public ChannelConfig config() { return config; } - - @Override - public boolean isOpen() { + + @Override + public boolean isOpen() { return this.open; } - - @Override - public boolean isActive() { + + @Override + public boolean isActive() { return isOpen() && localAddress0() != null; } - - @Override - public ChannelMetadata metadata() { - return METADATA; + + @Override + public ChannelMetadata metadata() { + return METADATA; } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java index 74cb0f92..1c931fd0 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java @@ -1,9 +1,10 @@ package dev.kastle.netty.channel.nethernet.config; -import dev.kastle.webrtc.PortAllocatorConfig; +import dev.kastle.netty.channel.nethernet.NetherNetConstants; import io.netty.channel.Channel; import io.netty.channel.ChannelOption; import io.netty.channel.DefaultChannelConfig; +import tel.schich.libdatachannel.PeerConnectionConfiguration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -11,16 +12,8 @@ public class DefaultNetherChannelConfig extends DefaultChannelConfig { private final Map, Object> options = new ConcurrentHashMap<>(); - private volatile PortAllocatorConfig portAllocatorConfig = new PortAllocatorConfig() - .setDisableTcp(true) - .setEnableIpv6(true) - .setEnableIpv6OnWifi(true) - .setEnableAnyAddressPorts(true) - .setDisableAdapterEnumeration(false) - .setEnableSharedSocket(true) - .setEnableAnyAddressPorts(true) - .setDisableCostlyNetworks(true) - .setDisableLinkLocalNetworks(true); + private volatile PeerConnectionConfiguration peerConnectionConfig = PeerConnectionConfiguration.DEFAULT + .withMaxMessageSize(NetherNetConstants.MAX_ADVERTISED_MESSAGE_SIZE); public DefaultNetherChannelConfig(Channel channel) { super(channel); @@ -30,7 +23,7 @@ public DefaultNetherChannelConfig(Channel channel) { public Map, Object> getOptions() { return this.getOptions( super.getOptions(), - NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG + NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG ); } @@ -38,8 +31,8 @@ public Map, Object> getOptions() { @Override public T getOption(ChannelOption option) { - if (option == NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG) { - return (T) this.portAllocatorConfig; + if (option == NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG) { + return (T) this.peerConnectionConfig; } else if (options.containsKey(option)) { return (T) options.get(option); } @@ -49,8 +42,8 @@ public T getOption(ChannelOption option) { @Override public boolean setOption(ChannelOption option, T value) { - if (option == NetherChannelOption.NETHER_PORT_ALLOCATOR_CONFIG) { - this.setPortAllocatorConfig((PortAllocatorConfig) value); + if (option == NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG) { + this.setPeerConnectionConfig((PeerConnectionConfiguration) value); return true; } else if (super.setOption(option, value)) { return true; @@ -60,7 +53,7 @@ public boolean setOption(ChannelOption option, T value) { } } - void setPortAllocatorConfig(PortAllocatorConfig portAllocatorConfig) { - this.portAllocatorConfig = portAllocatorConfig; + void setPeerConnectionConfig(PeerConnectionConfiguration peerConnectionConfig) { + this.peerConnectionConfig = peerConnectionConfig; } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java index 3aedcc14..1abcd2ee 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java @@ -1,15 +1,15 @@ package dev.kastle.netty.channel.nethernet.config; -import dev.kastle.webrtc.PortAllocatorConfig; import io.netty.channel.ChannelOption; +import tel.schich.libdatachannel.PeerConnectionConfiguration; public class NetherChannelOption extends ChannelOption { /** - * The PortAllocatorConfig used for WebRTC connections. + * The {@link PeerConnectionConfiguration} used for the underlying peer connections. */ - public static final ChannelOption NETHER_PORT_ALLOCATOR_CONFIG = - valueOf(NetherChannelOption.class, "NETHER_PORT_ALLOCATOR_CONFIG"); + public static final ChannelOption NETHER_PEER_CONNECTION_CONFIG = + valueOf(NetherChannelOption.class, "NETHER_PEER_CONNECTION_CONFIG"); /** * The timeout in seconds for completing the WebRTC handshake on the client before retrying. diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java index 782d66c7..3a4c1800 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java @@ -11,6 +11,7 @@ import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoop; import io.netty.channel.EventLoopGroup; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.nio.NioEventLoopGroup; @@ -21,6 +22,7 @@ import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler; +import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator; import io.netty.handler.codec.http.websocketx.WebSocketVersion; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; @@ -74,7 +76,7 @@ public synchronized CompletableFuture> connect(SocketAddress } @Override - public void bind(SocketAddress localAddress) throws ConnectException { + public void bind(SocketAddress localAddress, EventLoop eventLoop) throws ConnectException { try { connectInternal().join(); } catch (Exception e) { @@ -114,6 +116,7 @@ protected void initChannel(SocketChannel ch) { p.addLast(sslCtx.newHandler(ch.alloc(), uri.getHost(), 443)); p.addLast(new HttpClientCodec(), new HttpObjectAggregator(8192)); p.addLast("ws-handshake", new WebSocketClientProtocolHandler(handshaker)); + p.addLast("ws-aggregator", new WebSocketFrameAggregator(16 * 1024)); // Allow 16KB aggregations p.addLast("handler", AbstractNetherNetXboxSignaling.this); } }); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java index 16479d08..c3404f83 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -1,5 +1,6 @@ package dev.kastle.netty.channel.nethernet.signaling; +import io.netty.channel.EventLoop; import io.netty.util.ReferenceCountUtil; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; @@ -100,7 +101,12 @@ public CompletableFuture> connect(SocketAddress remote) { } @Override - public void bind(SocketAddress localAddress) { + public boolean allowsIceOnLocalPort() { + return false; + } + + @Override + public void bind(SocketAddress localAddress, EventLoop eventLoop) { if (!this.discovery.isActive()) { if (localAddress instanceof InetSocketAddress) { this.discovery.bind((InetSocketAddress) localAddress); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetHTTPSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetHTTPSignaling.java new file mode 100644 index 00000000..0e2a4b44 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetHTTPSignaling.java @@ -0,0 +1,497 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.JsonObject; +import dev.kastle.netty.util.http.HttpLoggingHandler; +import dev.kastle.netty.util.http.TlsRejectingHandler; +import dev.kastle.netty.util.nethernet.IdentityUtils; +import dev.kastle.netty.util.nethernet.PlayerInfo; +import dev.kastle.netty.util.nethernet.ServerIdentity; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFactory; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoop; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.QueryStringDecoder; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.util.concurrent.FutureListener; +import io.netty.util.concurrent.Promise; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import org.jose4j.jwt.JwtClaims; + +import javax.net.ssl.KeyManagerFactory; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.channels.ServerSocketChannel; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * This class implements a signaling server using HTTP(S) for the NetherNet protocol. + *

+ * Follows ... + */ +public class NetherNetHTTPSignaling implements NetherNetServerSignaling { + private final InternalLogger log = InternalLoggerFactory.getInstance(getClass()); + + private final Random random = new Random(); + private final Map> pendingAnswers = new ConcurrentHashMap<>(); + + private final PlayerFilter playerFilter; + private final MotdProvider motdProvider; + + private SslContext sslContext; + private ServerIdentity serverIdentity; + private NewConnectionHandler newConnectionHandler; + + private Channel serverChannel; + + private NetherNetHTTPSignaling(Builder builder) { + this.playerFilter = builder.playerFilter; + this.motdProvider = builder.motdProvider; + + if (builder.httpsKeystore != null) { + try { + char[] passwordChars = builder.httpsPassword.toCharArray(); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (FileInputStream fis = new FileInputStream(builder.httpsKeystore)) { + ks.load(fis, passwordChars); + } + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, passwordChars); + + this.sslContext = SslContextBuilder.forServer(kmf).build(); + } catch (Exception ex) { + log.error("Error loading https keystore: " + ex.getMessage(), ex); + } + } + + try { + this.serverIdentity = ServerIdentity.fromKeystore(builder.identityKeystore, builder.identityPassword); + } catch (Exception ex) { + log.error("Error loading identity keystore: " + ex.getMessage(), ex); + } + } + + @Override + public void bind(SocketAddress localAddress, EventLoop eventLoop) throws ConnectException { + if (!(localAddress instanceof InetSocketAddress)) { + throw new IllegalArgumentException("Unsupported address type"); + } + + // Bind the listening socket ourselves so a failure throws correctly + ServerSocketChannel channel; + try { + channel = ServerSocketChannel.open(); + channel.configureBlocking(false); + channel.bind(localAddress, 128); + } catch (IOException e) { + throw new ConnectException("Failed to bind HTTP signaling to " + localAddress + ": " + e.getMessage()); + } + + // Setup a new server bootstrap for http using the existing event loop and channel + ServerBootstrap bootstrap = new ServerBootstrap(); + bootstrap.group(eventLoop) + .channelFactory((ChannelFactory) () -> new NioServerSocketChannel(channel)) + .childHandler(new ChannelInitializer<>() { + @Override + protected void initChannel(Channel ch) { + ChannelPipeline p = ch.pipeline(); + // Handle ssl or drop it + if (sslContext != null) { + p.addLast(sslContext.newHandler(ch.alloc())); + } else { + p.addLast(new TlsRejectingHandler()); + } + + p.addLast(new HttpServerCodec()); + p.addLast(new HttpObjectAggregator(8 * 1024)); + p.addLast(new HttpLoggingHandler(log)); + p.addLast(new SignalingHandler()); + } + }); + + ChannelFuture regFuture = bootstrap.register(); + serverChannel = regFuture.channel(); + regFuture.addListener((ChannelFutureListener) future -> { + if (!future.isSuccess()) { + log.error("Failed to register HTTP signaling channel", future.cause()); + future.channel().close(); + } + }); + } + + private class SignalingHandler extends SimpleChannelInboundHandler { + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) { + if (req.decoderResult().isFailure()) { + respondEmptyWithStatus(ctx, HttpResponseStatus.BAD_REQUEST); + return; + } + + String path = new QueryStringDecoder(req.uri()).path(); + HttpMethod method = req.method(); + String host = req.headers().get(HttpHeaderNames.HOST); + InetSocketAddress remoteAddress = (InetSocketAddress) ctx.channel().remoteAddress(); + + // Respond to the status check + if (path.equals("/v1/join")) { + if (!HttpMethod.GET.equals(method)) { + respondEmptyWithStatus(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED); + return; + } + + PongData motd; + try { + motd = motdProvider.getMotd(host, remoteAddress); + } catch (Exception e) { + log.error("MOTD provider failed", e); + respondEmptyWithStatus(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR); + return; + } + + respondWithString(ctx, motd.toJson(), "application/json"); + return; + } + + // Only continue if the path is /v1/join/ + if (!path.startsWith("/v1/join/")) { + respondEmptyWithStatus(ctx, HttpResponseStatus.NOT_FOUND); + return; + } + + // Only continue if this is a post request + if (!HttpMethod.POST.equals(method)) { + respondEmptyWithStatus(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED); + return; + } + + String networkId = path.substring("/v1/join/".length()); + + // Reject empty, or anything with a further path segment + if (networkId.isEmpty() || networkId.indexOf('/') >= 0) { + respondEmptyWithStatus(ctx, HttpResponseStatus.NOT_FOUND); + return; + } + + String sdpOffer = req.content().toString(StandardCharsets.UTF_8); + log.trace("Received sdp offer: " + sdpOffer); + + JwtClaims claims; + try { + claims = IdentityUtils.validateSdp(sdpOffer); + } catch (Exception e) { + log.error("Identity validation failed", e); + respondEmptyWithStatus(ctx, HttpResponseStatus.UNAUTHORIZED); + return; + } + + PlayerInfo player = new PlayerInfo(claims.getClaimValueAsString("xid"), claims.getClaimValueAsString("xname"), networkId, remoteAddress, claims); + log.debug("Identity is valid: " + player.displayName() + " (" + player.xuid() + ")"); + + // Let the user reject the player before we start a connection for them + boolean allowed; + try { + allowed = playerFilter.allow(host, player); + } catch (Exception e) { + log.error("Player filter failed for " + player.xuid(), e); + allowed = false; + } + + if (!allowed) { + log.debug("Rejected join from " + player.displayName() + " (" + player.xuid() + ")"); + respondEmptyWithStatus(ctx, HttpResponseStatus.FORBIDDEN); + return; + } + + // Register the pending answer before firing the callback so a fast answer isn't missed. + Promise answer = ctx.executor().newPromise(); + pendingAnswers.put(networkId, answer); + + // Cancel the answer promise if we have waited 30s + ScheduledFuture timeout = ctx.executor().schedule(() -> { + answer.tryFailure(new TimeoutException("Timed out waiting for SDP answer")); + }, 30, TimeUnit.SECONDS); + + answer.addListener((FutureListener) future -> { + pendingAnswers.remove(networkId, answer); + timeout.cancel(false); + + if (!future.isSuccess()) { + log.error("No SDP answer for " + networkId, future.cause()); + respondEmptyWithStatus(ctx, HttpResponseStatus.GATEWAY_TIMEOUT); + return; + } + + String sdpAnswer = future.getNow(); + log.trace("Signed SDP answer: " + sdpAnswer); + + log.debug("Sending SDP answer"); + + respondWithString(ctx, sdpAnswer, "application/sdp"); + }); + + // We cant use the network ID as the connection ID as they can be out of the bounds of a long + newConnectionHandler.onConnect(random.nextLong(), networkId, sdpOffer); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + log.error("Signaling handler error", cause); + ctx.close(); + } + } + + private void respondEmptyWithStatus(ChannelHandlerContext ctx, HttpResponseStatus status) { + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.EMPTY_BUFFER); + response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 0); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private void respondWithString(ChannelHandlerContext ctx, String body, String contentType) { + ByteBuf bodyBuf = Unpooled.wrappedBuffer(body.getBytes(StandardCharsets.UTF_8)); + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, bodyBuf); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType); + response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bodyBuf.readableBytes()); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + @Override + public void setNewConnectionHandler(NewConnectionHandler handler) { + this.newConnectionHandler = handler; + } + + @Override + public void setAdvertisementData(PongData pongData) { + // No-op for Web Signaling. + } + + @Override + public ServerIdentity serverIdentity() { + return this.serverIdentity; + } + + @Override + public boolean usesTrickleIce() { + return false; + } + + @Override + public void sendFullSdp(String targetNetworkId, String sdp) { + log.debug("Sending sdp to " + targetNetworkId); + + Promise answer = pendingAnswers.get(targetNetworkId); + if (answer != null) { + answer.trySuccess(sdp); + } else { + log.debug("No pending join waiting for " + targetNetworkId); + } + } + + @Override + public void setSignalHandler(long connectionId, SignalHandler handler) { + // No-op for Web Signaling. + } + + @Override + public void removeSignalHandler(long connectionId) { + // No-op for Web Signaling. + } + + @Override + public String getLocalNetworkId() { + return ""; + } + + @Override + public void close() { + if (serverChannel != null) serverChannel.close(); + } + + /** + * Functional interface for filtering players before a connection is created for them. + */ + @FunctionalInterface + public interface PlayerFilter { + /** + * Called once the identity attached to an SDP offer has been validated, before + * the connection is handed to the {@link NewConnectionHandler}. + *

+ * Called on the event loop, so don't block in here. A thrown exception is treated + * as a rejection. + * + * @param host The host header from the join request, which may be used to identify the server + * @param player The validated player attempting to join + * @return true to accept the player, false to reject them with a 403 + */ + boolean allow(String host, PlayerInfo player); + } + + /** + * Functional interface providing the MOTD returned to clients querying the server. + */ + @FunctionalInterface + public interface MotdProvider { + /** + * Called for every status request, so the returned data can change over time. + *

+ * Called on the event loop, so don't block in here. The discovery-only fields of + * {@link PongData} are ignored, as they have no place in the status response. + * + * @param host The host header from the join request, which may be used to identify the server + * @param remoteAddress The address the status request came from + * @return The MOTD to advertise + */ + PongData getMotd(String host, InetSocketAddress remoteAddress); + } + + /** + * Builder for {@link NetherNetHTTPSignaling}. + *

+ * The server is backed by one keystore for the TLS listener and another for the + * server identity used to sign SDP answers. Both must be PKCS12 files, and only + * the identity keystore is required. + */ + public static class Builder { + private File identityKeystore; + private String identityPassword = ""; + private File httpsKeystore; + private String httpsPassword = ""; + private PlayerFilter playerFilter = (host, player) -> true; + private MotdProvider motdProvider = (host, remoteAddress) -> PongData.DEFAULT; + + /** + * Sets the unprotected keystore holding the identity key. Required. + * + * @param identityKeystore PKCS12 keystore holding the EC P-384 identity key + * @return This builder + */ + public Builder setIdentityKeystore(File identityKeystore) { + return setIdentityKeystore(identityKeystore, ""); + } + + /** + * Sets the keystore holding the identity key used to sign SDP answers. Required. + *

+ * The key must be EC P-384, and its certificate CN is surfaced as the identity + * domain, so set it to something recognisable. + * Generate one with: + *

{@code
+         * keytool -genkeypair -alias identity -keyalg EC -groupname secp384r1 \
+         *         -storetype PKCS12 -keystore identity.p12 -storepass changeit \
+         *         -dname "CN=Your Server" -validity 3650
+         * }
+ * + * @param identityKeystore PKCS12 keystore holding the EC P-384 identity key + * @param identityPassword Password for {@code identityKeystore}, or "" if unprotected + * @return This builder + */ + public Builder setIdentityKeystore(File identityKeystore, String identityPassword) { + this.identityKeystore = identityKeystore; + this.identityPassword = identityPassword; + return this; + } + + /** + * Sets the unprotected keystore holding the TLS certificate and key. + * + * @param httpsKeystore PKCS12 keystore holding the TLS certificate and key + * @return This builder + */ + public Builder setHttpsKeystore(File httpsKeystore) { + return setHttpsKeystore(httpsKeystore, ""); + } + + /** + * Sets the keystore holding the TLS certificate and key. + * If unset the server listens in plaintext. + * + * @param httpsKeystore PKCS12 keystore holding the TLS certificate and key + * @param httpsPassword Password for {@code httpsKeystore}, or "" if unprotected + * @return This builder + */ + public Builder setHttpsKeystore(File httpsKeystore, String httpsPassword) { + this.httpsKeystore = httpsKeystore; + this.httpsPassword = httpsPassword; + return this; + } + + /** + * Sets the filter consulted for each join once its identity has been validated. + * Defaults to allowing everyone. + * + * @param playerFilter The filter to consult + * @return This builder + */ + public Builder setPlayerFilter(PlayerFilter playerFilter) { + this.playerFilter = playerFilter; + return this; + } + + /** + * Sets the provider called for each status request. + * Defaults to {@link PongData#DEFAULT}. + * + * @param motdProvider The provider to call + * @return This builder + */ + public Builder setMotdProvider(MotdProvider motdProvider) { + this.motdProvider = motdProvider; + return this; + } + + /** + * Sets a fixed MOTD to advertise for every status request. + * + * @param motd The MOTD to advertise + * @return This builder + */ + public Builder setMotd(PongData motd) { + return setMotdProvider((host, remoteAddress) -> motd); + } + + /** + * Builds the signalling instance. + * + * @return A new signalling instance + * @throws IllegalStateException If no identity keystore was set + */ + public NetherNetHTTPSignaling build() { + if (identityKeystore == null) { + throw new IllegalStateException("An identity keystore is required"); + } + + return new NetherNetHTTPSignaling(this); + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java index a15b53ad..8063b7c6 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -1,5 +1,9 @@ package dev.kastle.netty.channel.nethernet.signaling; +import com.google.gson.JsonObject; +import dev.kastle.netty.util.nethernet.ServerIdentity; +import io.netty.channel.EventLoop; + import java.net.ConnectException; import java.net.SocketAddress; import java.util.List; @@ -7,11 +11,12 @@ public interface NetherNetServerSignaling extends NetherNetSignaling { /** * Binds the signaling medium to listen for incoming connections (Server mode). - * + * * @param localAddress The local address to bind to. - * @throws ConnectException + * @param eventLoop The owning channel's event loop. + * @throws ConnectException */ - void bind(SocketAddress localAddress) throws ConnectException; + void bind(SocketAddress localAddress, EventLoop eventLoop) throws ConnectException; /** * Handler for new connections. @@ -50,10 +55,39 @@ default List getIceServers() { return java.util.Collections.emptyList(); } + /** + * Returns the identity used to sign SDP answers, or null to have the channel generate an ephemeral one. + * + * @return The server identity, or null if this signaling has none + */ + default ServerIdentity serverIdentity() { + return null; + } + + /** + * Whether ICE may bind to the address the channel was bound to, instead of an ephemeral port. + * + * @return true if ICE should be pinned to the bound address + */ + default boolean allowsIceOnLocalPort() { + return true; + } + + /** + * Whether this signaling can deliver ICE candidates incrementally after the answer has been sent. + * + * @return true if candidates are trickled as they are gathered + */ + default boolean usesTrickleIce() { + return true; + } + /** * Data structure for Pong advertisement data. * * @param serverName The name of the server. + * @param protocol The Bedrock protocol version the server speaks. + * @param version The Bedrock version string the server reports. * @param levelName The name of the level/world. * @param gameType The game type (e.g. Survival, Creative). * @param playerCount The current number of players. @@ -63,10 +97,28 @@ default List getIceServers() { * @param transportLayer The transport layer identifier (e.g. NetherNet). * @param connectionType The connection type identifier (e.g. LAN, Online). */ - public record PongData(String serverName, String levelName, int gameType, int playerCount, int maxPlayerCount, - boolean isEditorWorld, boolean isHardcore, int transportLayer, int connectionType) { + public record PongData(String serverName, int protocol, String version, String levelName, int gameType, + int playerCount, int maxPlayerCount, boolean isEditorWorld, boolean isHardcore, int transportLayer, + int connectionType) { + + public static final PongData DEFAULT = new Builder().build(); + + public String toJson() { + JsonObject info = new JsonObject(); + info.addProperty("name", serverName()); + info.addProperty("protocol", protocol()); + info.addProperty("version", version()); + info.addProperty("level", levelName()); + info.addProperty("players", playerCount()); + info.addProperty("maxPlayers", maxPlayerCount()); + info.addProperty("gameType", gameType()); + return info.toString(); + } + public static class Builder { private String serverName = "Server"; + private int protocol = 2187; + private String version = "1.26.50"; private String levelName = "World"; private int gameType = 0; // Default to Survival private int playerCount = 0; @@ -81,6 +133,16 @@ public Builder setServerName(String serverName) { return this; } + public Builder setProtocol(int protocol) { + this.protocol = protocol; + return this; + } + + public Builder setVersion(String version) { + this.version = version; + return this; + } + public Builder setLevelName(String levelName) { this.levelName = levelName; return this; @@ -122,8 +184,8 @@ public Builder setConnectionType(int connectionType) { } public PongData build() { - return new PongData(serverName, levelName, gameType, playerCount, maxPlayerCount, - isEditorWorld, isHardcore, transportLayer, connectionType); + return new PongData(serverName, protocol, version, levelName, gameType, playerCount, + maxPlayerCount, isEditorWorld, isHardcore, transportLayer, connectionType); } } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java index fe63d3bb..8e66068f 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java @@ -1,5 +1,13 @@ package dev.kastle.netty.channel.nethernet.signaling; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; public interface NetherNetSignaling extends AutoCloseable { @@ -10,7 +18,20 @@ public interface NetherNetSignaling extends AutoCloseable { * @param targetNetworkId The Network ID of the destination (String to support Realms). * @param data The raw signaling payload. */ - void sendSignal(String targetNetworkId, String data); + default void sendSignal(String targetNetworkId, String data) { + // Default implementation does nothing + } + + /** + * Sends a full SDP message with all candidates to the remote peer + * TODO Find a better name + * + * @param targetNetworkId The Network ID of the destination (String to support Realms). + * @param sdp The full SDP message with all candidates. + */ + default void sendFullSdp(String targetNetworkId, String sdp) { + // Default implementation does nothing + } /** * Sets a handler to receive signaling messages for a specific connection ID. @@ -60,6 +81,55 @@ interface SignalHandler { * @param urls The list of URLs for the ICE server. */ public record IceServerInfo(String username, String password, List urls) { + private static final InternalLogger log = InternalLoggerFactory.getInstance(IceServerInfo.class); + + /** + * Converts this server to the URI form libdatachannel expects, which carries the credentials in + * the authority: {@code turn:user:pass@host:port?transport=udp}. Unparseable URLs are skipped. + * + * @return The URIs for this server. + */ + public List toUris() { + List uris = new ArrayList<>(); + if (urls == null) return uris; + + for (String url : urls) { + if (url == null || url.isBlank()) continue; + + try { + uris.add(new URI(withCredentials(url.trim()))); + } catch (URISyntaxException e) { + log.warn("Ignoring unparseable ICE server URL {}: {}", url, e.toString()); + } + } + + return uris; + } + + /** + * Inserts the credentials after the scheme, leaving STUN alone as it has no authentication. + */ + private String withCredentials(String url) { + int scheme = url.indexOf(':'); + if (scheme < 0 || username == null || username.isEmpty() || url.regionMatches(true, 0, "stun", 0, 4)) { + return url; + } + + int authority = url.startsWith("://", scheme) ? scheme + 3 : scheme + 1; + if (url.indexOf('@', authority) >= 0) return url; + + return url.substring(0, authority) + encode(username) + ":" + encode(password) + "@" + url.substring(authority); + } + + /** + * Encodes the input using %20 for spaces instead of +, which is what libdatachannel expects. + */ + private static String encode(String value) { + if (value == null) return ""; + + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + public static class Builder { private String username = ""; private String password = ""; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java index 4345a77f..b9cbd689 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java @@ -124,9 +124,17 @@ private void handleRequest(JsonObject json) { switch (method) { case NetherNetConstants.XBOX_RPC_METHOD_RECEIVE_MESSAGE -> { if (id != null) sendJsonRpcResult(id, null); - JsonArray params = json.getAsJsonArray("params"); - if (params != null) { - for (JsonElement el : params) processIncomingMessage(el.getAsJsonObject()); + + if (json.isJsonArray()) { + JsonArray params = json.getAsJsonArray("params"); + if (params != null) { + for (JsonElement el : params) processIncomingMessage(el.getAsJsonObject()); + } + } else if (json.isJsonObject()) { + JsonObject params = json.getAsJsonObject("params"); + if (params != null) { + processIncomingMessage(params); + } } } case NetherNetConstants.XBOX_RPC_METHOD_PONG, NetherNetConstants.XBOX_RPC_METHOD_PING -> { diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/http/HttpLoggingHandler.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/http/HttpLoggingHandler.java new file mode 100644 index 00000000..68d383b7 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/http/HttpLoggingHandler.java @@ -0,0 +1,41 @@ +package dev.kastle.netty.util.http; + +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.util.internal.logging.InternalLogger; + +/** + * Log any web requests and their response codes and times to a given Netty log + */ +public class HttpLoggingHandler extends ChannelDuplexHandler { + private final InternalLogger log; + + private long startNanos; + private String request; + + public HttpLoggingHandler(InternalLogger log) { + this.log = log; + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof HttpRequest req) { + startNanos = System.nanoTime(); + request = ctx.channel().remoteAddress() + " " + req.method() + " " + req.uri(); + } + ctx.fireChannelRead(msg); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + if (msg instanceof HttpResponse res) { + long ms = (System.nanoTime() - startNanos) / 1_000_000; + log.debug("{} -> {} ({} ms)", request, res.status().code(), ms); + } + ctx.write(msg, promise); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/http/TlsRejectingHandler.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/http/TlsRejectingHandler.java new file mode 100644 index 00000000..88aea911 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/http/TlsRejectingHandler.java @@ -0,0 +1,28 @@ +package dev.kastle.netty.util.http; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.handler.ssl.SslHandler; + +import java.util.List; + +/** + * Rejects TLS clients on a plaintext port + */ +public class TlsRejectingHandler extends ByteToMessageDecoder { + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + // Make sure we have enough bytes to check if this is TLS + if (in.readableBytes() < 5) return; + + if (SslHandler.isEncrypted(in)) { + ctx.close(); + return; + } + + // Allow the pipeline to continue + ctx.pipeline().remove(this); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/Identity.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/Identity.java new file mode 100644 index 00000000..065b0398 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/Identity.java @@ -0,0 +1,47 @@ +package dev.kastle.netty.util.nethernet; + +import com.google.gson.Gson; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +public record Identity(Idp idp, Assertion assertion) { + private static Gson gson = new Gson(); + + public static Identity fromJson(String identityString) { + return new Identity(gson.fromJson(identityString, Raw.class)); + } + + public static Identity fromBase64(String identityString) { + return Identity.fromJson(new String(Base64.getDecoder().decode(identityString.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)); + } + + public static Identity fromSdpOffer(String sdpOffer) { + String prefix = "a=identity:"; + String identity = Arrays.stream(sdpOffer.split("\n")).filter(line -> line.startsWith(prefix)).findFirst().orElse(null); + if (identity == null) { + return null; + } + identity = identity.substring(prefix.length()).trim(); + return Identity.fromBase64(identity); + } + + private Identity(Raw raw) { + this(raw.idp(), gson.fromJson(raw.assertion(), Assertion.class)); + } + + private record Raw(Idp idp, String assertion) {} + + public record Idp(String domain, String protocol) {} + + public record Assertion(String token, String fingerprints) {} + + public String toJson() { + return gson.toJson(new Raw(idp, gson.toJson(assertion))); + } + + public String toBase64() { + return Base64.getEncoder().encodeToString(toJson().getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/IdentityUtils.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/IdentityUtils.java new file mode 100644 index 00000000..13791819 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/IdentityUtils.java @@ -0,0 +1,119 @@ +package dev.kastle.netty.util.nethernet; + +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import org.jose4j.jwk.HttpsJwks; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwa.AlgorithmConstraints; +import org.jose4j.jwa.AlgorithmConstraints.ConstraintType; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumer; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.jwt.consumer.JwtContext; +import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; +import org.jose4j.lang.JoseException; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; +import java.util.Base64; +import java.util.stream.Collectors; + +public class IdentityUtils { + private static final InternalLogger log = InternalLoggerFactory.getInstance(IdentityUtils.class); + + private static final JwtConsumer JWT_CONSUMER = new JwtConsumerBuilder() + .setVerificationKeyResolver(new HttpsJwksVerificationKeyResolver(new HttpsJwks("https://authorization.franchise.minecraft-services.net/.well-known/keys"))) + .setRequireExpirationTime() + .setRequireSubject() + .setExpectedAudience(true, "api://auth-minecraft-services/multiplayer") + .setExpectedIssuer("https://authorization.franchise.minecraft-services.net/") + .build(); + + /** + * Validate the given identity against the known jwt signer + * + * @param identity The identity to validate + * @return The JWT context if the identity is valid + * @throws InvalidJwtException If the identity is invalid + */ + public static JwtContext validateIdentity(Identity identity) throws InvalidJwtException { + return JWT_CONSUMER.process(identity.assertion().token()); // TODO Take into account the idp in the identity + } + + /** + * Validate the SDP offer against the embedded identity and known jwt signer + * This is designed into the spec to prevent MITM + * + * @param sdpOffer The SDP offer to validate + * @return The JWT claims if the SDP offer is valid + * @throws JoseException If there is an error processing the SDP offer + * @throws InvalidJwtException If the SDP offer contains an invalid JWT + */ + public static JwtClaims validateSdp(String sdpOffer) throws JoseException, InvalidJwtException { + // Extract the identity + Identity identity = Identity.fromSdpOffer(sdpOffer); + if (identity == null) { + throw new JoseException("Invalid SDP offer: missing identity"); + } + log.debug("Received identity: " + identity); + + JwtContext jwtContext = IdentityUtils.validateIdentity(identity); + JwtClaims claims = jwtContext.getJwtClaims(); + + // Reconstruct the detached payload from the SDP fingerprint lines + String fingerprints = getCanonicalFingerprintJson(sdpOffer); + if (fingerprints.length() == 18) { // Check if it is just the empty array {"fingerprint":[]} + throw new JoseException("Invalid SDP offer: no fingerprints"); + } + + // Validate the detached fingerprints JWS against the cpk from the token + String detachedJws = identity.assertion().fingerprints(); + try { + JsonWebSignature jws = new JsonWebSignature(); + jws.setCompactSerialization(detachedJws); + + // cpk is base64, decode it and parse as a public key + byte[] der = Base64.getDecoder().decode(claims.getClaimValueAsString("cpk")); + PublicKey cpkKey = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(der)); + + // Set the JWS properties so we can verify + jws.setKey(cpkKey); + jws.setPayload(fingerprints); + jws.setAlgorithmConstraints(new AlgorithmConstraints(ConstraintType.PERMIT, AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384)); + + if (!jws.verifySignature()) { + throw new JoseException("Fingerprint signature mismatch"); + } + } catch (GeneralSecurityException e) { + throw new JoseException("Fingerprint JWS validation failed", e); + } + + return claims; + } + + /** + * Get the canonical fingerprint JSON from the SDP offer + * + * @param sdpOffer The SDP offer to extract fingerprints from + * @return The canonical fingerprint JSON + */ + public static String getCanonicalFingerprintJson(String sdpOffer) { + String prefix = "a=fingerprint:"; + return Arrays.stream(sdpOffer.split("\n")) + .filter(line -> line.startsWith(prefix)) + .map(line -> line.substring(prefix.length()).trim()) + .map(line -> { + String[] parts = line.split(" "); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid fingerprint line: " + line); + } + return "{\"algorithm\":\"" + parts[0] + "\",\"digest\":\"" + parts[1] + "\"}"; + }) + .collect(Collectors.joining(",", "{\"fingerprint\":[", "]}")); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java new file mode 100644 index 00000000..7902e08d --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java @@ -0,0 +1,83 @@ +package dev.kastle.netty.util.nethernet; + +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +/** + * Controls how much of libdatachannel's own logging reaches your logs. + *

+ * The native logger runs at its most verbose level and the binding maps that straight onto SLF4J, so a + * connection emits a couple of dozen lines at INFO about ICE, DTLS and SCTP internals. Neither is + * configurable, leaving the level of {@value #NATIVE_LOGGER} as the only place to filter. + */ +public final class NetherNetLogging { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetLogging.class); + + /** The SLF4J logger libdatachannel routes its native output through. */ + public static final String NATIVE_LOGGER = "tel.schich.libdatachannel.LibDataChannel"; + + private NetherNetLogging() { + } + + /** + * Sets the level of the libdatachannel logger. SLF4J has no level API, so this is applied through + * Log4j2 or Logback; with any other backend it does nothing and you should configure it yourself. + * + * @param level One of OFF, ERROR, WARN, INFO, DEBUG, TRACE or ALL. WARN is a good default. + * @return true if the level was applied, false if the backend was not recognised. + */ + public static boolean setNativeLogLevel(String level) { + if (level == null || level.isBlank()) { + return false; + } + + String normalised = level.trim().toUpperCase(); + + if (applyLog4j2(normalised) || applyLogback(normalised)) { + log.debug("Set {} to {}", NATIVE_LOGGER, normalised); + return true; + } + + log.debug("Could not set {} to {}, no supported logging backend found", NATIVE_LOGGER, normalised); + return false; + } + + private static boolean applyLog4j2(String level) { + try { + Class levelClass = Class.forName("org.apache.logging.log4j.Level"); + Class configurator = Class.forName("org.apache.logging.log4j.core.config.Configurator"); + + Object parsed = levelClass.getMethod("toLevel", String.class, levelClass) + .invoke(null, level, levelClass.getField("WARN").get(null)); + + configurator.getMethod("setLevel", String.class, levelClass) + .invoke(null, NATIVE_LOGGER, parsed); + return true; + } catch (Throwable t) { + // Not on Log4j2, or it resolved a different logger context than ours + return false; + } + } + + private static boolean applyLogback(String level) { + try { + Object logger = Class.forName("org.slf4j.LoggerFactory") + .getMethod("getLogger", String.class) + .invoke(null, NATIVE_LOGGER); + + Class logbackLogger = Class.forName("ch.qos.logback.classic.Logger"); + if (!logbackLogger.isInstance(logger)) { + return false; + } + + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Object parsed = levelClass.getMethod("toLevel", String.class).invoke(null, level); + + logbackLogger.getMethod("setLevel", levelClass).invoke(logger, parsed); + return true; + } catch (Throwable t) { + // Not on Logback + return false; + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/PlayerInfo.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/PlayerInfo.java new file mode 100644 index 00000000..c5cccfce --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/PlayerInfo.java @@ -0,0 +1,17 @@ +package dev.kastle.netty.util.nethernet; + +import org.jose4j.jwt.JwtClaims; + +import java.net.InetSocketAddress; + +/** + * The validated identity of a player attempting to join. + * + * @param xuid The Xbox user ID of the player + * @param displayName The Xbox gamertag of the player + * @param networkId The Network ID the player is joining with + * @param remoteAddress The address the join request came from + * @param claims The full set of validated JWT claims, for anything not surfaced above + */ +public record PlayerInfo(String xuid, String displayName, String networkId, InetSocketAddress remoteAddress, JwtClaims claims) { +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/ServerIdentity.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/ServerIdentity.java new file mode 100644 index 00000000..eeda3aa8 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/ServerIdentity.java @@ -0,0 +1,220 @@ +package dev.kastle.netty.util.nethernet; + +import org.jose4j.jwk.EcJwkGenerator; +import org.jose4j.jwk.EllipticCurveJsonWebKey; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.NumericDate; +import org.jose4j.keys.EllipticCurves; +import org.jose4j.lang.JoseException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.security.auth.x500.X500Principal; + +/** + * Produces the server-side identity assertion for each SDP answer + * + * @see NetherNet onboarding guide, section 5.2 + */ +public class ServerIdentity { + private static final String ALG = AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384; // ES384 / P-384 + + private final PrivateKey privateKey; + private final String domain; + private final String token; + + public ServerIdentity(PrivateKey privateKey, PublicKey publicKey, Instant expiry, String domain) throws JoseException { + this.privateKey = privateKey; + this.domain = domain; + this.token = buildToken(publicKey, expiry); + } + + /** + * Loads the keypair from the first key entry of a PKCS12 keystore. + * + * @param keystore The PKCS12 keystore file + * @param password The keystore password + * @return The loaded ServerIdentity + * @throws GeneralSecurityException If there is a security error + * @throws IOException If there is an I/O error + * @throws JoseException If there is an error creating the JWT + */ + public static ServerIdentity fromKeystore(File keystore, String password) throws GeneralSecurityException, IOException, JoseException { + char[] pwd = password.toCharArray(); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (FileInputStream fis = new FileInputStream(keystore)) { + ks.load(fis, pwd); + } + + // Find the first key in the keystore and extract the certificate + String alias = findKeyAlias(ks); + PrivateKey privateKey = (PrivateKey) ks.getKey(alias, pwd); + Certificate cert = ks.getCertificate(alias); + PublicKey publicKey = cert.getPublicKey(); + + // Extract the expiry and common name from the cert if they exist + Instant expiry = null; + String domain = ""; + if (cert instanceof X509Certificate x509) { + expiry = x509.getNotAfter().toInstant(); + domain = extractCommonName(x509.getSubjectX500Principal()); + } + + return new ServerIdentity(privateKey, publicKey, expiry, domain); + } + + /** + * Finds the first key entry alias in a keystore. + * + * @param keyStore The keystore to search + * @return The alias of the first key entry + * @throws KeyStoreException If no key entry is found + */ + private static String findKeyAlias(KeyStore keyStore) throws KeyStoreException { + for (String candidate : Collections.list(keyStore.aliases())) { + if (keyStore.isKeyEntry(candidate)) { + return candidate; + } + } + throw new KeyStoreException("No private key entry found in identity keystore"); + } + + /** + * Search the principal and extract the common name + * + * @param principal The X500Principal to extract the common name from + * @return The common name, or an empty string if not found + */ + private static String extractCommonName(X500Principal principal) { + try { + LdapName name = new LdapName(principal.getName()); + for (Rdn rdn : name.getRdns()) { + if (rdn.getType().equalsIgnoreCase("CN")) { + return rdn.getValue().toString(); + } + } + } catch (InvalidNameException ignored) { } + return ""; + } + + /** + * Generate a brand-new server identity that is not stored + * + * @param domain The domain name for the server identity + * @return A new ServerIdentity instance + * @throws JoseException If there is an error creating the JWT + */ + public static ServerIdentity generate(String domain) throws JoseException { + EllipticCurveJsonWebKey jwk = EcJwkGenerator.generateJwk(EllipticCurves.P384); + return new ServerIdentity(jwk.getPrivateKey(), jwk.getPublicKey(), null, domain); + } + + /** + * Build a JWT token with the given public key and expiry. + * + * @param publicKey The public key to include in the token + * @param expiry The expiration time of the token + * @return The signed JWT token + * @throws JoseException If there is an error signing the token + */ + private String buildToken(PublicKey publicKey, Instant expiry) throws JoseException { + JwtClaims claims = new JwtClaims(); + claims.setClaim("cpk", Base64.getEncoder().encodeToString(publicKey.getEncoded())); // Custom claim required by the NetherNet spec + claims.setIssuedAtToNow(); + + // If we have a domain set it as the isser as it could be shown to the user + if (domain != null && !domain.isBlank()) { + claims.setIssuer(domain); + } + + // Mirror the certificate expiry if set + if (expiry != null) { + claims.setExpirationTime(NumericDate.fromMilliseconds(expiry.toEpochMilli())); + } + + return sign(claims.toJson()); + } + + /** + * Sign the payload with the private key and return the compact JWS serialization. + * + * @param payload The payload to sign + * @return The compact JWS serialization + * @throws JoseException If there is an error signing the payload + */ + private String sign(String payload) throws JoseException { + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(payload); + jws.setKey(privateKey); + jws.setAlgorithmHeaderValue(ALG); + return jws.getCompactSerialization(); + } + + /** + * Generate the identity value as base64 for this answer SDP + * + * @param answerSdp The SDP to generate the identity value for + * @return The base64 identity value + * @throws JoseException If there is an error signing the identity value + */ + public String identityValue(String answerSdp) throws JoseException { + // Generate and sign the fingerprint + String[] fingerprintParts = sign(IdentityUtils.getCanonicalFingerprintJson(answerSdp)).split("\\."); + String fingerprints = fingerprintParts[0] + ".." + fingerprintParts[2]; + + Identity.Assertion assertion = new Identity.Assertion(token, fingerprints); + Identity.Idp idp = new Identity.Idp(domain, "default"); + return new Identity(idp, assertion).toBase64(); + } + + /** + * Insert the identity into the answer SDP + * The specific placement is a strange requirement for the spec but we will follow it + * + * @param answerSdp The SDP to insert the identity into + * @return The SDP with the identity inserted + * @throws JoseException If there is an error signing the identity value + */ + public String augmentAnswer(String answerSdp) throws JoseException { + String line = "a=identity:" + identityValue(answerSdp); + String eol = answerSdp.contains("\r\n") ? "\r\n" : "\n"; + + String[] lines = answerSdp.split("\r\n|\n", -1); + List out = new ArrayList<>(lines.length + 1); + + boolean inserted = false; + for (String current : lines) { + if (!inserted && current.startsWith("m=")) { + out.add(line); + inserted = true; + } + out.add(current); + } + + if (!inserted) { + out.add(line); + } + + return String.join(eol, out); + } +} From fc7dcc3534d34a0268e9a26ead49a48794b32c0b Mon Sep 17 00:00:00 2001 From: Zulu Date: Sat, 5 Sep 2026 21:09:50 +0100 Subject: [PATCH 03/15] Define NXS v1 and a provider-neutral external signalling client Specify registration, signed lifecycle, scheduling, key management, extensions and stateless handoff. The Java client negotiates the new profile, preserves durable identity through explicit profile recovery, carries optional extension metadata only in memory, and never stages individual admissions from control-plane delivery. Include independent provider journeys and canonical JavaScript/JVM conformance fixtures. --- docs/external-signalling/README.md | 277 ++++++++++++ .../cloudburst-protocol-vectors.v1.json | 36 ++ docs/external-signalling/fixtures.mjs | 53 +++ docs/external-signalling/nxs-v1.fixtures.json | 77 ++++ docs/external-signalling/nxs-v1.schema.json | 425 ++++++++++++++++++ docs/external-signalling/provenance.json | 7 + .../stateless-admission-v1.fixtures.json | 47 ++ external-signalling/README.md | 28 ++ external-signalling/build.gradle.kts | 61 +++ .../netty/signalling/CheckInSchedule.java | 25 ++ .../signalling/LimitedBodySubscriber.java | 27 ++ .../netty/signalling/ProtocolExtensions.java | 38 ++ .../netty/signalling/ProviderClient.java | 422 +++++++++++++++++ .../netty/signalling/ProviderContract.java | 30 ++ .../netty/signalling/ProviderCrypto.java | 80 ++++ .../netty/signalling/ProviderIdentity.java | 18 + .../netty/signalling/ProviderStateStore.java | 44 ++ .../netty/signalling/ProviderTransport.java | 24 + .../netty/signalling/ServerStatus.java | 11 + .../netty/signalling/CheckInScheduleTest.java | 32 ++ .../signalling/IndependentProviderStub.java | 127 ++++++ .../signalling/ProtocolExtensionsTest.java | 34 ++ .../netty/signalling/ProviderBench.java | 48 ++ .../netty/signalling/ProviderClientTest.java | 170 +++++++ .../netty/signalling/ProviderInteropTest.java | 32 ++ .../signalling/ProviderJourneysTest.java | 98 ++++ gradle.properties | 4 + settings.gradle.kts | 1 + 28 files changed, 2276 insertions(+) create mode 100644 docs/external-signalling/README.md create mode 100644 docs/external-signalling/cloudburst-protocol-vectors.v1.json create mode 100644 docs/external-signalling/fixtures.mjs create mode 100644 docs/external-signalling/nxs-v1.fixtures.json create mode 100644 docs/external-signalling/nxs-v1.schema.json create mode 100644 docs/external-signalling/provenance.json create mode 100644 docs/external-signalling/stateless-admission-v1.fixtures.json create mode 100644 external-signalling/README.md create mode 100644 external-signalling/build.gradle.kts create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/LimitedBodySubscriber.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProtocolExtensions.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderContract.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderCrypto.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderIdentity.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderStateStore.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ServerStatus.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProtocolExtensionsTest.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderInteropTest.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md new file mode 100644 index 00000000..47cad465 --- /dev/null +++ b/docs/external-signalling/README.md @@ -0,0 +1,277 @@ +# NetherNet External Signalling v1 + +Status: experimental open specification. Identifier: `urn:nethernet:external-signalling:v1`. +Licensed under this repository's Apache-2.0 license. The schema, canonical fixtures, +and this document form one versioned contract. Product account systems, credential +issuance, billing, Microsoft login, DNS management, and host-selection policy are +outside the contract. + +NXS connects a running NetherNet host to an operator-selected signalling provider. +The provider can issue an answer using previously published host information. +The host validates the connecting client's first STUN packet without a per-client +request to the provider. A conforming implementation MUST NOT require any push, +poll, shared lookup, offer fetch, or pre-staged client state to admit that client. +Transport and game outcomes are asynchronous observations, never admission prerequisites. + +## Version and discovery + +| Field | v1 value | +| --- | --- | +| Registration/request protocol | `nethernet-external-signalling-v1` | +| Machine request signature | `nxs-es384-v1` | +| Operational profile | `nxs-admission-v1` | +| Discovery path | `/.well-known/nethernet-external-signalling` | +| Stateless capability | `nethernet.stateless-admission.v1` | +| Stateless carrier prefix | `NXS1` | + +The configured origin MUST use HTTPS; HTTP is permitted only for loopback +development. Origins are normalized by lowercasing scheme/host and omitting default +ports. Credentials, path, query and fragment are not permitted in the configured +origin. Discovery is unauthenticated `GET`. `provider` and `controlOrigin` MUST +equal that origin. Each operation URL MUST have the same origin, no userinfo or +fragment. Clients MUST disable redirects for discovery and credential-bearing calls. +Encoded paths and query strings are signed exactly as transmitted. + +Discovery contains `provider`, `controlOrigin`, arrays `protocols`, `signatures`, +`profiles`, `modes`, an `operations` map, `authorization`, `limits`, and optional +`extensions`. Clients reject an unsupported protocol/profile/signature/mode or +required extension before transmitting any credentials. This profile defines +all operation names in the table below; URL paths are discovered, not hard-coded. +`/v1/nxs/` is a recommended mapping, not a routing requirement. + +`authorization` has `header: "Authorization"` and `schemes` entries containing +`scheme` and supported `modes`. Schemes are `anonymous-proof-of-work` and +`bearer-token`; an implementation need only advertise the schemes it accepts. +Anonymous creation permits `new-service`. Bearer authorization permits +`new-service` and/or `attach-instance`. Token scope, reuse policy and issuance +remain provider decisions. Every flow proves possession of the instance key. + +Limits contain `maxBodyBytes` (at most 65536), `clockSkewMs` (at most 60000), +`heartbeatIntervalMs` (1000–30000), `leaseMs`, and `maxControlPage` (at most 100). +`checkInVersion: 1` negotiates response-driven scheduling. A provider MUST advertise +all limits it enforces, reject oversize bodies, and return errors as +`{"code":"lowercase_machine_code"}` with an appropriate HTTP failure status. +Clients bound response bodies before parsing them. On transient transport failure, +429, 502, 503 or 504, the supplied client retries at most three attempts with +bounded exponential delay and jitter; `Retry-After` seconds over ten cause a +retry-later result. Retries never extend a granted lease or challenge expiry. + +## Registration and persistent identity + +Generate a fresh P-384 machine signing key for each logical instance. Persist it +before requesting a challenge; no live replicas may share a key/state directory. +An instance restart reuses its own durable state. Images/templates MUST contain +neither machine identity nor DTLS private keys. Clients lock their state directory, +write private state atomically with owner-only permissions and durable file/directory +sync, and stop advertising healthy readiness after persistence failure. + +The challenge request contains `protocol`, `mode`, `profile`, `publicKeyJwk`, +optional `label`, explicit `authorization: {scheme}`, and optional `placement`. +The bearer credential is sent only as `Authorization: Bearer ` to the +challenge operation. It MUST NOT enter JSON, proofs, persistent state, or logs. +`attach-instance` requires bearer authorization and placement. A bearer token +authorizes the service; a client-provided label never grants authority. + +Placement is `{region,pool,tags?}`. Region and pool are immutable routing labels +matching `[A-Za-z0-9_-]{1,32}` and `[A-Za-z0-9_-]{1,64}` respectively. Tags have at +most 16 keys matching `[A-Za-z0-9_.-]{1,32}` and trimmed string values of 1–64 +characters without control characters. Exact placement is bound into the challenge +and revalidated against token authority at atomic completion. No provider selection +algorithm is implied by these fields. + +The public JWK is EC/P-384 with canonical unpadded base64url `x` and `y` encoding +exactly 48 bytes each, and MUST NOT contain `d`. RFC 7638 thumbprint is SHA-256 of +UTF-8 JSON with members exactly `crv,kty,x,y` in that order. ES384 signatures are +96-byte IEEE-P1363 `r || s`, unpadded base64url; DER and noncanonical base64url fail. + +A challenge contains `protocol`, `signature`, `challengeId`, `nonce`, `audience`, +`thumbprint`, `context`, `contextDigest`, `expiresAt`, `serverTime`, and +`pow: {algorithm:"sha256-leading-zero-bits-v0",difficulty}`. Difficulty is 0–24; +bearer-authorized and recovery flows use zero. An authorization reference is opaque, +never the credential itself. Expiry/server times use integer epoch milliseconds. + +Canonical arrays are UTF-8 JSON without whitespace or Unicode normalization. +Missing context strings are empty strings. `contextDigest` is unpadded base64url +SHA-256 of `[mode,profile,label,authorizationId,serviceId,region,pool,registrationId]`. +When tags are nonempty, append `tagsDigest`, the same digest of sorted `[key,value]` +pairs. The completion proof is: + +```text +[protocol,"complete",audience,challengeId,nonce,thumbprint,contextDigest, + expiresAt,proofNonce,idempotencyKey] +``` + +PoW counts leading zero bits of SHA-256 over those bytes. Completion sends +`protocol,challengeId,proofNonce,idempotencyKey,signature`. The provider MUST check +expiry, binding, signature, difficulty, current authority and single-use completion +atomically with resource creation. Retrying completion MUST NOT replay one-time +key secrets. Recover an interrupted completion through proof of the same key. + +Completion returns `protocol,provider,registrationId,serviceId,instanceId,keyId, +profile,publicAddress,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, +readiness` and optional one-time `ticketKey` and `extensions`. Persist returned IDs +and key material before activation. Strip secret material from application-facing +registration results and diagnostic output. + +## Signed lifecycle and host profile + +Every operational request uses the registered machine key, never the enrollment +bearer token. Required headers are `nxs-instance-id`, `nxs-key-id`, `nxs-timestamp`, +`nxs-signature-version`, `nxs-generation`, `nxs-sequence`, `nxs-signature`, and +`idempotency-key`. Timestamp is epoch milliseconds; generation and sequence are +nonnegative integers. Reserve sequence durably before sending. Authentication binds: + +```text +[protocol,signatureVersion,audience,method,encodedPathAndQuery,timestamp, + instanceId,keyId,idempotencyKey,generation,sequence,base64url(sha256(bodyBytes))] +``` + +The empty body hashes as zero bytes. Providers reject stale generations, reused +sequence numbers, invalid timestamps and signatures. An idempotent retry with the +same intent and unchanged semantic request can return its recorded non-secret result; +it cannot reapply an operation. Accepted activation increments generation and resets +sequence; old processes are fenced. Signed stateful operations belong to the active +profile. Recovery and a signed activation are the explicit profile migration boundary. + +| Operation | Request | Required behavior/result | +| --- | --- | --- | +| `challenges` | POST challenge request, optional bearer | Bound registration challenge | +| `complete` | POST completion proof | Registration or recovered registration; secrets only once | +| `recover` | POST `{registrationId,protocol,profile}` | Challenge for current/pending machine key; preserves assigned IDs | +| `activate` | Signed POST `{profile}` | Incremented `leaseGeneration,leaseDeadline`; resets stale host readiness | +| `readiness` | Signed GET | Routability and reasons, optional extension metadata | +| `host-profile` | Signed POST profile below | Immutable/monotonic `revision`; reject unusable candidates or keys | +| `heartbeat` | Signed POST health/status below | Received time, renewed lease and optional check-in schedule | +| `control` | Signed GET, optional cursor | Bounded `commands`, optional `cursor`, `serverTime` | +| `control/ack` | Signed POST `{cursor}` | Acknowledge only completed terminal lifecycle commands | +| `ticket-keys` | Signed POST `{}` | One-time `{ticketKey:{keyId,secret,...}}` for a new epoch | +| `ticket-keys/ack` | Signed POST `{keyId}` | Confirm keys installed before routing with their epoch | +| `ticket-events` / `events` | Signed POST `{events:[...]}` | Idempotent bounded asynchronous observations | +| `rotate` | Signed POST `{publicKeyJwk,proof}` | New `keyId` after proof by replacement key | +| `retire` | Signed POST `{keyId}` | Retire previous machine signing key | +| `drain` | Signed POST `{}` | Stop new routing/admissions, preserve existing sessions | +| `deregister` | Signed POST `{}` | Revoke instance from routing; terminate its registration lifecycle | + +Rotation proof bytes are `[protocol,"rotate",audience,instanceId,oldKeyId, +newThumbprint,generation,idempotencyKey]`. Persist replacement private key before +rotation, then result before retiring the old key. Recovery can resolve an interrupted +rotation using the key thumbprint returned by the provider. + +`host-profile` contains `candidates`, `dtlsFingerprint`, `credentialKeyId`, +`sctpPort`, `maxMessageSize`, and `statelessAdmission: {capability,incarnation}`. +Incarnation is fresh random 16-byte lowercase hex for each bound native endpoint. +The fingerprint is `sha-256 ` followed by colon-separated uppercase certificate +digest bytes. Candidates contain `foundation,component,protocol,priority,address, +port,type`; only reachable, explicitly advertised UDP candidates may be published. +Bind addresses and advertised addresses are separate concepts. Never advertise +wildcard `0.0.0.0`/`::`. NAT and relay reachability must be established by the +deployment/provider; passing a registration test does not prove reachability. + +The host provisions its DTLS certificate/key before profile publication and keeps +the private key local. All peers represented by a published profile use that +certificate. Machine signing keys, DTLS identities and admission keys are distinct. +An endpoint can use a newly generated identity on a later incarnation after publishing +the new fingerprint; a shared permanent fleet certificate is neither required nor advised. + +Admission keys have `keyId` (four uppercase alphanumeric characters), secret +(32–256 UTF-8 characters), optional `notBefore` and `retireAfter` epoch milliseconds. +Install at most eight epochs atomically, acknowledge them, then publish a profile +using an active installed epoch. Hosts reject before activation/after retirement +and erase retired material. They do not extend token expiry when rotating keys. + +Heartbeat contains `healthy,capacity,load,protocolVersion,build,hostProfileRevision, +clockUnixMillis`, optional `region,serverStatus,checkInVersion`. Capacity and load +are routing observations, independent of advertised player/max-player counts. +Status contains `name,protocol,version,level,players,maxPlayers,gameType`. +Publishing failures do not refresh old status timestamps. Readiness requires current +identity/generation, a live lease, usable fresh host profile and installed key acknowledgment. +Optional product extensions cannot gate core readiness. + +When check-in v1 is negotiated, the heartbeat response has ISO8601 `receivedAt` and +`checkIn: {version:1,afterMillis,nextCheckInAt,leaseExpiresAt,minUpdateIntervalMillis, +controlPollAfterMillis}`. Absolute times are epoch milliseconds. `nextCheckInAt` +precedes lease expiry. Hosts schedule against monotonic clocks and count network +time against the interval; changed activity/status may prompt an earlier rate-limited +heartbeat. Restarts publish immediately and reset old schedules. Provider outage +expires routing leases but does not itself tear down established sessions. + +Lifecycle controls supported by this profile are `noop,drain,suspend,revoke`. +Unknown controls are not silently acknowledged; process later known lifecycle +commands even while an earlier unknown command prevents advancing the page cursor. +`join-admission` is explicitly not a v1 control: admissions never wait for it. +Event batches have at most 100 entries and retain only redacted correlation, +stage/type, timestamp and bounded reason fields. Never send SDP, private keys, +player identity or game payloads as telemetry. Transport establishment is distinct +from `ticket.game_joined` (game play-ready) and `ticket.game_rejected`. + +## Stateless admission carrier + +The client's first STUN USERNAME is `:`. +`answerUfrag = "NXS1" + keyId + unpaddedBase64(nonce || ciphertext || tag)`. +Use standard base64 alphabet (ICE permits `+` and `/`), not base64url. Total ufrag +length is at most 256 characters. Noncanonical encoding, trailing padding, wrong +prefix, unknown epochs and oversized inputs are rejected before allocation. + +AES-256-GCM uses random 12-byte nonce and 16-byte tag. Its key is +`HMAC-SHA256(secret, "nxs-stateless-aead-v1" || NUL || audience)`. +Audience is `nxs-stateless-host-v1/`. AAD is +`"nxs-stateless-admission-v1" || NUL || ("NXS1"+keyId) || NUL || audience || NUL || clientUfrag`. + +| Plaintext offset | Size | Meaning, unsigned big-endian where numeric | +| --- | --- | --- | +| 0 | 4 | Expiry in epoch seconds, exactly representable in milliseconds | +| 4 | 32 | SHA-256 client certificate fingerprint | +| 36 | 2 | Client SCTP port, 1–65535 | +| 38 | 4 | Client maximum message size, 1–262144 | +| 42 | 16 | Opaque caller-context hash, no account-specific interpretation | +| 58 | 8 | NetherNet network ID, unsigned 64-bit | +| 66 | 1 | Client ICE password length, 22–91 | +| 67 | N | Client ICE password in ICE base64 alphabet | + +The host's local ICE password is unpadded standard base64 of the first 24 bytes of +`HMAC-SHA256(secret, "nxs-stateless-ice-v1" || NUL || audience || NUL || answerUfrag)`. +The ticket correlation ID is the first 16 bytes of SHA-256 of the ASCII answer ufrag, +encoded lowercase hex. Maximum admitted token TTL is 120 seconds; the supplied +implementation uses 60 seconds. Hosts validate expiry, bounds, GCM, client binding +and raw STUN MESSAGE-INTEGRITY before tuple promotion or native peer creation. +The resulting DTLS handshake MUST verify the client fingerprint from the token. + +Only identical-token retransmissions from the same UDP tuple may reuse a reservation. +Token replay from another tuple and conflicting admission on an occupied tuple fail +closed. Bound sessions, pending handshakes, replay cache, callbacks and datagram queues. +Peer creation happens outside the mux callback lock. Deliver/replay the authenticated +first datagram after native registration so the first STUN request receives a response. +Do not release admission capacity until native teardown actually completes. + +## Optional extensions and compatibility + +`extensions` is an object with at most 16 reverse-DNS namespace keys and 16384 bytes +of encoded UTF-8 JSON. Each value is `{version:positiveInteger,critical:boolean,data:object}`. +Namespace keys are lowercase domain-style labels, at most 128 characters. Unknown +optional extensions are passed through/ignored, never automatically executed. +Unsupported critical extensions fail before credentials or activation. Core semantics +cannot be redefined by an optional extension. Bodies and operation paths remain +authenticated by the surrounding TLS/signature boundary. + +An extension may advertise `data.operations` URLs. An application may explicitly +request an operation only after validating its namespace/version and meaning. +The generic transport still enforces same-origin URLs and signs their exact path. +Account claim actions are a product extension; NXS assigns them no core meaning. + +Previously persisted IDs/keys may be recovered into this profile through explicit +`recover {registrationId,protocol,profile}` and signed `activate {profile}`. Verify +the same key and origin, preserve IDs and DTLS files, then atomically record the new +profile/generation. Legacy protocol bytes MUST NOT be relabelled as v1. Providers +may retain separately negotiated legacy adapters; the neutral Java module implements +only NXS. Rollback requires explicit signed profile activation and recovery with the +previous client; never bypass machine authentication or copy a live state directory. + +## Conformance + +`node docs/external-signalling/fixtures.mjs` verifies independent JavaScript signing, +encryption and fixture hashes. `--write` regenerates public test signatures. +The JVM suites consume these exact files via Gradle resources. The independent +provider implements registration, signed lifecycle, status, keys, outcomes, drain +and recovery without a product account system. Native tests separately exercise +raw STUN admission and DTLS transport. Stock-client admission, gameplay and two-host +routing must be reported separately from fixture/native conformance. diff --git a/docs/external-signalling/cloudburst-protocol-vectors.v1.json b/docs/external-signalling/cloudburst-protocol-vectors.v1.json new file mode 100644 index 00000000..29c10ab4 --- /dev/null +++ b/docs/external-signalling/cloudburst-protocol-vectors.v1.json @@ -0,0 +1,36 @@ +{ + "stun": { + "rfc5769": { + "messageIntegrityHex": "9aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a2", + "name": "rfc5769-sample-request", + "packetHex": "000100582112a442b7e7a701bc34d686fa87dfae802200105354554e207465737420636c69656e74002400046e0001ff80290008932ff9b151263b36000600096576746a3a68367659202020000800149aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a280280004e57a3bcf", + "passwordUtf8": "VOkJxbRl1RmTxUk/WvJxBt", + "transactionIdHex": "b7e7a701bc34d686fa87dfae", + "username": "evtj:h6vY" + } + }, + "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 + } + ] +} diff --git a/docs/external-signalling/fixtures.mjs b/docs/external-signalling/fixtures.mjs new file mode 100644 index 00000000..7c6b7652 --- /dev/null +++ b/docs/external-signalling/fixtures.mjs @@ -0,0 +1,53 @@ +// Public fixture keys only. Node's independent crypto implementation verifies JVM conformance. +import { readFileSync, writeFileSync } from 'node:fs'; +import { createHash, createHmac, createCipheriv, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; + +const protocol = 'nethernet-external-signalling-v1', signature = 'nxs-es384-v1'; +const path = name => fileURLToPath(new URL(name, import.meta.url)); +const read = name => JSON.parse(readFileSync(path(name), 'utf8')); +const write = (name, value) => writeFileSync(path(name), JSON.stringify(value, null, 2) + '\n'); +const digest = value => createHash('sha256').update(value).digest(); +const b64 = value => value.toString('base64url'); +const hmac = (key, data) => createHmac('sha256', key).update(data).digest(); +const update = process.argv.includes('--write'); + +const f = read('nxs-v1.fixtures.json'), c = f.challenge; +f.protocol = c.protocol = protocol; c.signature = signature; c.context.profile = 'nxs-admission-v1'; +c.thumbprint = b64(digest(JSON.stringify(Object.fromEntries(['crv', 'kty', 'x', 'y'].map(k => [k, f.publicKeyJwk[k]]))))); +c.contextDigest = b64(digest(JSON.stringify(['mode','profile','label','authorizationId','serviceId','region','pool','registrationId','tagsDigest'].filter(k => k in c.context).map(k => c.context[k])))); +const proof = JSON.stringify([protocol,'complete',c.audience,c.challengeId,c.nonce,c.thumbprint,c.contextDigest,c.expiresAt,f.proofNonce,f.idempotencyKey]); +const i = f.request.input; +const request = JSON.stringify([protocol,signature,i.audience,i.method,i.path,i.timestamp,i.instanceId,i.keyId,i.idempotencyKey,i.generation,i.sequence,b64(digest(i.body))]); +const pub = createPublicKey({ key: f.publicKeyJwk, format: 'jwk' }); +for (const [name, payload] of [['proof',proof],['request',request]]) { + if (update) { + f[name].payload = payload; + f[name].signature = b64(sign('sha384', Buffer.from(payload), { key: createPrivateKey({key:f.privateKeyJwk,format:'jwk'}), dsaEncoding:'ieee-p1363' })); + } + assert.equal(f[name].payload, payload); + assert(verify('sha384', Buffer.from(payload), {key:pub,dsaEncoding:'ieee-p1363'}, Buffer.from(f[name].signature,'base64url'))); +} +if (update) write('nxs-v1.fixtures.json', f); + +const v = read('stateless-admission-v1.fixtures.json'), claims = v.claims, context = v.context; +const plain = Buffer.alloc(67 + claims.clientIcePwd.length); +plain.writeUInt32BE(claims.expiresAt / 1000); Buffer.from(claims.clientFingerprintHex,'hex').copy(plain,4); +plain.writeUInt16BE(claims.clientSctpPort,36); plain.writeUInt32BE(claims.clientMaxMessageSize,38); +Buffer.from(claims.callerContextHashHex,'hex').copy(plain,42); plain.writeBigUInt64BE(BigInt(claims.networkId),58); +plain[66] = claims.clientIcePwd.length; plain.write(claims.clientIcePwd,67,'ascii'); +const nonce = Buffer.from(v.nonceHex,'hex'), header = 'NXS1' + context.keyId; +const key = hmac(context.secret, `nxs-stateless-aead-v1\0${context.audience}`); +const cipher = createCipheriv('aes-256-gcm', key, nonce); +cipher.setAAD(Buffer.from(`nxs-stateless-admission-v1\0${header}\0${context.audience}\0${v.clientIceUfrag}`)); +const encrypted = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]); plain.fill(0); +const localUfrag = header + Buffer.concat([nonce,encrypted]).toString('base64').replaceAll('=',''); +const icePwd = hmac(context.secret,`nxs-stateless-ice-v1\0${context.audience}\0${localUfrag}`).subarray(0,24).toString('base64'); +const expected = {localUfrag,icePwd,ufragLength:localUfrag.length}; +if (update) { v.expected = expected; write('stateless-admission-v1.fixtures.json',v); } +assert.deepEqual(v.expected,expected); +const provenance = {specification:'urn:nethernet:external-signalling:v1', files:Object.fromEntries(['stateless-admission-v1.fixtures.json','cloudburst-protocol-vectors.v1.json'].map(name => [name,digest(readFileSync(path(name))).toString('hex')]))}; +if (update) write('provenance.json',provenance); +assert.deepEqual(read('provenance.json'),provenance); +console.log('NXS canonical signing, stateless encryption, and fixture hashes verified.'); diff --git a/docs/external-signalling/nxs-v1.fixtures.json b/docs/external-signalling/nxs-v1.fixtures.json new file mode 100644 index 00000000..f46fb48c --- /dev/null +++ b/docs/external-signalling/nxs-v1.fixtures.json @@ -0,0 +1,77 @@ +{ + "warning": "LOCAL CONFORMANCE KEY ONLY. Never deploy this key.", + "protocol": "nethernet-external-signalling-v1", + "publicKeyJwk": { + "key_ops": [ + "verify" + ], + "ext": true, + "kty": "EC", + "x": "7onqrvcQqP_J5uJk-j3M7KhZqAB3OwxxFkg2XodPmV9KmC7ALcVeK0CQ-pJqX88F", + "y": "7mSiEjHsP4o6StG48-3Vvb2BfG8WTuYxmfrYmaS_CZDVHoWibgPWvmkGUbVQG9xq", + "crv": "P-384" + }, + "privateKeyJwk": { + "key_ops": [ + "sign" + ], + "ext": true, + "kty": "EC", + "x": "7onqrvcQqP_J5uJk-j3M7KhZqAB3OwxxFkg2XodPmV9KmC7ALcVeK0CQ-pJqX88F", + "y": "7mSiEjHsP4o6StG48-3Vvb2BfG8WTuYxmfrYmaS_CZDVHoWibgPWvmkGUbVQG9xq", + "crv": "P-384", + "d": "yjEPqO0LygBnf919VCmdziREaPgeFeCT4M4QC_lW1Nvkda9iOoZHuRlQAUUGYua4" + }, + "challenge": { + "protocol": "nethernet-external-signalling-v1", + "signature": "nxs-es384-v1", + "challengeId": "challenge_fixture", + "nonce": "8WH8OdS12DO6ju0_", + "audience": "https://provider.example", + "thumbprint": "FV0R5dHP--qigTPgspmBk6zImSz6BhG0368xJW7Gxlo", + "context": { + "mode": "attach-instance", + "profile": "nxs-admission-v1", + "label": "EU café 🦊\n", + "authorizationId": "auth_fixture", + "serviceId": "service_neutral", + "region": "EU", + "pool": "proxy", + "registrationId": "", + "tagsDigest": "CEHUNj2V3qUPpjRczgqi9TCVca5-uQ8g29G8YT4qtng" + }, + "contextDigest": "d7Aq1Xak842s6La_FqpAOA_NolAtu7gG3jvS75n6OpM", + "expiresAt": 1788484800000, + "serverTime": 1788484200000, + "pow": { + "algorithm": "sha256-leading-zero-bits-v0", + "difficulty": 0 + }, + "authorization": { + "scheme": "bearer-token", + "reference": "auth_fixture" + } + }, + "proofNonce": "0", + "idempotencyKey": "intent_fixture_0001", + "proof": { + "payload": "[\"nethernet-external-signalling-v1\",\"complete\",\"https://provider.example\",\"challenge_fixture\",\"8WH8OdS12DO6ju0_\",\"FV0R5dHP--qigTPgspmBk6zImSz6BhG0368xJW7Gxlo\",\"d7Aq1Xak842s6La_FqpAOA_NolAtu7gG3jvS75n6OpM\",1788484800000,\"0\",\"intent_fixture_0001\"]", + "signature": "HwAyBojOh_FOHzCt_dABnf3cEgypZYERBg3TJVDySRZB3dPKTcOTCwo8sFmy83c3EyUYx2ynMMbHUlh7NqHLCQMRSsntyeC7yn-wxQ4dC6mx0Lnyq4Telp0H7CfhRNS8" + }, + "request": { + "input": { + "audience": "https://provider.example", + "method": "POST", + "path": "/renew?region=EU&label=caf%C3%A9", + "timestamp": 1788484200123, + "instanceId": "machine_neutral", + "keyId": "key_fixture", + "idempotencyKey": "intent_fixture_0001", + "generation": 2, + "sequence": 17, + "body": "{\"name\":\"café 🦊\",\"players\":0}\n" + }, + "payload": "[\"nethernet-external-signalling-v1\",\"nxs-es384-v1\",\"https://provider.example\",\"POST\",\"/renew?region=EU&label=caf%C3%A9\",1788484200123,\"machine_neutral\",\"key_fixture\",\"intent_fixture_0001\",2,17,\"hW1_XbRZsu7XCnVbFpxNepqnGsOafEGkN_VFWwKb4jQ\"]", + "signature": "SMg1I5sJM8nQMSLcGMx8ajKG2HuF2b0qXolYIyPNtuC4jRT89G_MKemggBUGNw1_CtwBhEJiZfdQsM5OTkIDZTFukS2pQdFmLYw8x8GkWeouETOl7CbRnVCAA3SpV3Un" + } +} diff --git a/docs/external-signalling/nxs-v1.schema.json b/docs/external-signalling/nxs-v1.schema.json new file mode 100644 index 00000000..b003b4e0 --- /dev/null +++ b/docs/external-signalling/nxs-v1.schema.json @@ -0,0 +1,425 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nethernet:external-signalling:v1", + "title": "NetherNet External Signalling v1", + "type": "object", + "required": [ + "protocol", + "mode", + "profile", + "publicKeyJwk" + ], + "properties": { + "protocol": { + "const": "nethernet-external-signalling-v1" + }, + "mode": { + "enum": [ + "new-service", + "attach-instance" + ] + }, + "profile": { + "type": "string" + }, + "publicKeyJwk": { + "type": "object", + "required": [ + "crv", + "kty", + "x", + "y" + ], + "not": { + "required": [ + "d" + ] + }, + "properties": { + "crv": { + "const": "P-384" + }, + "kty": { + "const": "EC" + }, + "x": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{64}$" + }, + "y": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{64}$" + } + } + }, + "label": { + "type": "string", + "maxLength": 128 + }, + "authorization": { + "type": "object", + "additionalProperties": false, + "required": [ + "scheme" + ], + "properties": { + "scheme": { + "enum": [ + "anonymous-proof-of-work", + "bearer-token" + ] + } + } + }, + "placement": { + "type": "object", + "additionalProperties": false, + "required": [ + "region", + "pool" + ], + "properties": { + "region": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,32}$" + }, + "pool": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,64}$" + }, + "tags": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9_.-]{1,32}$" + }, + "additionalProperties": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxProperties": 16 + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "const": "attach-instance" + } + } + }, + "then": { + "required": [ + "placement", + "authorization" + ], + "properties": { + "authorization": { + "properties": { + "scheme": { + "const": "bearer-token" + } + } + } + } + } + } + ], + "$defs": { + "discovery": { + "type": "object", + "required": [ + "provider", + "controlOrigin", + "protocols", + "signatures", + "modes", + "profiles", + "operations", + "authorization", + "limits" + ], + "properties": { + "authorization": { + "type": "object", + "required": [ + "header", + "schemes" + ], + "properties": { + "header": { + "const": "Authorization" + }, + "schemes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "scheme", + "modes" + ], + "properties": { + "scheme": { + "enum": [ + "anonymous-proof-of-work", + "bearer-token" + ] + }, + "modes": { + "type": "array", + "items": { + "enum": [ + "new-service", + "attach-instance" + ] + } + } + } + } + } + } + }, + "extensions": { + "$ref": "#/$defs/extensions" + } + } + }, + "challenge": { + "type": "object", + "required": [ + "protocol", + "signature", + "challengeId", + "nonce", + "audience", + "thumbprint", + "context", + "contextDigest", + "expiresAt", + "serverTime", + "pow" + ], + "properties": { + "protocol": { + "const": "nethernet-external-signalling-v1" + }, + "signature": { + "const": "nxs-es384-v1" + }, + "expiresAt": { + "type": "integer" + }, + "serverTime": { + "type": "integer" + }, + "authorization": { + "type": "object", + "required": [ + "scheme", + "reference" + ], + "properties": { + "scheme": { + "enum": [ + "anonymous-proof-of-work", + "bearer-token" + ] + }, + "reference": { + "type": "string" + } + } + } + } + }, + "completion": { + "type": "object", + "required": [ + "protocol", + "challengeId", + "proofNonce", + "signature", + "idempotencyKey" + ], + "properties": { + "protocol": { + "const": "nethernet-external-signalling-v1" + } + } + }, + "registration": { + "type": "object", + "required": [ + "protocol", + "provider", + "registrationId", + "serviceId", + "instanceId", + "keyId", + "profile", + "publicAddress", + "placement", + "heartbeatIntervalMs", + "leaseGeneration", + "leaseDeadline", + "readiness" + ], + "properties": { + "protocol": { + "const": "nethernet-external-signalling-v1" + }, + "extensions": { + "$ref": "#/$defs/extensions" + } + } + }, + "readiness": { + "type": "object", + "required": [ + "serverTime", + "serviceId", + "instanceId", + "serviceAvailable", + "instanceRoutable", + "readiness", + "leaseGeneration", + "leaseDeadline", + "placement" + ] + }, + "activation": { + "type": "object", + "required": [ + "profile" + ], + "properties": { + "profile": { + "const": "nxs-admission-v1" + } + } + }, + "rotation": { + "type": "object", + "required": [ + "publicKeyJwk", + "proof" + ] + }, + "ticketKey": { + "type": "object", + "required": [ + "keyId", + "secret" + ] + }, + "pendingAction": { + "type": "object", + "required": [ + "url", + "expiresAt", + "text" + ] + }, + "extensions": { + "type": "object", + "maxProperties": 16, + "propertyNames": { + "type": "string", + "maxLength": 128, + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*){1,7}$" + }, + "additionalProperties": { + "type": "object", + "required": [ + "version", + "critical", + "data" + ], + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 999999999 + }, + "critical": { + "type": "boolean" + }, + "data": { + "type": "object" + } + } + }, + "x-max-utf8-bytes": 16384 + }, + "recovery": { + "type": "object", + "required": [ + "registrationId", + "protocol", + "profile" + ], + "properties": { + "registrationId": { + "type": "string", + "minLength": 1 + }, + "protocol": { + "const": "nethernet-external-signalling-v1" + }, + "profile": { + "const": "nxs-admission-v1" + } + } + } + }, + "x-context-order": [ + "mode", + "profile", + "label", + "authorizationId", + "serviceId", + "region", + "pool", + "registrationId" + ], + "x-optional-context-order": [ + "tagsDigest" + ], + "x-signature-format": "ES384 P1363 r||s 96 bytes unpadded base64url", + "x-canonicalization": "UTF-8 JSON arrays, no whitespace, no Unicode normalization, epoch milliseconds as integers, absent context strings are empty", + "description": "Canonical registration and lifecycle metadata for the nxs-admission-v1 profile.", + "x-profile": "nxs-admission-v1", + "x-discovery-path": "/.well-known/nethernet-external-signalling", + "x-headers": [ + "nxs-instance-id", + "nxs-key-id", + "nxs-timestamp", + "nxs-signature-version", + "nxs-generation", + "nxs-sequence", + "nxs-signature", + "idempotency-key" + ], + "x-operations": [ + "challenges", + "complete", + "recover", + "activate", + "drain", + "deregister", + "rotate", + "retire", + "ticket-keys", + "ticket-keys/ack", + "readiness", + "heartbeat", + "host-profile", + "control", + "control/ack", + "ticket-events", + "events" + ] +} diff --git a/docs/external-signalling/provenance.json b/docs/external-signalling/provenance.json new file mode 100644 index 00000000..01260175 --- /dev/null +++ b/docs/external-signalling/provenance.json @@ -0,0 +1,7 @@ +{ + "specification": "urn:nethernet:external-signalling:v1", + "files": { + "stateless-admission-v1.fixtures.json": "f84db8b78018cf3f5b910625414701f7c41bca1a5fa8056ff22c0d17df99301a", + "cloudburst-protocol-vectors.v1.json": "2bd4c3305a0911f2d4a1166f1c6760ed6979c5af551cbd9660e9badd1f7fb993" + } +} diff --git a/docs/external-signalling/stateless-admission-v1.fixtures.json b/docs/external-signalling/stateless-admission-v1.fixtures.json new file mode 100644 index 00000000..870fe880 --- /dev/null +++ b/docs/external-signalling/stateless-admission-v1.fixtures.json @@ -0,0 +1,47 @@ +{ + "kind": "nxs-stateless-admission-vectors", + "version": 1, + "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": "nxs-stateless-host-v1/0123456789abcdef0123456789abcdef" + }, + "claims": { + "expiresAt": 1788484830000, + "clientFingerprintHex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "clientIcePwd": "clientPassword01234567890", + "clientSctpPort": 5000, + "clientMaxMessageSize": 262144, + "callerContextHashHex": "abcdef0123456789abcdef0123456789", + "networkId": "18446744073709551615" + }, + "clientIceUfrag": "clientFixtureUf", + "nonceHex": "000102030405060708090a0b", + "now": 1788484800000, + "maxTtlMs": 60000, + "expected": { + "localUfrag": "NXS1K001AAECAwQFBgcICQoLVMAD+DMWY8K/TmtjsFluCDDdBa2hZL+TXNNyTD2z2mk9pDlsv7HZSWUQcYeCwa1spGxF25/EdEU8Qa7XN2qhYd9Ha7UENSp5AIMknX1jCS8Mw4joQSTW9M1qq5G/t+T8agkcuu9qluHVT/Am", + "icePwd": "hLPAsKmOEIGguJ3cJoj7OQqkkbLN6RWH", + "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" + ] +} diff --git a/external-signalling/README.md b/external-signalling/README.md new file mode 100644 index 00000000..558e037e --- /dev/null +++ b/external-signalling/README.md @@ -0,0 +1,28 @@ +# NetherNet External Signalling + +Java 21 client for the open [NXS v1 specification](../docs/external-signalling/README.md). +Published coordinates follow Cloudburst conventions: `org.cloudburstmc.netty:netty-external-signalling`. +The independent provider and fixtures require no product account or proprietary control plane. + +```sh +./gradlew --max-workers=2 :external-signalling:test :transport-nethernet:test +./gradlew --max-workers=2 :external-signalling:providerStub +node docs/external-signalling/fixtures.mjs +``` + +`ProviderClient` supports new-service registration by advertised anonymous proof of work +or bearer token, token-authorized instance attachment, durable recovery, generation-fenced +activation, status/profile publication, scheduled heartbeats, key rotation, drain, and +asynchronous outcomes. Tokens are enrollment-only and excluded from durable state/logs. +One instance owns one private state directory; restarts preserve that directory. + +`ProtocolExtensions` carries bounded optional metadata. Applications explicitly interpret +known namespaces and invoke only their advertised same-origin operations. The core never +performs product account/claim actions or stages individual joins from provider control. + +`NativeProviderTransport` publishes the actual bound UDP endpoint and certificate +fingerprint before accepting clients. Its admission validator verifies an NXS1 token and +raw STUN integrity before creating a native peer. The optional native test task is +`:external-signalling:nativeAdmissionTest`; native packaging must match the immutable JNI +revision in `native-dependencies.properties`. Never combine new headers with older native +binaries. Native tests prove transport conformance, not stock-client gameplay. diff --git a/external-signalling/build.gradle.kts b/external-signalling/build.gradle.kts new file mode 100644 index 00000000..5ecb454e --- /dev/null +++ b/external-signalling/build.gradle.kts @@ -0,0 +1,61 @@ +description = "NetherNet External Signalling client and stateless admission" +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("${rootProject.property("nativeJavaGroup")}:libdatachannel-java:${rootProject.property("nativeJavaVersion")}:x86_64") +} +tasks.jar { manifest.attributes["Automatic-Module-Name"] = "org.cloudburstmc.netty.signalling" } + + +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") { + 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 } +} + +tasks.register("providerStub") { + dependsOn(tasks.testClasses) + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("org.cloudburstmc.netty.signalling.IndependentProviderStub") +} + +tasks.register("providerBench") { + dependsOn(tasks.testClasses) + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("org.cloudburstmc.netty.signalling.ProviderBench") + listOf("providerOrigin", "providerState", "providerMode", "providerGrantFile", "providerToken", "providerRegistrationMode", "providerRegion", "providerPool", "providerTags", "providerHoldSeconds").forEach { name -> + providers.gradleProperty(name).orNull?.let { systemProperty(name, it) } + } +} + +// Avoid shell-specific dependency-cache paths in the cross-repository local workflow. +tasks.register("providerBenchClasspath") { + dependsOn(tasks.testClasses) + dependsOn(sourceSets.test.get().runtimeClasspath) + doLast { println(sourceSets.test.get().runtimeClasspath.asPath) } +} + +tasks.processResources { from(rootProject.file("docs/external-signalling/nxs-v1.schema.json")) } + +tasks.processTestResources { + from(rootProject.file("docs/external-signalling/nxs-v1.fixtures.json")) + from(rootProject.file("docs/external-signalling")) { + include("stateless-admission-v1.fixtures.json", "cloudburst-protocol-vectors.v1.json", "provenance.json") + into("nxs") + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java new file mode 100644 index 00000000..560f2b56 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java @@ -0,0 +1,25 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.io.IOException; + +/** Validates the scheduling contract; policy and idle thresholds belong to the provider. */ +record CheckInSchedule(long afterMillis, long controlPollAfterMillis, long minUpdateIntervalMillis) { + static CheckInSchedule parse(JsonObject response) throws IOException { + try { + JsonObject s = response.getAsJsonObject("checkIn"); + if (number(s, "version") != 1) throw new IllegalArgumentException(); + long after = number(s, "afterMillis"), control = number(s, "controlPollAfterMillis"), minimum = number(s, "minUpdateIntervalMillis"); + long next = number(s, "nextCheckInAt"), expires = number(s, "leaseExpiresAt"); + long received = java.time.Instant.parse(response.get("receivedAt").getAsString()).toEpochMilli(); + if (after < 1000 || after > 86400000 || control < 1000 || control > after || minimum < 1000 || minimum > after + || next - received != after || expires <= next || expires - next > 300000) throw new IllegalArgumentException(); + return new CheckInSchedule(after, control, minimum); + } catch (RuntimeException invalid) { throw new IOException("Invalid provider check-in schedule", invalid); } + } + private static long number(JsonObject object, String field) { + var value = object.getAsJsonPrimitive(field); + if (!value.isNumber()) throw new IllegalArgumentException(); + return value.getAsBigDecimal().longValueExact(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/LimitedBodySubscriber.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/LimitedBodySubscriber.java new file mode 100644 index 00000000..9d50b820 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/LimitedBodySubscriber.java @@ -0,0 +1,27 @@ +package org.cloudburstmc.netty.signalling; + +import java.io.IOException; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; + +/** Enforces the cap before allocating a response body; completion includes all bytes. */ +final class LimitedBodySubscriber implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber delegate = HttpResponse.BodySubscribers.ofByteArray(); + private final int limit; + private Flow.Subscription upstream; + private int count; + private boolean failed; + LimitedBodySubscriber(int limit) { this.limit = limit; } + public CompletionStage getBody() { return delegate.getBody(); } + public void onSubscribe(Flow.Subscription subscription) { upstream = subscription; delegate.onSubscribe(subscription); } + public void onNext(List buffers) { + if (failed) return; + for (ByteBuffer b : buffers) { if (b.remaining() > limit - count) { failed = true; upstream.cancel(); delegate.onError(new IOException("Provider response exceeds limit")); return; } count += b.remaining(); } + delegate.onNext(buffers); + } + public void onError(Throwable error) { if (!failed) { failed = true; delegate.onError(error); } } + public void onComplete() { if (!failed) delegate.onComplete(); } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProtocolExtensions.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProtocolExtensions.java new file mode 100644 index 00000000..b10fd16c --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProtocolExtensions.java @@ -0,0 +1,38 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; + +/** Bounded, authenticated metadata whose optional contents are interpreted by applications. */ +public final class ProtocolExtensions { + public static final int MAX_BYTES = 16_384; + public static final int MAX_ENTRIES = 16; + private ProtocolExtensions() {} + + public static void validate(JsonObject document) { + if (!document.has("extensions")) return; + JsonElement value = document.get("extensions"); + if (!value.isJsonObject()) throw new IllegalArgumentException("Invalid extensions object"); + JsonObject extensions = value.getAsJsonObject(); + if (extensions.size() > MAX_ENTRIES || extensions.toString().getBytes(StandardCharsets.UTF_8).length > MAX_BYTES) + throw new IllegalArgumentException("Extensions exceed limits"); + for (var entry : extensions.entrySet()) { + if (!entry.getKey().matches("[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*){1,7}") || entry.getKey().length() > 128 || !entry.getValue().isJsonObject()) + throw new IllegalArgumentException("Invalid extension namespace"); + JsonObject extension = entry.getValue().getAsJsonObject(); + if (!extension.has("version") || !extension.get("version").isJsonPrimitive() + || !extension.getAsJsonPrimitive("version").isNumber() || !extension.get("version").getAsString().matches("[1-9][0-9]{0,8}") + || !extension.has("critical") || !extension.get("critical").isJsonPrimitive() + || !extension.getAsJsonPrimitive("critical").isBoolean() || !extension.has("data") || !extension.get("data").isJsonObject()) + throw new IllegalArgumentException("Invalid extension envelope"); + // This core implements no mandatory extension. Applications cannot silently weaken core checks. + if (extension.get("critical").getAsBoolean()) throw new IllegalArgumentException("Unsupported required extension: " + entry.getKey()); + } + } + + public static JsonObject copy(JsonObject document) { + validate(document); + return document.has("extensions") ? document.getAsJsonObject("extensions").deepCopy() : new JsonObject(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java new file mode 100644 index 00000000..9fdbe943 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java @@ -0,0 +1,422 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import java.io.*; +import java.net.URI; +import java.net.http.*; +import java.security.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.*; + +/** One asynchronous, serialized control lifecycle per backend, never one poller per player. */ +public final class ProviderClient implements AutoCloseable { + public static final String NEW_SERVICE = "new-service", ATTACH_INSTANCE = "attach-instance"; + public static final String ANONYMOUS_PROOF_OF_WORK = "anonymous-proof-of-work", BEARER_TOKEN = "bearer-token"; + public record Configuration(URI provider, String profile, String label, String registrationMode, String authorizationScheme, + String authorizationToken, String region, String pool, Map tags) { + public Configuration { + Objects.requireNonNull(provider); Objects.requireNonNull(profile); Objects.requireNonNull(registrationMode); Objects.requireNonNull(authorizationScheme); + if (!"nxs-admission-v1".equals(profile)) throw new IllegalArgumentException("Unsupported operational profile"); + ProviderCrypto.origin(provider); + if (region != null && (!region.matches("[A-Za-z0-9_-]{1,32}") || pool == null || !pool.matches("[A-Za-z0-9_-]{1,64}"))) throw new IllegalArgumentException("Invalid placement"); + tags = tags == null ? Map.of() : Collections.unmodifiableMap(new TreeMap<>(tags)); + if (!Set.of(NEW_SERVICE, ATTACH_INSTANCE).contains(registrationMode)) throw new IllegalArgumentException("Invalid provider registration mode"); + if (!Set.of(ANONYMOUS_PROOF_OF_WORK, BEARER_TOKEN).contains(authorizationScheme)) throw new IllegalArgumentException("Invalid provider authorization scheme"); + if ((BEARER_TOKEN.equals(authorizationScheme)) != (authorizationToken != null && !authorizationToken.isBlank())) throw new IllegalArgumentException("Bearer authorization requires exactly one token"); + if (ANONYMOUS_PROOF_OF_WORK.equals(authorizationScheme) && !NEW_SERVICE.equals(registrationMode)) throw new IllegalArgumentException("Anonymous proof of work can only create a service"); + if (ATTACH_INSTANCE.equals(registrationMode) && (region == null || region.isBlank() || pool == null || pool.isBlank())) throw new IllegalArgumentException("Attached instances require region and pool"); + if ((region == null) != (pool == null) || (!tags.isEmpty() && region == null)) throw new IllegalArgumentException("Provider placement requires region and pool together"); + if (tags.size() > 16 || tags.entrySet().stream().anyMatch(e -> !e.getKey().matches("[A-Za-z0-9_.-]{1,32}") || e.getValue() == null || !e.getValue().equals(e.getValue().trim()) || e.getValue().isEmpty() || e.getValue().length() > 64 || e.getValue().codePoints().anyMatch(c -> c < 32 || c == 127))) throw new IllegalArgumentException("Invalid provider placement tags"); + } + public Configuration(URI provider, String profile, String label) { + this(provider, profile, label, NEW_SERVICE, ANONYMOUS_PROOF_OF_WORK, null, null, null, Map.of()); + } + @Override public String toString() { return "Configuration[provider=" + provider + ", profile=" + profile + ", registrationMode=" + registrationMode + ", authorizationScheme=" + authorizationScheme + "]"; } + } + public record Health(boolean healthy, int capacity, double load, String protocolVersion, String build) { + public Health { if (capacity < 0 || capacity > 1000000 || !Double.isFinite(load) || load < 0 || load > 1 || protocolVersion == null) throw new IllegalArgumentException("Invalid health"); } + } + public static final class ProviderException extends IOException { + private final int status; + ProviderException(int status, String code) { super("Provider request failed: " + status + " " + code); this.status = status; } + public int status() { return status; } + } + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().create(); + private final Configuration config; + private final ProviderStateStore store; + private final ProviderTransport transport; + private final Supplier statusSupplier; + private final Supplier healthSupplier; + private final Consumer diagnostics; + private final String origin; + private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "nethernet-provider"); t.setDaemon(true); return t; }); + private final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).connectTimeout(Duration.ofSeconds(10)).build(); + private final AtomicReference explicitStatus = new AtomicReference<>(); + private final AtomicBoolean refreshQueued = new AtomicBoolean(); + private JsonObject state, discovery; + private JsonObject registrationExtensions = new JsonObject(); + private PrivateKey privateKey; + private String profileRevision; + private JsonObject lastProfile; + private long intervalMs = 10000, nextHeartbeat, snapshotClock; + private boolean started, closed, scheduledCheckIns; + private long nextControl, nextStatusUpdate, controlIntervalMs = 1000, minUpdateIntervalMs = 1000; + private ServerStatus lastReportedStatus; + private Health lastReportedHealth; + private final AtomicBoolean closing = new AtomicBoolean(); + private final CompletableFuture stopped = new CompletableFuture<>(); + private ScheduledFuture timer; + public ProviderClient(Configuration config, ProviderStateStore store, ProviderTransport transport, Supplier statusSupplier, Supplier healthSupplier, Consumer diagnostics) { + this.config = config; this.store = store; this.transport = transport; this.statusSupplier = statusSupplier; this.healthSupplier = healthSupplier; + this.diagnostics = diagnostics; this.origin = ProviderCrypto.origin(config.provider()); + } + public CompletableFuture start() { return submit(() -> { + if (started) throw new IllegalStateException("Already started"); + discovery = exchange(URI.create(origin + "/.well-known/nethernet-external-signalling"), "GET", null, false, null, null); + validateDiscovery(); state = store.read(); + if (state.has("provider") && !origin.equals(state.get("provider").getAsString())) throw new IOException("State belongs to another provider; use a separate directory"); + if (!state.has("privateKey")) { + KeyPair pair = ProviderCrypto.generate(); state.addProperty("provider", origin); state.addProperty("privateKey", ProviderCrypto.base64(pair.getPrivate().getEncoded())); state.add("publicKeyJwk", ProviderCrypto.publicJwk(pair.getPublic())); save(); + } + privateKey = ProviderCrypto.privateKey(state.get("privateKey").getAsString()); + if (!state.has("registration")) enroll(); + else recoverExisting(); + JsonObject registration = state.getAsJsonObject("registration"); + if (!registration.get("provider").getAsString().equals(origin)) throw new IOException("Registration audience changed"); + JsonObject activationRequest = new JsonObject(); activationRequest.addProperty("profile", config.profile()); + JsonObject activation = signed("activate", "POST", activationRequest); + state.addProperty("protocol", ProviderCrypto.PROTOCOL); state.addProperty("profile", config.profile()); + state.addProperty("generation", activation.get("leaseGeneration").getAsLong()); state.addProperty("sequence", 0); state.remove("cursor"); save(); + installKeys(); + // Volatile admissions from an earlier profile cannot be restored by a stateless endpoint. + state.remove("pendingAdmissions"); save(); + started = true; heartbeat(); + timer = executor.scheduleWithFixedDelay(() -> { + if (closed) return; + try { + if (started && (System.nanoTime() >= nextHeartbeat || statusChanged())) heartbeat(); + } catch (Exception e) { + nextHeartbeat = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + nextStatusUpdate = nextHeartbeat; + diagnostics.accept("provider_status_unavailable: " + safeFailure(e)); + } + if (System.nanoTime() >= nextControl) { + nextControl = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(controlIntervalMs); + try { control(); } catch (Exception e) { diagnostics.accept("provider_control_unavailable: " + safeFailure(e)); } + } + try { flushEvents(); } catch (Exception e) { diagnostics.accept("provider_events_unavailable: " + safeFailure(e)); } + }, 1000, 1000, TimeUnit.MILLISECONDS); + return redactedRegistration(); + }); } + private void validateDiscovery() throws IOException { + ProviderContract.require("discovery", discovery); + ProtocolExtensions.validate(discovery); + if (!origin.equals(discovery.get("controlOrigin").getAsString()) || !origin.equals(discovery.get("provider").getAsString())) throw new IOException("Discovery provider mismatch"); + for (String[] pair : List.of(new String[]{"protocols", ProviderCrypto.PROTOCOL}, new String[]{"signatures", ProviderCrypto.SIGNATURE}, new String[]{"profiles", config.profile()}, new String[]{"modes", config.registrationMode()})) { + if (!discovery.getAsJsonArray(pair[0]).contains(new JsonPrimitive(pair[1]))) throw new IOException("Unsupported required provider capability: " + pair[0]); + } + boolean authorizationSupported = false; + for (JsonElement item : discovery.getAsJsonObject("authorization").getAsJsonArray("schemes")) { + JsonObject scheme = item.getAsJsonObject(); + if (config.authorizationScheme().equals(scheme.get("scheme").getAsString()) && scheme.getAsJsonArray("modes").contains(new JsonPrimitive(config.registrationMode()))) authorizationSupported = true; + } + if (!authorizationSupported || !"Authorization".equals(discovery.getAsJsonObject("authorization").get("header").getAsString())) throw new IOException("Unsupported provider authorization"); + for (String op : ProviderContract.operations()) if (!discovery.getAsJsonObject("operations").has(op)) throw new IOException("Missing required operation: " + op); + for (var op : discovery.getAsJsonObject("operations").entrySet()) trusted(URI.create(op.getValue().getAsString())); + intervalMs = discovery.getAsJsonObject("limits").get("heartbeatIntervalMs").getAsLong(); + if (intervalMs < 1000 || intervalMs > 30000) throw new IOException("Unsupported heartbeat interval"); + JsonObject limits = discovery.getAsJsonObject("limits"); + if (limits.get("maxBodyBytes").getAsLong() < 1 || limits.get("maxBodyBytes").getAsLong() > 65536 + || limits.get("maxControlPage").getAsLong() < 1 || limits.get("maxControlPage").getAsLong() > 100 + || limits.get("clockSkewMs").getAsLong() < 0 || limits.get("clockSkewMs").getAsLong() > 60000) + throw new IOException("Unsupported provider limits"); + } + private void recoverExisting() throws Exception { + JsonObject recovery = new JsonObject(); recovery.addProperty("registrationId", registration("registrationId")); + recovery.addProperty("protocol", ProviderCrypto.PROTOCOL); recovery.addProperty("profile", config.profile()); + JsonObject challenge = unsigned("recover", recovery); + String thumbprint = challenge.get("thumbprint").getAsString(); + boolean pending = state.has("pendingPublicKeyJwk") && ProviderCrypto.thumbprint(state.getAsJsonObject("pendingPublicKeyJwk")).equals(thumbprint); + PrivateKey key = pending ? ProviderCrypto.privateKey(state.get("pendingPrivateKey").getAsString()) : privateKey; + if (!pending && !ProviderCrypto.thumbprint(state.getAsJsonObject("publicKeyJwk")).equals(thumbprint)) throw new IOException("Recovery key does not match durable state"); + if (!origin.equals(challenge.get("audience").getAsString()) || !ProviderCrypto.PROTOCOL.equals(challenge.get("protocol").getAsString())) throw new IOException("Recovery audience mismatch"); + String intent = UUID.randomUUID().toString(); JsonObject completion = new JsonObject(); + completion.addProperty("protocol", ProviderCrypto.PROTOCOL); completion.addProperty("challengeId", challenge.get("challengeId").getAsString()); + completion.addProperty("proofNonce", "0"); completion.addProperty("idempotencyKey", intent); completion.addProperty("signature", ProviderCrypto.sign(key, ProviderCrypto.proof(challenge, "0", intent))); + JsonObject recovered = unsigned("complete", completion); validateRegistration(recovered); + registrationExtensions = ProtocolExtensions.copy(recovered); recovered.remove("extensions"); + state.add("registration", recovered); state.addProperty("generation", recovered.get("leaseGeneration").getAsLong()); + // Sequence is monotonic within a generation; the previous durable reservation is retained. + if (pending) { state.add("privateKey", state.remove("pendingPrivateKey")); state.add("publicKeyJwk", state.remove("pendingPublicKeyJwk")); privateKey = key; } + save(); + } + private void enroll() throws Exception { + JsonObject challenge; + if (state.has("challenge")) { + JsonObject recovery = new JsonObject(); recovery.addProperty("registrationId", state.getAsJsonObject("challenge").get("challengeId").getAsString()); + try { challenge = unsigned("recover", recovery); } + catch (ProviderException e) { if (e.status != 403) throw e; challenge = state.getAsJsonObject("challenge"); } + } else { + JsonObject request = new JsonObject(); request.addProperty("protocol", ProviderCrypto.PROTOCOL); request.addProperty("mode", config.registrationMode()); + request.addProperty("profile", config.profile()); request.add("publicKeyJwk", state.get("publicKeyJwk")); if (config.label() != null) request.addProperty("label", config.label()); + JsonObject authorization = new JsonObject(); authorization.addProperty("scheme", config.authorizationScheme()); request.add("authorization", authorization); + if (config.region() != null) { JsonObject p = new JsonObject(); p.addProperty("region", config.region()); p.addProperty("pool", config.pool()); if (!config.tags().isEmpty()) p.add("tags", JSON.toJsonTree(config.tags())); request.add("placement", p); } + challenge = unsigned("challenges", request, config.authorizationToken()); state.add("challenge", challenge); save(); + } + ProviderContract.require("challenge", challenge); + if (!ProviderCrypto.PROTOCOL.equals(challenge.get("protocol").getAsString()) || !ProviderCrypto.SIGNATURE.equals(challenge.get("signature").getAsString()) || !origin.equals(challenge.get("audience").getAsString()) || !ProviderCrypto.thumbprint(state.getAsJsonObject("publicKeyJwk")).equals(challenge.get("thumbprint").getAsString()) || !ProviderCrypto.contextDigest(challenge.getAsJsonObject("context")).equals(challenge.get("contextDigest").getAsString())) throw new IOException("Unbound registration challenge"); + JsonObject context = challenge.getAsJsonObject("context"); + if (!config.profile().equals(context.get("profile").getAsString()) || !config.registrationMode().equals(context.get("mode").getAsString())) throw new IOException("Challenge registration context changed"); + String expectedTagsDigest = ProviderCrypto.tagsDigest(config.tags()); + if (config.region() == null) { + if (!context.get("region").getAsString().isEmpty() || !context.get("pool").getAsString().isEmpty() || context.has("tagsDigest")) throw new IOException("Challenge placement changed"); + } else if (!config.region().equals(context.get("region").getAsString()) || !config.pool().equals(context.get("pool").getAsString()) || + (expectedTagsDigest == null ? context.has("tagsDigest") : !context.has("tagsDigest") || !expectedTagsDigest.equals(context.get("tagsDigest").getAsString()))) throw new IOException("Challenge placement changed"); + if (challenge.has("authorization") && !config.authorizationScheme().equals(challenge.getAsJsonObject("authorization").get("scheme").getAsString())) throw new IOException("Challenge authorization changed"); + if (BEARER_TOKEN.equals(config.authorizationScheme()) && (!challenge.has("authorization") || challenge.getAsJsonObject("authorization").get("reference").getAsString().isBlank())) throw new IOException("Bearer challenge omitted its authority reference"); + String intent = UUID.randomUUID().toString(), nonce = null; + int bits = challenge.getAsJsonObject("pow").get("difficulty").getAsInt(); + if (!"sha256-leading-zero-bits-v0".equals(challenge.getAsJsonObject("pow").get("algorithm").getAsString()) || bits < 0 || bits > 24) throw new IOException("Unsupported proof of work"); + if (BEARER_TOKEN.equals(config.authorizationScheme()) && bits != 0) throw new IOException("Bearer-authorized registration unexpectedly requires proof of work"); + long deadline = challenge.get("expiresAt").getAsLong(); + for (long i = 0; System.currentTimeMillis() < deadline; i++) { String candidate = Long.toString(i); if (ProviderCrypto.meetsDifficulty(ProviderCrypto.digest(ProviderCrypto.proof(challenge, candidate, intent)), bits)) { nonce = candidate; break; } } + if (nonce == null) throw new IOException("Challenge expired before proof completed"); + JsonObject completion = new JsonObject(); completion.addProperty("protocol", ProviderCrypto.PROTOCOL); completion.addProperty("challengeId", challenge.get("challengeId").getAsString()); completion.addProperty("proofNonce", nonce); completion.addProperty("idempotencyKey", intent); completion.addProperty("signature", ProviderCrypto.sign(privateKey, ProviderCrypto.proof(challenge, nonce, intent))); + JsonObject registration = unsigned("complete", completion); ProviderContract.require("registration", registration); validateRegistration(registration); + registrationExtensions = ProtocolExtensions.copy(registration); registration.remove("extensions"); + state.add("registration", registration); state.addProperty("generation", registration.get("leaseGeneration").getAsLong()); state.addProperty("sequence", 0); + state.add("ticketKeys", new JsonArray()); + if (registration.has("ticketKey")) { state.getAsJsonArray("ticketKeys").add(registration.remove("ticketKey")); } + save(); + } + private void validateRegistration(JsonObject registration) throws IOException { + ProviderContract.require("registration", registration); + ProtocolExtensions.validate(registration); + if (!origin.equals(registration.get("provider").getAsString()) || !config.profile().equals(registration.get("profile").getAsString())) throw new IOException("Registration provider or profile changed"); + JsonObject placement = registration.getAsJsonObject("placement"); + String expectedRegion = config.region() == null ? "" : config.region(), expectedPool = config.pool() == null ? "" : config.pool(); + if (!expectedRegion.equals(placement.get("region").getAsString()) || !expectedPool.equals(placement.get("pool").getAsString())) throw new IOException("Registration placement changed"); + JsonObject expectedTags = JSON.toJsonTree(config.tags()).getAsJsonObject(); + JsonObject actualTags = placement.has("tags") ? placement.getAsJsonObject("tags") : new JsonObject(); + if (!expectedTags.equals(actualTags)) throw new IOException("Registration placement tags changed"); + } + private void installKeys() throws Exception { + if (!state.has("ticketKeys")) state.add("ticketKeys", new JsonArray()); + JsonArray unexpired = new JsonArray(); for (JsonElement e : state.getAsJsonArray("ticketKeys")) if (!e.getAsJsonObject().has("retireAfter") || e.getAsJsonObject().get("retireAfter").getAsLong() > System.currentTimeMillis()) unexpired.add(e); + state.add("ticketKeys", unexpired); save(); + if (state.getAsJsonArray("ticketKeys").isEmpty()) { + JsonObject fresh = signed("ticket-keys", "POST", new JsonObject()); + if (!fresh.has("ticketKey")) throw new IOException("Ticket response was lost; retry fresh provisioning"); + state.getAsJsonArray("ticketKeys").add(fresh.get("ticketKey")); save(); + } + List keys = new ArrayList<>(); + for (JsonElement e : state.getAsJsonArray("ticketKeys")) { JsonObject k = e.getAsJsonObject(); keys.add(new ProviderTransport.TicketKey(k.get("keyId").getAsString(), k.get("secret").getAsString(), k.has("notBefore") ? k.get("notBefore").getAsLong() : 0, k.has("retireAfter") ? k.get("retireAfter").getAsLong() : Long.MAX_VALUE)); } + transport.installTicketKeys(List.copyOf(keys)).toCompletableFuture().get(10, TimeUnit.SECONDS); + JsonObject ack = new JsonObject(); ack.addProperty("keyId", keys.getLast().keyId()); JsonObject acknowledgement = signed("ticket-keys/ack", "POST", ack); + if (acknowledgement.has("retirements")) { + for (JsonElement retired : acknowledgement.getAsJsonArray("retirements")) for (JsonElement stored : state.getAsJsonArray("ticketKeys")) { + JsonObject r = retired.getAsJsonObject(), k = stored.getAsJsonObject(); + if (r.get("keyId").equals(k.get("keyId"))) k.addProperty("retireAfter", Math.min(k.has("retireAfter") ? k.get("retireAfter").getAsLong() : Long.MAX_VALUE, r.get("retireAfter").getAsLong())); + } + save(); List bounded = new ArrayList<>(); + for (JsonElement stored : state.getAsJsonArray("ticketKeys")) { JsonObject k = stored.getAsJsonObject(); long end = k.has("retireAfter") ? k.get("retireAfter").getAsLong() : Long.MAX_VALUE; if (end > System.currentTimeMillis()) bounded.add(new ProviderTransport.TicketKey(k.get("keyId").getAsString(), k.get("secret").getAsString(), k.has("notBefore") ? k.get("notBefore").getAsLong() : 0, end)); } + transport.installTicketKeys(List.copyOf(bounded)).toCompletableFuture().get(10, TimeUnit.SECONDS); + } + } + /** A full immutable snapshot. Callers may update every one of the seven fields. */ + public void setServerStatus(ServerStatus status) { explicitStatus.set(Objects.requireNonNull(status)); requestStatusRefresh(); } + /** Wake local observation on join/leave/reload; unchanged snapshots never create network traffic. */ + public void requestStatusRefresh() { + if (!closing.get() && refreshQueued.compareAndSet(false, true)) executor.execute(() -> { refreshQueued.set(false); if (!closed && started) { try { if (statusChanged()) heartbeat(); } catch (Exception e) { nextStatusUpdate = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); diagnostics.accept("provider_status_unavailable: " + safeFailure(e)); } } }); + } + private ServerStatus currentStatus() { ServerStatus s = explicitStatus.get(); return s == null && statusSupplier != null ? statusSupplier.get() : s; } + private boolean statusChanged() { + if (System.nanoTime() < nextStatusUpdate) return false; + if (!scheduledCheckIns) return System.nanoTime() >= nextHeartbeat; + ServerStatus status = currentStatus(); Health health = healthSupplier.get(); + return !Objects.equals(status, lastReportedStatus) || lastReportedHealth == null + || health.healthy() != lastReportedHealth.healthy() || health.capacity() != lastReportedHealth.capacity() + || !Objects.equals(health.protocolVersion(), lastReportedHealth.protocolVersion()) || !Objects.equals(health.build(), lastReportedHealth.build()); + } + private void heartbeat() throws Exception { + JsonObject profile = transport.hostProfile().toCompletableFuture().get(10, TimeUnit.SECONDS); + if (profile == null) throw new IOException("Transport profile unavailable"); + boolean supportsSchedule = discovery.getAsJsonObject("limits").has("checkInVersion") + && discovery.getAsJsonObject("limits").get("checkInVersion").getAsInt() == 1 && profile.has("statelessAdmission"); + if (!profile.equals(lastProfile) || !state.has("profilePublishedAt") || (!supportsSchedule && System.currentTimeMillis() - state.get("profilePublishedAt").getAsLong() > 300000)) { + JsonObject published = signed("host-profile", "POST", profile); profileRevision = published.get("revision").getAsString(); lastProfile = profile.deepCopy(); state.addProperty("profilePublishedAt", System.currentTimeMillis()); save(); + } + Health h = healthSupplier.get(); JsonObject body = new JsonObject(); body.addProperty("healthy", h.healthy()); body.addProperty("capacity", h.capacity()); body.addProperty("load", h.load()); body.addProperty("protocolVersion", h.protocolVersion()); body.addProperty("build", h.build()); body.addProperty("hostProfileRevision", profileRevision); + if (config.region() != null) body.addProperty("region", config.region()); + snapshotClock = Math.max(System.currentTimeMillis(), snapshotClock + 1); body.addProperty("clockUnixMillis", snapshotClock); + ServerStatus status = null; + try { status = currentStatus(); if (status != null) body.add("serverStatus", JSON.toJsonTree(status)); } + catch (RuntimeException e) { diagnostics.accept("status_refresh_failed"); /* Omit snapshot; old report timestamp must expire. */ } + if (supportsSchedule) body.addProperty("checkInVersion", 1); + long requestStarted = System.nanoTime(); + JsonObject response = signed("heartbeat", "POST", body); + if (supportsSchedule && response.has("checkIn")) { + CheckInSchedule schedule = CheckInSchedule.parse(response); + scheduledCheckIns = true; controlIntervalMs = schedule.controlPollAfterMillis(); minUpdateIntervalMs = schedule.minUpdateIntervalMillis(); + // Count network time against the granted interval; retries cannot postpone an absolute lease. + long received = java.time.Instant.parse(response.get("receivedAt").getAsString()).toEpochMilli(); + long remaining = Math.min(schedule.afterMillis(), Math.max(0, response.getAsJsonObject("checkIn").get("nextCheckInAt").getAsLong() - Math.max(received, System.currentTimeMillis()))); + nextHeartbeat = Math.min(requestStarted + TimeUnit.MILLISECONDS.toNanos(schedule.afterMillis()), System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(remaining)); + nextControl = Math.min(nextControl, requestStarted + TimeUnit.MILLISECONDS.toNanos(controlIntervalMs)); + } else { + scheduledCheckIns = false; controlIntervalMs = 1000; + nextHeartbeat = requestStarted + TimeUnit.MILLISECONDS.toNanos(intervalMs + ThreadLocalRandom.current().nextLong(Math.max(1, intervalMs / 10))); + nextControl = Math.min(nextControl, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + } + nextStatusUpdate = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(scheduledCheckIns ? minUpdateIntervalMs : intervalMs); + lastReportedStatus = status; lastReportedHealth = h; + } + private void control() throws Exception { + JsonObject page = signed("control", "GET", null); + JsonArray commands = page.has("commands") ? page.getAsJsonArray("commands") : new JsonArray(); + if (commands.size() > discovery.getAsJsonObject("limits").get("maxControlPage").getAsInt()) throw new IOException("Control page exceeds limit"); + boolean terminal = true; + for (JsonElement item : commands) { + JsonObject command = item.getAsJsonObject(); String kind = command.has("kind") ? command.get("kind").getAsString() : ""; + if (!Set.of("noop", "drain", "suspend", "revoke").contains(kind)) { + terminal = false; diagnostics.accept("unsupported_control_command"); continue; + } + ProviderTransport.ApplyResult result = transport.applyControl(command.deepCopy()).toCompletableFuture().get(10, TimeUnit.SECONDS); + if (result == ProviderTransport.ApplyResult.PENDING) terminal = false; + } + if (terminal && !commands.isEmpty() && page.has("cursor")) { + // Native terminal results are replay-safe; merely staged volatile admissions never reach here. + String cursor = page.get("cursor").getAsString(); JsonObject ack = new JsonObject(); ack.addProperty("cursor", cursor); signed("control/ack", "POST", ack); state.addProperty("cursor", cursor); save(); + } + } + private void flushEvents() throws Exception { + if (!state.has("pendingEvents")) state.add("pendingEvents", new JsonArray()); + JsonArray pending = state.getAsJsonArray("pendingEvents"); + List fresh = transport.pollEvents(); + if (fresh.size() > 100 || pending.size() + fresh.size() > 1000) throw new IOException("Transport event queue exceeds limit"); + for (JsonObject event : fresh) { + // Persist only the existing redacted telemetry fields, never native SDP or secret extensions. + JsonObject safe = new JsonObject(); + for (String field : List.of("stage", "type", "ticketId", "decisionId", "occurredAt", "reason")) if (event.has(field)) safe.add(field, event.get(field)); + if ((!safe.has("stage") && !safe.has("type")) || !safe.has("occurredAt")) throw new IOException("Malformed transport event"); + pending.add(safe); + } + if (pending.isEmpty()) return; + save(); + for (String operation : List.of("ticket-events", "events")) { + JsonArray batch = new JsonArray(); + for (JsonElement e : pending) if (e.getAsJsonObject().has(operation.equals("events") ? "type" : "stage") && batch.size() < 100) batch.add(e); + if (batch.isEmpty()) continue; + JsonObject body = new JsonObject(); body.add("events", batch); signed(operation, "POST", body); + for (JsonElement sent : batch) pending.remove(sent); save(); + } + } + public CompletableFuture readiness() { return submit(() -> { + JsonObject response = signed("readiness", "GET", null); ProtocolExtensions.validate(response); return response; + }); } + /** Opaque optional extension metadata; the application decides what it means. */ + public CompletableFuture extensions() { return submit(() -> registrationExtensions.deepCopy()); } + /** Explicit application request to an advertised extension operation, never automatic execution. */ + public CompletableFuture extensionRequest(String namespace, String operation, String method, JsonObject body) { + return submit(() -> { + if (!Set.of("GET", "POST").contains(method)) throw new IOException("Unsupported extension method"); + JsonObject extension = ProtocolExtensions.copy(discovery).getAsJsonObject(namespace); + if (extension == null) throw new IOException("Extension unavailable"); + JsonObject operations = extension.getAsJsonObject("data").getAsJsonObject("operations"); + if (operations == null || !operations.has(operation)) throw new IOException("Extension operation unavailable"); + URI uri = trusted(URI.create(operations.get(operation).getAsString())); + long sequence = state.has("sequence") ? state.get("sequence").getAsLong() + 1 : 1; + state.addProperty("sequence", sequence); save(); + JsonObject response = exchange(uri, method, body == null ? null : JSON.toJson(body), true, UUID.randomUUID().toString(), null); + ProtocolExtensions.validate(response); return response; + }); + } + public CompletableFuture deregister() { return submit(() -> { + signed("deregister", "POST", new JsonObject()); transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); started = false; return null; + }); } + public CompletableFuture rotateTicketKey() { return submit(() -> { + JsonObject result = signed("ticket-keys", "POST", new JsonObject()); if (!result.has("ticketKey")) throw new IOException("Fresh ticket provisioning required"); + state.getAsJsonArray("ticketKeys").add(result.get("ticketKey")); save(); installKeys(); lastProfile = null; heartbeat(); return redactedRegistration(); + }); } + public CompletableFuture rotateMachineKey() { return submit(() -> { + KeyPair replacement = ProviderCrypto.generate(); JsonObject jwk = ProviderCrypto.publicJwk(replacement.getPublic()); + state.addProperty("pendingPrivateKey", ProviderCrypto.base64(replacement.getPrivate().getEncoded())); state.add("pendingPublicKeyJwk", jwk); save(); + String intent = UUID.randomUUID().toString(); JsonObject body = new JsonObject(); body.add("publicKeyJwk", jwk); + body.addProperty("proof", ProviderCrypto.sign(replacement.getPrivate(), ProviderCrypto.array(ProviderCrypto.PROTOCOL, "rotate", origin, registration("instanceId"), registration("keyId"), ProviderCrypto.thumbprint(jwk), state.get("generation").getAsLong(), intent))); + JsonObject result = signed("rotate", "POST", body, intent); String oldKey = registration("keyId"); + state.add("privateKey", state.remove("pendingPrivateKey")); state.add("publicKeyJwk", state.remove("pendingPublicKeyJwk")); state.getAsJsonObject("registration").addProperty("keyId", result.get("keyId").getAsString()); save(); privateKey = replacement.getPrivate(); + JsonObject retire = new JsonObject(); retire.addProperty("keyId", oldKey); signed("retire", "POST", retire); return result; + }); } + public CompletableFuture drain() { return submit(() -> { signed("drain", "POST", new JsonObject()); transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); started = false; return null; }); } + private JsonObject unsigned(String op, JsonObject body) throws Exception { return unsigned(op, body, null); } + private JsonObject unsigned(String op, JsonObject body, String bearerToken) throws Exception { return exchange(operation(op), "POST", JSON.toJson(body), false, null, bearerToken); } + private JsonObject signed(String op, String method, JsonObject body) throws Exception { return signed(op, method, body, UUID.randomUUID().toString()); } + private JsonObject signed(String op, String method, JsonObject body, String intent) throws Exception { + long sequence = state.has("sequence") ? state.get("sequence").getAsLong() + 1 : 1; state.addProperty("sequence", sequence); save(); + URI uri = operation(op); + if (op.equals("control") && state.has("cursor")) uri = URI.create(uri + "?cursor=" + java.net.URLEncoder.encode(state.get("cursor").getAsString(), java.nio.charset.StandardCharsets.UTF_8)); + return exchange(uri, method, body == null ? null : JSON.toJson(body), true, intent, null); + } + private URI operation(String op) throws IOException { if (!discovery.getAsJsonObject("operations").has(op)) throw new IOException("Missing provider operation: " + op); return trusted(URI.create(discovery.getAsJsonObject("operations").get(op).getAsString())); } + private URI trusted(URI uri) throws IOException { + URI authority = URI.create(uri.getScheme() + "://" + uri.getRawAuthority()); + if (!ProviderCrypto.origin(authority).equals(origin) || uri.getUserInfo() != null || uri.getFragment() != null) throw new IOException("Untrusted provider operation"); return uri; + } + private JsonObject exchange(URI uri, String method, String body, boolean signed, String intent, String bearerToken) throws Exception { + trusted(uri); String raw = body == null ? "" : body; + for (int attempt = 0; attempt < 3; attempt++) { + HttpRequest.Builder b = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(15)).header("accept", "application/json").method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)); + if (body != null) b.header("content-type", "application/json"); + if (bearerToken != null) b.header("authorization", "Bearer " + bearerToken); + if (signed) { + long now = System.currentTimeMillis(), generation = state.get("generation").getAsLong(), sequence = state.get("sequence").getAsLong(); + String path = uri.getRawPath() + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery()); + b.header("nxs-instance-id", registration("instanceId")).header("nxs-key-id", registration("keyId")).header("nxs-timestamp", Long.toString(now)).header("nxs-signature-version", ProviderCrypto.SIGNATURE).header("nxs-generation", Long.toString(generation)).header("nxs-sequence", Long.toString(sequence)).header("idempotency-key", intent).header("nxs-signature", ProviderCrypto.sign(privateKey, ProviderCrypto.request(origin, method, path, now, registration("instanceId"), registration("keyId"), intent, generation, sequence, raw))); + } + HttpResponse response; + var responseFuture = http.sendAsync(b.build(), info -> new LimitedBodySubscriber(65536)); + try { response = responseFuture.get(20, TimeUnit.SECONDS); } + catch (ExecutionException | TimeoutException failure) { + responseFuture.cancel(true); + if (attempt == 2) throw new IOException("Provider transport unavailable", failure); + Thread.sleep((250L << attempt) + ThreadLocalRandom.current().nextLong(100)); continue; + } + String text = new String(response.body(), java.nio.charset.StandardCharsets.UTF_8); + int status = response.statusCode(); + if ((status == 429 || status == 503 || status == 502 || status == 504) && attempt < 2) { long delay = 250L << attempt; + try { delay = Math.max(delay, Long.parseLong(response.headers().firstValue("retry-after").orElse("0")) * 1000); } catch (NumberFormatException ignored) { } + if (delay > 10000) throw new ProviderException(status, "retry_later"); Thread.sleep(delay + ThreadLocalRandom.current().nextLong(100)); continue; + } + if (status / 100 != 2) { String code = "request_rejected"; try { JsonObject error = JsonParser.parseString(text).getAsJsonObject(); if (error.has("code") && error.get("code").getAsString().matches("[a-z0-9_]{1,80}")) code = error.get("code").getAsString(); } catch (RuntimeException ignored) {} throw new ProviderException(status, code); } + return JsonParser.parseString(text).getAsJsonObject(); + } + throw new IOException("Provider retry limit exceeded"); + } + private String registration(String field) { return state.getAsJsonObject("registration").get(field).getAsString(); } + private JsonObject redactedRegistration() { JsonObject copy = state.getAsJsonObject("registration").deepCopy(); copy.remove("ticketKey"); if (!registrationExtensions.isEmpty()) copy.add("extensions", registrationExtensions.deepCopy()); return copy; } + private void save() throws IOException { + try { store.write(state); } + catch (IOException failure) { diagnostics.accept("provider_persistence_failed"); stop(); throw failure; } + } + private static String safeFailure(Exception e) { return e instanceof ProviderException ? e.getMessage() : e.getClass().getSimpleName(); } + private CompletableFuture submit(Callable fn) { + CompletableFuture f = new CompletableFuture<>(); executor.execute(() -> { try { if (closed) throw new IOException("Provider is closed"); f.complete(fn.call()); } catch (Throwable e) { f.completeExceptionally(e); } }); return f; + } + public CompletionStage stop() { + if (!closing.compareAndSet(false, true)) return stopped; + executor.execute(() -> { try { if (started) { signed("drain", "POST", new JsonObject()); transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); } } catch (Exception e) { diagnostics.accept("provider_drain_unavailable"); } + finally { + closed = true; started = false; if (timer != null) timer.cancel(false); + try { transport.close().toCompletableFuture().get(10, TimeUnit.SECONDS); } + catch (Exception e) { diagnostics.accept("transport_close_failed"); } + try { store.close(); } catch (Exception e) { diagnostics.accept("provider_state_close_failed"); } + http.close(); executor.shutdown(); stopped.complete(null); + } + }); + return stopped; + } + @Override public void close() { stop(); } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderContract.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderContract.java new file mode 100644 index 00000000..8e2bca46 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderContract.java @@ -0,0 +1,30 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** Canonical schema is owned by docs/external-signalling; JVM and other implementations consume the same resource. */ +final class ProviderContract { + private static final JsonObject SCHEMA; + static { + try (var in = ProviderContract.class.getResourceAsStream("/nxs-v1.schema.json")) { + SCHEMA = JsonParser.parseString(new String(Objects.requireNonNull(in).readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); + } catch (Exception e) { throw new ExceptionInInitializerError(e); } + } + static void require(String document, JsonObject value) { + JsonObject schema = SCHEMA.getAsJsonObject("$defs").getAsJsonObject(document); + for (JsonElement field : schema.getAsJsonArray("required")) if (!value.has(field.getAsString()) || value.get(field.getAsString()).isJsonNull()) throw new IllegalArgumentException("Missing required provider field: " + field.getAsString()); + if (schema.has("properties")) for (var p : schema.getAsJsonObject("properties").entrySet()) { + JsonObject property = p.getValue().getAsJsonObject(); + if (property.has("const") && !property.get("const").equals(value.get(p.getKey()))) throw new IllegalArgumentException("Unsupported provider field: " + p.getKey()); + } + } + static java.util.List operations() { return SCHEMA.getAsJsonArray("x-operations").asList().stream().map(JsonElement::getAsString).toList(); } + static Object[] contextValues(JsonObject context) { + List values = new ArrayList<>(); + for (JsonElement key : SCHEMA.getAsJsonArray("x-context-order")) values.add(context.get(key.getAsString()).getAsString()); + if (SCHEMA.has("x-optional-context-order")) for (JsonElement key : SCHEMA.getAsJsonArray("x-optional-context-order")) if (context.has(key.getAsString())) values.add(context.get(key.getAsString()).getAsString()); + return values.toArray(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderCrypto.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderCrypto.java new file mode 100644 index 00000000..7a3cbd38 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderCrypto.java @@ -0,0 +1,80 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.math.BigInteger; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.interfaces.ECPublicKey; +import java.security.spec.*; +import java.util.*; + +/** Exact v0 canonical bytes. P1363 explicitly avoids the JVM's default DER ECDSA encoding. */ +public final class ProviderCrypto { + public static final String PROTOCOL = "nethernet-external-signalling-v1"; + public static final String SIGNATURE = "nxs-es384-v1"; + private ProviderCrypto() {} + public static KeyPair generate() throws GeneralSecurityException { + KeyPairGenerator g = KeyPairGenerator.getInstance("EC"); g.initialize(new ECGenParameterSpec("secp384r1")); return g.generateKeyPair(); + } + public static String base64(byte[] b) { return Base64.getUrlEncoder().withoutPadding().encodeToString(b); } + public static byte[] decode(String s) { + if (!s.matches("[A-Za-z0-9_-]+")) throw new IllegalArgumentException("Invalid base64url"); + byte[] b = Base64.getUrlDecoder().decode(s); + if (!base64(b).equals(s)) throw new IllegalArgumentException("Noncanonical base64url"); return b; + } + public static byte[] digest(String s) { + try { return MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)); } + catch (GeneralSecurityException e) { throw new IllegalStateException(e); } + } + public static JsonObject publicJwk(PublicKey key) { + ECPublicKey ec = (ECPublicKey) key; JsonObject j = new JsonObject(); + j.addProperty("crv", "P-384"); j.addProperty("kty", "EC"); j.addProperty("x", base64(coordinate(ec.getW().getAffineX()))); j.addProperty("y", base64(coordinate(ec.getW().getAffineY()))); return j; + } + private static byte[] coordinate(BigInteger n) { byte[] raw = n.toByteArray(), out = new byte[48]; System.arraycopy(raw, Math.max(0, raw.length - 48), out, Math.max(0, 48 - raw.length), Math.min(48, raw.length)); return out; } + public static PublicKey publicKey(JsonObject j) throws GeneralSecurityException { + validateJwk(j); AlgorithmParameters p = AlgorithmParameters.getInstance("EC"); p.init(new ECGenParameterSpec("secp384r1")); + return KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(new BigInteger(1, decode(j.get("x").getAsString())), new BigInteger(1, decode(j.get("y").getAsString()))), p.getParameterSpec(ECParameterSpec.class))); + } + public static PrivateKey privateKey(String encoded) throws GeneralSecurityException { return KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(decode(encoded))); } + private static void validateJwk(JsonObject j) { + if (j.has("d") || !"EC".equals(j.get("kty").getAsString()) || !"P-384".equals(j.get("crv").getAsString()) || !j.get("x").getAsString().matches("[A-Za-z0-9_-]{64}") || !j.get("y").getAsString().matches("[A-Za-z0-9_-]{64}")) throw new IllegalArgumentException("Invalid public P-384 JWK"); + } + public static String thumbprint(JsonObject j) { + validateJwk(j); return base64(digest("{\"crv\":\"P-384\",\"kty\":\"EC\",\"x\":\"" + j.get("x").getAsString() + "\",\"y\":\"" + j.get("y").getAsString() + "\"}")); + } + public static String sign(PrivateKey key, String payload) throws GeneralSecurityException { + Signature s = Signature.getInstance("SHA384withECDSAinP1363Format"); s.initSign(key); s.update(payload.getBytes(StandardCharsets.UTF_8)); return base64(s.sign()); + } + public static boolean verify(JsonObject key, String signature, String payload) { + try { byte[] raw = decode(signature); if (raw.length != 96) return false; + Signature s = Signature.getInstance("SHA384withECDSAinP1363Format"); s.initVerify(publicKey(key)); s.update(payload.getBytes(StandardCharsets.UTF_8)); return s.verify(raw); + } catch (GeneralSecurityException | RuntimeException e) { return false; } + } + public static String contextDigest(JsonObject c) { return base64(digest(array(ProviderContract.contextValues(c)))); } + public static String tagsDigest(Map tags) { + if (tags == null || tags.isEmpty()) return null; + StringJoiner entries = new StringJoiner(",", "[", "]"); + new TreeMap<>(tags).forEach((key, value) -> entries.add("[" + quote(key) + "," + quote(value) + "]")); + return base64(digest(entries.toString())); + } + public static String proof(JsonObject c, String nonce, String intent) { return array(PROTOCOL, "complete", c.get("audience").getAsString(), c.get("challengeId").getAsString(), c.get("nonce").getAsString(), c.get("thumbprint").getAsString(), c.get("contextDigest").getAsString(), c.get("expiresAt").getAsLong(), nonce, intent); } + public static boolean meetsDifficulty(byte[] bytes, int bits) { if (bits < 0 || bits > 24) return false; for (int i = 0; i < bits; i++) if ((bytes[i / 8] & (128 >> (i % 8))) != 0) return false; return true; } + public static String request(String audience, String method, String path, long timestamp, String instance, String key, String intent, long generation, long sequence, String body) { return array(PROTOCOL, SIGNATURE, audience, method, path, timestamp, instance, key, intent, generation, sequence, base64(digest(body))); } + public static String origin(URI u) { + if (u.getHost() == null || u.getUserInfo() != null || u.getFragment() != null || u.getQuery() != null || !(u.getPath().isEmpty() || u.getPath().equals("/"))) throw new IllegalArgumentException("Invalid provider origin"); + String host = u.getHost().toLowerCase(Locale.ROOT), scheme = u.getScheme().toLowerCase(Locale.ROOT); + if (!scheme.equals("https") && !(scheme.equals("http") && Set.of("localhost", "127.0.0.1", "[::1]").contains(host))) throw new IllegalArgumentException("HTTPS provider required"); + int port = u.getPort(); return scheme + "://" + host + (port < 0 || (scheme.equals("https") && port == 443) || (scheme.equals("http") && port == 80) ? "" : ":" + port); + } + public static String array(Object... values) { + StringJoiner out = new StringJoiner(",", "[", "]"); for (Object v : values) out.add(v instanceof String s ? quote(s) : String.valueOf(v)); return out.toString(); + } + public static String quote(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); switch (c) { + case '"' -> b.append("\\\""); case '\\' -> b.append("\\\\"); case '\b' -> b.append("\\b"); case '\f' -> b.append("\\f"); case '\n' -> b.append("\\n"); case '\r' -> b.append("\\r"); case '\t' -> b.append("\\t"); + default -> { if (c < 32 || (Character.isSurrogate(c) && !(Character.isHighSurrogate(c) && i + 1 < s.length() && Character.isLowSurrogate(s.charAt(i + 1))) && !(Character.isLowSurrogate(c) && i > 0 && Character.isHighSurrogate(s.charAt(i - 1))))) b.append(String.format("\\u%04x", (int)c)); else b.append(c); } + }} return b.append('"').toString(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderIdentity.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderIdentity.java new file mode 100644 index 00000000..189cc15b --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderIdentity.java @@ -0,0 +1,18 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.net.URI; +import java.security.KeyPair; + +/** Bootstrap controllers need the public key before issuing a key-bound attachment grant. */ +public final class ProviderIdentity { + private ProviderIdentity() {} + public static JsonObject initialize(ProviderStateStore store, URI provider) throws Exception { + String origin = ProviderCrypto.origin(provider); JsonObject state = store.read(); + if (state.has("provider") && !origin.equals(state.get("provider").getAsString())) throw new IllegalArgumentException("Provider state mismatch"); + if (!state.has("privateKey")) { + KeyPair pair = ProviderCrypto.generate(); state.addProperty("provider", origin); state.addProperty("privateKey", ProviderCrypto.base64(pair.getPrivate().getEncoded())); state.add("publicKeyJwk", ProviderCrypto.publicJwk(pair.getPublic())); state.addProperty("generation", 0); state.addProperty("sequence", 0); store.write(state); + } + return state.getAsJsonObject("publicKeyJwk").deepCopy(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderStateStore.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderStateStore.java new file mode 100644 index 00000000..a5573c02 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderStateStore.java @@ -0,0 +1,44 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.*; +import java.nio.file.*; +import java.nio.file.attribute.PosixFilePermissions; + +/** One logical instance owns this directory and key; never share or clone it across live instances. */ +public final class ProviderStateStore implements AutoCloseable { + private final Path directory, stateFile; + private final FileChannel lockChannel; + private final FileLock lock; + public ProviderStateStore(Path directory) throws IOException { + this.directory = directory.toAbsolutePath(); this.stateFile = this.directory.resolve("provider-state.json"); + Files.createDirectories(this.directory, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + if (Files.isSymbolicLink(this.directory) || Files.isSymbolicLink(stateFile)) throw new IOException("State paths must not be symbolic links"); + Files.setPosixFilePermissions(this.directory, PosixFilePermissions.fromString("rwx------")); + Path lockFile = this.directory.resolve("provider.lock"); + if (Files.isSymbolicLink(lockFile)) throw new IOException("Lock must not be a symbolic link"); + lockChannel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock acquired; + try { acquired = lockChannel.tryLock(); } catch (OverlappingFileLockException e) { lockChannel.close(); throw new IOException("State directory is already active", e); } + if (acquired == null) { lockChannel.close(); throw new IOException("State directory is already active"); } + lock = acquired; + } + public JsonObject read() throws IOException { + if (!Files.exists(stateFile)) return new JsonObject(); + if (Files.size(stateFile) > 262144) throw new IOException("State exceeds limit"); + Files.setPosixFilePermissions(stateFile, PosixFilePermissions.fromString("rw-------")); + return JsonParser.parseString(Files.readString(stateFile)).getAsJsonObject(); + } + public void write(JsonObject state) throws IOException { + Path tmp = Files.createTempFile(directory, "provider-state-", ".tmp", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + try { + byte[] bytes = new GsonBuilder().disableHtmlEscaping().create().toJson(state).getBytes(java.nio.charset.StandardCharsets.UTF_8); + try (FileChannel file = FileChannel.open(tmp, StandardOpenOption.WRITE)) { ByteBuffer b = ByteBuffer.wrap(bytes); while (b.hasRemaining()) file.write(b); file.force(true); } + Files.move(tmp, stateFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + try (FileChannel dir = FileChannel.open(directory, StandardOpenOption.READ)) { dir.force(true); } + } finally { Files.deleteIfExists(tmp); } + } + @Override public void close() throws IOException { try { lock.release(); } finally { lockChannel.close(); } } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java new file mode 100644 index 00000000..625ab3ff --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java @@ -0,0 +1,24 @@ +package org.cloudburstmc.netty.signalling; + +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/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ServerStatus.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ServerStatus.java new file mode 100644 index 00000000..4d7272a7 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ServerStatus.java @@ -0,0 +1,11 @@ +package org.cloudburstmc.netty.signalling; + +/** Complete atomic snapshot. Advertised maxPlayers is independent of routing capacity. */ +public record ServerStatus(String name, int protocol, String version, String level, int players, int maxPlayers, int gameType) { + public ServerStatus { + if (name == null || name.isEmpty() || name.codePointCount(0, name.length()) > 128 || version == null || version.isEmpty() || version.length() > 64 || + level == null || level.codePointCount(0, level.length()) > 128 || protocol < 1 || players < 0 || players > 1_000_000 || maxPlayers < 0 || maxPlayers > 1_000_000 || gameType < 0 || gameType > 2) + throw new IllegalArgumentException("Invalid complete server status snapshot"); + for (String value : java.util.List.of(name, version, level)) if (value.codePoints().anyMatch(c -> Character.getType(c) == Character.CONTROL || Character.getType(c) == Character.SURROGATE)) throw new IllegalArgumentException("Invalid status text"); + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java new file mode 100644 index 00000000..7c5357ba --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java @@ -0,0 +1,32 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class CheckInScheduleTest { + private JsonObject response(long delay) { + long now = 1800000000000L; + JsonObject response = new JsonObject(), schedule = new JsonObject(); + response.addProperty("receivedAt", java.time.Instant.ofEpochMilli(now).toString()); + schedule.addProperty("version", 1); schedule.addProperty("afterMillis", delay); + schedule.addProperty("nextCheckInAt", now + delay); schedule.addProperty("leaseExpiresAt", now + delay + 30000); + schedule.addProperty("controlPollAfterMillis", delay); schedule.addProperty("minUpdateIntervalMillis", 1000); + response.add("checkIn", schedule); return response; + } + @Test void acceptsChangedPolicyWithoutHardCodedIdleThresholds() throws Exception { + assertEquals(900000, CheckInSchedule.parse(response(900000)).afterMillis()); + assertEquals(3600000, CheckInSchedule.parse(response(3600000)).controlPollAfterMillis()); + assertEquals(45000, CheckInSchedule.parse(response(45000)).afterMillis()); + } + @Test void rejectsUnboundedFractionalAndInconsistentSchedules() { + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(response(0))); + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(response(86400001))); + JsonObject fractional = response(900000); fractional.getAsJsonObject("checkIn").addProperty("afterMillis", 900000.5); + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(fractional)); + JsonObject wrongDeadline = response(900000); wrongDeadline.getAsJsonObject("checkIn").addProperty("leaseExpiresAt", 1); + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(wrongDeadline)); + JsonObject wrongVersion = response(900000); wrongVersion.getAsJsonObject("checkIn").addProperty("version", 2); + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(wrongVersion)); + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java new file mode 100644 index 00000000..0e9e42f0 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java @@ -0,0 +1,127 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import com.sun.net.httpserver.*; +import java.net.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; + +/** Standalone conformance provider: no product code, accounts, hostname rules or database. */ +public final class IndependentProviderStub implements AutoCloseable { + final HttpServer server; + final String origin; + final Map challenges = new HashMap<>(), keys = new HashMap<>(), placements = new HashMap<>(); + JsonObject registration; volatile JsonObject lastHeartbeat; + volatile int failHeartbeats; + volatile long checkInMillis; + volatile int controlPolls; + final java.util.List events = new java.util.concurrent.CopyOnWriteArrayList<>(); + long generation, sequence; + volatile int registrations, heartbeats, acknowledgements; + volatile String challengeAuthorization; + volatile int challengeDifficulty = -1; + volatile JsonObject extensionMetadata; + volatile int extensionRequests, keyAcknowledgements; + boolean draining; + volatile JsonArray commands = new JsonArray(); + public IndependentProviderStub() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); origin = "http://127.0.0.1:" + server.getAddress().getPort(); + server.createContext("/", this::handle); server.start(); + } + private synchronized void handle(HttpExchange e) throws IOException { + int status = 200; JsonObject response; + try { response = dispatch(e); } catch (Failure f) { status = f.status; response = new JsonObject(); response.addProperty("code", f.getMessage()); } + catch (Exception f) { status = 400; response = new JsonObject(); response.addProperty("code", "invalid_request"); } + byte[] bytes = response.toString().getBytes(StandardCharsets.UTF_8); e.getResponseHeaders().add("content-type", "application/json"); e.sendResponseHeaders(status, bytes.length); try (OutputStream out = e.getResponseBody()) { out.write(bytes); } + } + private JsonObject dispatch(HttpExchange e) throws Exception { + String path = e.getRequestURI().getPath(), raw = new String(e.getRequestBody().readNBytes(65537), StandardCharsets.UTF_8); + if (raw.length() > 65536) throw new Failure(400, "payload_limit"); + JsonObject body = raw.isEmpty() ? new JsonObject() : JsonParser.parseString(raw).getAsJsonObject(); + if (path.equals("/.well-known/nethernet-external-signalling")) { + JsonObject d = new JsonObject(); d.addProperty("provider", origin); d.addProperty("controlOrigin", origin); + d.add("protocols", strings(ProviderCrypto.PROTOCOL)); d.add("signatures", strings(ProviderCrypto.SIGNATURE)); d.add("modes", strings("new-service", "attach-instance")); d.add("profiles", strings("nxs-admission-v1")); + JsonObject operations = new JsonObject(); for (String op : List.of("challenges", "complete", "recover", "activate", "heartbeat", "host-profile", "readiness", "control", "control/ack", "drain", "rotate", "retire", "ticket-keys", "ticket-keys/ack", "ticket-events", "events", "deregister")) operations.addProperty(op, origin + "/example/" + op); + if (extensionMetadata != null) d.add("extensions", extensionMetadata.deepCopy()); + d.add("operations", operations); JsonObject limits = new JsonObject(); limits.addProperty("heartbeatIntervalMs", 1000); if (checkInMillis > 0) limits.addProperty("checkInVersion", 1); limits.addProperty("maxControlPage", 100); limits.addProperty("leaseMs", 30000); limits.addProperty("maxBodyBytes", 65536); limits.addProperty("clockSkewMs", 60000); d.add("limits", limits); + JsonObject authorization = new JsonObject(); authorization.addProperty("header", "Authorization"); JsonArray schemes = new JsonArray(); + schemes.add(authorizationScheme("anonymous-proof-of-work", "new-service")); schemes.add(authorizationScheme("bearer-token", "new-service", "attach-instance")); authorization.add("schemes", schemes); d.add("authorization", authorization); return d; + } + if (path.equals("/example/challenges") || path.equals("/example/recover")) { + boolean recovery = path.endsWith("recover"); + if (recovery && (registration == null || !registration.get("registrationId").equals(body.get("registrationId")))) throw new Failure(403, "recovery_unavailable"); + JsonObject key = recovery ? keys.get(registration.get("keyId").getAsString()) : body.getAsJsonObject("publicKeyJwk"); + String authorization = recovery ? "recovery" : body.getAsJsonObject("authorization").get("scheme").getAsString(); + if (!recovery && body.get("mode").getAsString().equals("attach-instance") && !authorization.equals("bearer-token")) throw new Failure(403, "bearer_token_required"); + if (authorization.equals("bearer-token")) { + challengeAuthorization = e.getRequestHeaders().getFirst("Authorization"); + if (!"Bearer independent-provider-token".equals(challengeAuthorization)) throw new Failure(401, "invalid_bearer_token"); + } + JsonObject c = new JsonObject(); c.addProperty("protocol", ProviderCrypto.PROTOCOL); c.addProperty("signature", ProviderCrypto.SIGNATURE); c.addProperty("challengeId", UUID.randomUUID().toString()); c.addProperty("audience", origin); c.addProperty("nonce", UUID.randomUUID().toString()); c.addProperty("thumbprint", ProviderCrypto.thumbprint(key)); c.addProperty("expiresAt", System.currentTimeMillis() + 60000); c.addProperty("serverTime", System.currentTimeMillis()); + JsonObject context = new JsonObject(); for (String f : List.of("label", "authorizationId", "serviceId", "region", "pool", "registrationId")) context.addProperty(f, ""); context.addProperty("mode", recovery ? "recover" : body.get("mode").getAsString()); context.addProperty("profile", "nxs-admission-v1"); + if (!recovery && authorization.equals("bearer-token")) { context.addProperty("authorizationId", "independent-authority"); JsonObject selected = new JsonObject(); selected.addProperty("scheme", authorization); selected.addProperty("reference", "independent-authority"); c.add("authorization", selected); } + if (!recovery && body.has("placement")) { JsonObject placement = body.getAsJsonObject("placement"); context.add("region", placement.get("region")); context.add("pool", placement.get("pool")); if (placement.has("tags")) { Map tags = new TreeMap<>(); for (var tag : placement.getAsJsonObject("tags").entrySet()) tags.put(tag.getKey(), tag.getValue().getAsString()); context.addProperty("tagsDigest", ProviderCrypto.tagsDigest(tags)); } } + c.add("context", context); c.addProperty("contextDigest", ProviderCrypto.contextDigest(context)); JsonObject pow = new JsonObject(); pow.addProperty("algorithm", "sha256-leading-zero-bits-v0"); challengeDifficulty = recovery || authorization.equals("bearer-token") ? 0 : 2; pow.addProperty("difficulty", challengeDifficulty); c.add("pow", pow); + challenges.put(c.get("challengeId").getAsString(), c.deepCopy()); keys.put(c.get("challengeId").getAsString(), key); + if (!recovery && body.has("placement")) placements.put(c.get("challengeId").getAsString(), body.getAsJsonObject("placement").deepCopy()); + return c; + } + if (path.equals("/example/complete")) { + String id = body.get("challengeId").getAsString(); JsonObject c = challenges.get(id); + if (c == null) throw new Failure(409, "challenge_consumed"); + String proof = ProviderCrypto.proof(c, body.get("proofNonce").getAsString(), body.get("idempotencyKey").getAsString()); + if (!ProviderCrypto.verify(keys.get(id), body.get("signature").getAsString(), proof) || !ProviderCrypto.meetsDifficulty(ProviderCrypto.digest(proof), c.getAsJsonObject("pow").get("difficulty").getAsInt())) throw new Failure(401, "proof_invalid"); + challenges.remove(id); + if (c.getAsJsonObject("context").get("mode").getAsString().equals("recover")) { JsonObject r = registration.deepCopy(); r.remove("ticketKey"); r.addProperty("leaseGeneration", generation); return r; } + if (registration != null) throw new Failure(409, "already_registered"); registrations++; + registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", 0); registration.addProperty("leaseDeadline", 0); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); return registration.deepCopy(); + } + if (path.equals("/example/heartbeat") && failHeartbeats-- > 0) throw new Failure(503, "fixture_transient"); + authenticate(e, raw); + JsonObject ok = new JsonObject(); ok.addProperty("accepted", true); + switch (path) { + case "/example/activate" -> { generation++; sequence = 0; draining = false; ok.addProperty("leaseGeneration", generation); ok.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); } + case "/example/host-profile" -> ok.addProperty("revision", "example-profile-revision"); + case "/example/heartbeat" -> { if (draining) throw new Failure(403, "draining"); lastHeartbeat = body; heartbeats++; + if (checkInMillis > 0 && body.has("checkInVersion")) { + long now = System.currentTimeMillis(); JsonObject schedule = new JsonObject(); + schedule.addProperty("version", 1); schedule.addProperty("afterMillis", checkInMillis); + schedule.addProperty("nextCheckInAt", now + checkInMillis); schedule.addProperty("leaseExpiresAt", now + checkInMillis + 30000); + schedule.addProperty("minUpdateIntervalMillis", 1000); schedule.addProperty("controlPollAfterMillis", checkInMillis); + ok.add("checkIn", schedule); ok.addProperty("receivedAt", java.time.Instant.ofEpochMilli(now).toString()); + } } + case "/example/control" -> { controlPolls++; ok.add("commands", commands.deepCopy()); ok.addProperty("cursor", "example-cursor"); ok.addProperty("serverTime", java.time.Instant.now().toString()); } + case "/example/control/ack" -> { acknowledgements++; commands = new JsonArray(); } + case "/example/readiness" -> { ok.addProperty("routable", heartbeats > 0 && !draining); if (extensionMetadata != null) ok.add("extensions", extensionMetadata.deepCopy()); } + case "/example/extension" -> { extensionRequests++; } + case "/example/deregister" -> { draining = true; } + case "/example/drain" -> draining = true; + case "/example/ticket-keys" -> ok.add("ticketKey", ticket()); + case "/example/ticket-keys/ack" -> { keyAcknowledgements++; } + case "/example/ticket-events", "/example/events" -> { for (JsonElement event : body.getAsJsonArray("events")) events.add(event.getAsJsonObject()); } + case "/example/rotate" -> { + String old = e.getRequestHeaders().getFirst("nxs-key-id"), intent = e.getRequestHeaders().getFirst("idempotency-key"); JsonObject key = body.getAsJsonObject("publicKeyJwk"); + if (!ProviderCrypto.verify(key, body.get("proof").getAsString(), ProviderCrypto.array(ProviderCrypto.PROTOCOL, "rotate", origin, "example-machine-1", old, ProviderCrypto.thumbprint(key), generation, intent))) throw new Failure(401, "replacement_proof_invalid"); + String id = "example-key-" + UUID.randomUUID(); keys.put(id, key); registration.addProperty("keyId", id); ok.addProperty("keyId", id); + } + case "/example/retire" -> keys.remove(body.get("keyId").getAsString()); + default -> throw new Failure(422, "unknown_operation"); + } + return ok; + } + private void authenticate(HttpExchange e, String raw) throws Failure { + try { + Headers h = e.getRequestHeaders(); String key = h.getFirst("nxs-key-id"), instance = h.getFirst("nxs-instance-id"); long timestamp = Long.parseLong(h.getFirst("nxs-timestamp")), gen = Long.parseLong(h.getFirst("nxs-generation")), seq = Long.parseLong(h.getFirst("nxs-sequence")); + if (!ProviderCrypto.SIGNATURE.equals(h.getFirst("nxs-signature-version")) || !"example-machine-1".equals(instance) || Math.abs(System.currentTimeMillis() - timestamp) > 60000 || gen != generation || seq <= sequence) throw new Failure(401, "auth_invalid"); + if (!ProviderCrypto.verify(keys.get(key), h.getFirst("nxs-signature"), ProviderCrypto.request(origin, e.getRequestMethod(), e.getRequestURI().toASCIIString(), timestamp, instance, key, h.getFirst("idempotency-key"), gen, seq, raw))) throw new Failure(401, "signature_invalid"); sequence = seq; + } catch (RuntimeException f) { throw new Failure(401, "auth_invalid"); } + } + private static JsonArray strings(String... strings) { JsonArray a = new JsonArray(); for (String s : strings) a.add(s); return a; } + private static JsonObject authorizationScheme(String scheme, String... modes) { JsonObject value = new JsonObject(); value.addProperty("scheme", scheme); value.add("modes", strings(modes)); return value; } + private static JsonObject ticket() { JsonObject key = new JsonObject(); key.addProperty("keyId", "T001"); key.addProperty("secret", "independent-stub-only-secret-32-bytes-minimum"); return key; } + private static final class Failure extends Exception { final int status; Failure(int status, String message) { super(message); this.status = status; } } + @Override public void close() { server.stop(0); } + public static void main(String[] args) throws Exception { var stub = new IndependentProviderStub(); System.out.println(stub.origin); Runtime.getRuntime().addShutdownHook(new Thread(stub::close)); new CountDownLatch(1).await(); } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProtocolExtensionsTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProtocolExtensionsTest.java new file mode 100644 index 00000000..81801196 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProtocolExtensionsTest.java @@ -0,0 +1,34 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ProtocolExtensionsTest { + private static JsonObject document(String envelope) { + return JsonParser.parseString("{\"extensions\":{\"org.example.feature\":" + envelope + "}}").getAsJsonObject(); + } + @Test void ignoresUnknownOptionalVersionsWithoutGrantingCoreCapabilities() { + JsonObject input = document("{\"version\":17,\"critical\":false,\"data\":{\"anything\":true}}"); + assertDoesNotThrow(() -> ProtocolExtensions.validate(input)); + JsonObject copy = ProtocolExtensions.copy(input); copy.remove("org.example.feature"); + assertEquals(1, input.getAsJsonObject("extensions").size()); + } + @Test void rejectsRequiredOrMalformedExtensions() { + for (String envelope : new String[]{ + "{\"version\":1,\"critical\":true,\"data\":{}}", + "{\"version\":1.5,\"critical\":false,\"data\":{}}", + "{\"version\":1,\"critical\":\"false\",\"data\":{}}", + "{\"version\":1,\"critical\":false,\"data\":[]}"}) + assertThrows(IllegalArgumentException.class, () -> ProtocolExtensions.validate(document(envelope))); + } + @Test void boundsEncodedSizeAndCount() { + JsonObject value = document("{\"version\":1,\"critical\":false,\"data\":{}}"); + JsonObject extension = value.getAsJsonObject("extensions").getAsJsonObject("org.example.feature"); + extension.getAsJsonObject("data").addProperty("text", "x".repeat(ProtocolExtensions.MAX_BYTES)); + assertThrows(IllegalArgumentException.class, () -> ProtocolExtensions.validate(value)); + extension.getAsJsonObject("data").remove("text"); + for (int i = 0; i < 17; i++) value.getAsJsonObject("extensions").add("org.example.feature" + i, extension.deepCopy()); + assertThrows(IllegalArgumentException.class, () -> ProtocolExtensions.validate(value)); + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java new file mode 100644 index 00000000..bb6eb503 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java @@ -0,0 +1,48 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import java.net.URI; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; + +/** Loopback-only Java-to-Worker workflow. It intentionally proves no native gameplay. */ +public final class ProviderBench { + public static void main(String[] args) throws Exception { + URI provider = URI.create(System.getProperty("providerOrigin", "http://127.0.0.1:8787")); + if (!Set.of("127.0.0.1", "localhost", "[::1]").contains(provider.getHost())) throw new IllegalArgumentException("Fixture transport is loopback only"); + Path state = Path.of(System.getProperty("providerState")); + if (System.getProperty("providerMode", "once").equals("identity")) { try (var store = new ProviderStateStore(state)) { System.out.println(ProviderIdentity.initialize(store, provider)); } return; } + String token = System.getProperty("providerToken"); + ProviderTransport transport = new ProviderTransport() { + String keyId; + public CompletionStage installTicketKeys(List keys) { keyId = keys.getLast().keyId(); return CompletableFuture.completedFuture(null); } + public CompletionStage hostProfile() { + JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", keyId); p.addProperty("dtlsFingerprint", "sha-256 " + String.join(":", Collections.nCopies(32, "11"))); p.addProperty("sctpPort", 5000); p.addProperty("maxMessageSize", 262144); + JsonObject c = new JsonObject(); c.addProperty("address", "127.0.0.1"); c.addProperty("port", 19133); c.addProperty("foundation", "fixture"); c.addProperty("component", 1); c.addProperty("priority", 100); c.addProperty("protocol", "udp"); c.addProperty("type", "host"); JsonArray candidates = new JsonArray(); candidates.add(c); p.add("candidates", candidates); return CompletableFuture.completedFuture(p); + } + public CompletionStage applyControl(JsonObject c) { return CompletableFuture.completedFuture(ApplyResult.REJECTED); } + public List pollEvents() { return List.of(); } + public CompletionStage drain() { return CompletableFuture.completedFuture(null); } + public CompletionStage close() { return CompletableFuture.completedFuture(null); } + }; + String registrationMode = System.getProperty("providerRegistrationMode", token == null ? ProviderClient.NEW_SERVICE : ProviderClient.ATTACH_INSTANCE); + String authorization = token == null ? ProviderClient.ANONYMOUS_PROOF_OF_WORK : ProviderClient.BEARER_TOKEN; + Map tags = new TreeMap<>(); + JsonObject configuredTags = JsonParser.parseString(System.getProperty("providerTags", "{}")).getAsJsonObject(); + for (var entry : configuredTags.entrySet()) tags.put(entry.getKey(), entry.getValue().getAsString()); + String region = System.getProperty("providerRegion", registrationMode.equals(ProviderClient.ATTACH_INSTANCE) ? "EU" : null); + String pool = System.getProperty("providerPool", registrationMode.equals(ProviderClient.ATTACH_INSTANCE) ? "proxy" : null); + var config = new ProviderClient.Configuration(provider, "nxs-admission-v1", "Java conformance backend", registrationMode, authorization, token, region, pool, tags); + var client = new ProviderClient(config, new ProviderStateStore(state), transport, () -> new ServerStatus("Java bench", 1234, "fixture-only", "Fixture", 2, 50, 0), () -> new ProviderClient.Health(true, 100, 0.02, "nethernet", "java-conformance"), System.err::println); + try { + JsonObject registration = client.start().get(30, TimeUnit.SECONDS); + System.out.println("instance=" + registration.get("instanceId").getAsString() + " service=" + registration.get("serviceId").getAsString()); + System.out.println(client.readiness().get(10, TimeUnit.SECONDS)); + long hold = Long.parseLong(System.getProperty("providerHoldSeconds", "0")); + String stopFile = System.getProperty("providerStopFile"); + if (stopFile != null) { long until = System.currentTimeMillis() + 180000; while (!Files.exists(Path.of(stopFile)) && System.currentTimeMillis() < until) Thread.sleep(100); } + else if (hold > 0) Thread.sleep(hold * 1000); + } finally { client.stop().toCompletableFuture().get(20, TimeUnit.SECONDS); } + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java new file mode 100644 index 00000000..43d46382 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java @@ -0,0 +1,170 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.net.URI; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; + +class ProviderClientTest { + @Test void usesProviderNeutralBearerAuthorizationWithoutPowOrPersistingTheToken(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Hosted customer", + ProviderClient.NEW_SERVICE, ProviderClient.BEARER_TOKEN, "independent-provider-token", "EU", "customers", Map.of("plan", "premium")); + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path), new FakeTransport(), () -> null, + () -> new ProviderClient.Health(true, 10, 0, "nethernet", "fixture"), message -> {}); + try { + JsonObject registration = client.start().get(20, TimeUnit.SECONDS); + assertEquals("example-machine-1", registration.get("instanceId").getAsString()); + assertEquals("Bearer independent-provider-token", stub.challengeAuthorization); + assertEquals(0, stub.challengeDifficulty); + assertFalse(java.nio.file.Files.readString(path.resolve("provider-state.json")).contains("independent-provider-token")); + assertFalse(config.toString().contains("independent-provider-token")); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + + @Test void optionalExtensionsRemainOpaqueAndExplicit(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.extensionMetadata = JsonParser.parseString("{\"org.example.operator\":{\"version\":1,\"critical\":false,\"data\":{\"message\":\"optional\",\"operations\":{\"inspect\":\"" + stub.origin + "/example/extension\"}}}}").getAsJsonObject(); + ProviderClient client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"), + new ProviderStateStore(path), new FakeTransport(), () -> null, + () -> new ProviderClient.Health(true, 10, 0, "nethernet", "fixture"), message -> {}); + try { + JsonObject result = client.start().get(20, TimeUnit.SECONDS); + assertFalse(result.has("ticketKey")); + assertEquals(stub.extensionMetadata, result.getAsJsonObject("extensions")); + assertFalse(java.nio.file.Files.readString(path.resolve("provider-state.json")).contains("org.example.operator")); + assertEquals(0, stub.extensionRequests); + assertTrue(client.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); + client.extensionRequest("org.example.operator", "inspect", "POST", new JsonObject()).get(10, TimeUnit.SECONDS); + assertEquals(1, stub.extensionRequests); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + @Test void followsProviderScheduleWithoutIdlePollingAndWakesForPlayers(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.checkInMillis = 900000; + AtomicInteger players = new AtomicInteger(0); FakeTransport transport = new FakeTransport(); transport.stateless = true; + ProviderClient client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Scheduled"), + new ProviderStateStore(path), transport, () -> new ServerStatus("Scheduled", 1234, "fixture", "world", players.get(), 40, 0), + () -> new ProviderClient.Health(true, 40, players.get() / 40.0, "nethernet", "fixture"), message -> {}); + try { + client.start().get(20, TimeUnit.SECONDS); + eventually(() -> stub.controlPolls == 1); + Thread.sleep(2200); + assertEquals(1, stub.heartbeats); assertEquals(1, stub.controlPolls); + for (int i = 0; i < 100; i++) client.requestStatusRefresh(); + Thread.sleep(1200); assertEquals(1, stub.heartbeats, "Unchanged local refreshes must not send requests"); + stub.checkInMillis = 1000; players.set(1); client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 1); + int busyBefore = stub.heartbeats; + eventually(() -> stub.heartbeats > busyBefore && stub.controlPolls > 1); + stub.checkInMillis = 3600000; players.set(0); client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 0); + Thread.sleep(2200); int before = stub.heartbeats; int polls = stub.controlPolls; + Thread.sleep(2200); assertEquals(before, stub.heartbeats); assertEquals(polls, stub.controlPolls); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + assertTrue(stub.draining, "Orderly shutdown still reports drain while the timer is asleep"); + int beforeRestart = stub.heartbeats; + FakeTransport replacement = new FakeTransport(); replacement.stateless = true; + stub.checkInMillis = 900000; + ProviderClient resumed = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Scheduled"), + new ProviderStateStore(path), replacement, () -> new ServerStatus("Restarted", 1234, "fixture", "world", 0, 40, 0), + () -> new ProviderClient.Health(true, 40, 0, "nethernet", "fixture"), message -> {}); + try { + resumed.start().get(20, TimeUnit.SECONDS); + assertEquals(2, stub.generation); + assertEquals(beforeRestart + 1, stub.heartbeats, "Startup must publish immediately despite the previous one-hour schedule"); + assertEquals("Restarted", stub.lastHeartbeat.getAsJsonObject("serverStatus").get("name").getAsString()); + } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + + } + } + static final class FakeTransport implements ProviderTransport { + final CompletableFuture closed = new CompletableFuture<>(); + final java.util.Queue events = new java.util.concurrent.ConcurrentLinkedQueue<>(); + volatile int installed, applied, admissions, drains; + boolean stateless; + volatile ApplyResult result = ApplyResult.APPLIED; + public CompletionStage hostProfile() { JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", "T001"); p.addProperty("dtlsFingerprint", "fixture-native-profile"); if (stateless) p.add("statelessAdmission", new JsonObject()); return CompletableFuture.completedFuture(p); } + public CompletionStage installTicketKeys(List keys) { installed = keys.size(); return CompletableFuture.completedFuture(null); } + public CompletionStage applyControl(JsonObject c) { + applied++; String kind = c.get("kind").getAsString(); + if (kind.equals("join-admission")) admissions++; + if (kind.equals("drain")) drains++; + return CompletableFuture.completedFuture(kind.equals("join-admission") ? result : ApplyResult.APPLIED); + } + public List pollEvents() { List batch = new ArrayList<>(); for (JsonObject event; (event = events.poll()) != null;) batch.add(event); return batch; } + public CompletionStage drain() { return CompletableFuture.completedFuture(null); } + public CompletionStage close() { closed.complete(null); return closed; } + } + @Test void portableRegistrationRefreshRotationAndRestart(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + AtomicInteger players = new AtomicInteger(2); FakeTransport host = new FakeTransport(); + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"); + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path), host, () -> new ServerStatus("Example", 1234, "preview-fixture", "", players.get(), 40, 0), () -> new ProviderClient.Health(true, 100, 0.1, "nethernet", "fixture"), message -> {}); + JsonObject registration = client.start().get(20, TimeUnit.SECONDS); + assertEquals("example-machine-1", registration.get("instanceId").getAsString()); assertEquals(1, stub.registrations); assertEquals(1, host.installed); + assertEquals(100, stub.lastHeartbeat.get("capacity").getAsInt()); assertEquals(40, stub.lastHeartbeat.getAsJsonObject("serverStatus").get("maxPlayers").getAsInt()); + players.set(7); eventually(() -> stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 7); + client.setServerStatus(new ServerStatus("Renamed", 1234, "preview-fixture", "World", 8, 30, 2)); + eventually(() -> "Renamed".equals(stub.lastHeartbeat.getAsJsonObject("serverStatus").get("name").getAsString())); + assertTrue(client.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); + client.rotateMachineKey().get(10, TimeUnit.SECONDS); client.drain().get(10, TimeUnit.SECONDS); assertTrue(stub.draining); + client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); + FakeTransport restarted = new FakeTransport(); ProviderClient resumed = new ProviderClient(config, new ProviderStateStore(path), restarted, () -> new ServerStatus("Restarted", 1234, "preview-fixture", "", 1, 50, 1), () -> new ProviderClient.Health(true, 100, 0, "nethernet", "fixture"), message -> {}); + try { assertEquals("example-machine-1", resumed.start().get(20, TimeUnit.SECONDS).get("instanceId").getAsString()); assertEquals(1, stub.registrations); assertEquals(2, stub.generation); } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + private static void eventually(java.util.function.BooleanSupplier condition) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(8); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(40); + assertTrue(condition.getAsBoolean(), "Timed out waiting for provider lifecycle"); + } + @Test void failedRefreshRetriesAndRejectsPerJoinControlWithoutBlockingDrain(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + AtomicInteger players = new AtomicInteger(2); FakeTransport host = new FakeTransport(); + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"); + var health = (java.util.function.Supplier) () -> new ProviderClient.Health(true, 100, 0.1, "nethernet", "fixture"); + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path), host, () -> { + if (players.get() < 0) throw new IllegalStateException("query unavailable"); + return new ServerStatus("Example", 1234, "fixture", "", players.get(), 40, 0); + }, health, message -> {}); + client.start().get(20, TimeUnit.SECONDS); + players.set(-1); eventually(() -> !stub.lastHeartbeat.has("serverStatus")); + int before = stub.heartbeats; players.set(5); stub.failHeartbeats = 1; + for (int i = 0; i < 500; i++) client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.has("serverStatus") && stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 5); + assertTrue(stub.heartbeats - before <= 2, "Burst must coalesce within heartbeat cadence"); + JsonObject join = new JsonObject(); join.addProperty("kind", "join-admission"); + JsonObject unknown = new JsonObject(); unknown.addProperty("kind", "future-command"); + JsonObject drain = new JsonObject(); drain.addProperty("kind", "drain"); + JsonArray commands = new JsonArray(); commands.add(join); commands.add(unknown); commands.add(drain); stub.commands = commands; + eventually(() -> host.drains > 0); + assertEquals(0, host.admissions, "NXS never stages a join from provider control"); + assertEquals(0, stub.acknowledgements, "Unsupported control must not be silently acknowledged"); + JsonArray known = new JsonArray(); known.add(drain); stub.commands = known; + eventually(() -> stub.acknowledgements == 1); + client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); + } + } + + @Test void localPersistenceFailureStopsPublicationAndClosesTransport(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + FakeTransport host = new FakeTransport(); + var client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"), new ProviderStateStore(path), host, () -> null, () -> new ProviderClient.Health(true, 10, 0, "nethernet", "fixture"), message -> {}); + client.start().get(20, TimeUnit.SECONDS); + java.nio.file.Files.move(path.resolve("provider-state.json"), path.resolve("saved-state.json")); + java.nio.file.Files.createDirectory(path.resolve("provider-state.json")); + assertThrows(ExecutionException.class, () -> client.readiness().get(10, TimeUnit.SECONDS)); + client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); + assertTrue(host.closed.isDone()); + } + } + +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderInteropTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderInteropTest.java new file mode 100644 index 00000000..6f8bfdf4 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderInteropTest.java @@ -0,0 +1,32 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.security.*; +import java.security.spec.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProviderInteropTest { + static JsonObject fixture(String name) throws Exception { try (var in = ProviderInteropTest.class.getResourceAsStream("/" + name)) { return JsonParser.parseString(new String(Objects.requireNonNull(in).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); } } + @Test void verifiesJavaScriptCanonicalBytesAndRejectsMutations() throws Exception { + JsonObject f = fixture("nxs-v1.fixtures.json"), c = f.getAsJsonObject("challenge"), pub = f.getAsJsonObject("publicKeyJwk"); + assertEquals(c.get("thumbprint").getAsString(), ProviderCrypto.thumbprint(pub)); + assertEquals(c.get("contextDigest").getAsString(), ProviderCrypto.contextDigest(c.getAsJsonObject("context"))); + assertEquals(f.getAsJsonObject("proof").get("payload").getAsString(), ProviderCrypto.proof(c, f.get("proofNonce").getAsString(), f.get("idempotencyKey").getAsString())); + for (String field : List.of("proof", "request")) { JsonObject v = f.getAsJsonObject(field); assertTrue(ProviderCrypto.verify(pub, v.get("signature").getAsString(), v.get("payload").getAsString())); assertFalse(ProviderCrypto.verify(pub, v.get("signature").getAsString(), v.get("payload").getAsString() + " ")); } + JsonObject request = f.getAsJsonObject("request"), i = request.getAsJsonObject("input"); + assertEquals(request.get("payload").getAsString(), ProviderCrypto.request(i.get("audience").getAsString(), i.get("method").getAsString(), i.get("path").getAsString(), i.get("timestamp").getAsLong(), i.get("instanceId").getAsString(), i.get("keyId").getAsString(), i.get("idempotencyKey").getAsString(), i.get("generation").getAsLong(), i.get("sequence").getAsLong(), i.get("body").getAsString())); + JsonObject optional = pub.deepCopy(); optional.addProperty("ext", false); assertEquals(ProviderCrypto.thumbprint(pub), ProviderCrypto.thumbprint(optional)); + assertThrows(IllegalArgumentException.class, () -> ProviderCrypto.thumbprint(f.getAsJsonObject("privateKeyJwk"))); + KeyPair keys = ProviderCrypto.generate(); String signature = ProviderCrypto.sign(keys.getPrivate(), "test"); assertEquals(96, ProviderCrypto.decode(signature).length); assertTrue(ProviderCrypto.verify(ProviderCrypto.publicJwk(keys.getPublic()), signature, "test")); + Signature der = Signature.getInstance("SHA384withECDSA"); der.initSign(keys.getPrivate()); der.update("test".getBytes()); assertFalse(ProviderCrypto.verify(ProviderCrypto.publicJwk(keys.getPublic()), ProviderCrypto.base64(der.sign()), "test")); + } + @Test void durablePrivateStateAndDuplicateDirectoryFencing(@TempDir Path dir) throws Exception { + try (ProviderStateStore s = new ProviderStateStore(dir)) { JsonObject data = new JsonObject(); data.addProperty("privateKey", "fixture-only"); s.write(data); assertEquals(data, s.read()); assertThrows(java.io.IOException.class, () -> new ProviderStateStore(dir)); assertEquals("rw-------", java.nio.file.attribute.PosixFilePermissions.toString(Files.getPosixFilePermissions(dir.resolve("provider-state.json")))); } + try (ProviderStateStore s = new ProviderStateStore(dir)) { assertEquals("fixture-only", s.read().get("privateKey").getAsString()); } + } + @Test void exactStatusBounds() { assertThrows(IllegalArgumentException.class, () -> new ServerStatus("name", 0, "version", "", 0, 1, 0)); assertThrows(IllegalArgumentException.class, () -> new ServerStatus("name\n", 1, "version", "", 0, 1, 0)); assertDoesNotThrow(() -> new ServerStatus("name", 1, "version", "", 20, 1, 0)); } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java new file mode 100644 index 00000000..bb42aa8a --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java @@ -0,0 +1,98 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.net.URI; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +class ProviderJourneysTest { + private static ProviderClient client(IndependentProviderStub stub, Path directory, ProviderClient.Configuration configuration, + ProviderClientTest.FakeTransport transport) throws Exception { + return new ProviderClient(configuration, new ProviderStateStore(directory), transport, + () -> new ServerStatus("Independent host", 1000, "conformance", "world", 1, 20, 0), + () -> new ProviderClient.Health(true, 100, .01, "nethernet", "fixture"), message -> {}); + } + + @Test void allFourOperatorJourneysUseOneNeutralLifecycle(@TempDir Path directory) throws Exception { + String[] journeys = {"anonymous-standalone", "token-new-service", "token-fleet-attachment", "custom-host-provider"}; + for (String journey : journeys) { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + boolean bearer = !journey.equals("anonymous-standalone"); + boolean attach = journey.equals("token-fleet-attachment"); + var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", journey, + attach ? ProviderClient.ATTACH_INSTANCE : ProviderClient.NEW_SERVICE, + bearer ? ProviderClient.BEARER_TOKEN : ProviderClient.ANONYMOUS_PROOF_OF_WORK, + bearer ? "independent-provider-token" : null, attach ? "EU" : null, attach ? "proxy" : null, + attach ? Map.of("location", "london", "role", "proxy") : Map.of()); + var transport = new ProviderClientTest.FakeTransport(); + ProviderClient instance = client(stub, directory.resolve(journey), configuration, transport); + try { + JsonObject result = instance.start().get(20, TimeUnit.SECONDS); + assertEquals("nethernet-external-signalling-v1", result.get("protocol").getAsString()); + assertEquals("nxs-admission-v1", result.get("profile").getAsString()); + assertFalse(result.has("extensions"), "A provider needs no product extension"); + assertEquals(bearer ? 0 : 2, stub.challengeDifficulty); + assertTrue(stub.keyAcknowledgements > 0); + assertTrue(instance.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); + if (attach) assertEquals("london", result.getAsJsonObject("placement").getAsJsonObject("tags").get("location").getAsString()); + JsonObject event = new JsonObject(); event.addProperty("stage", "ticket.transport_established"); + event.addProperty("ticketId", "opaque-correlation"); event.addProperty("occurredAt", java.time.Instant.now().toString()); + event.addProperty("reason", "connected"); event.addProperty("privatePayload", "must-not-be-persisted"); transport.events.add(event); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (stub.events.isEmpty() && System.nanoTime() < deadline) Thread.sleep(25); + assertEquals(1, stub.events.size()); assertFalse(stub.events.getFirst().has("privatePayload")); + assertEquals(0, transport.admissions, "Control-plane delivery cannot stage individual clients"); + instance.deregister().get(10, TimeUnit.SECONDS); assertTrue(stub.draining); + } finally { instance.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + } + + @Test void profileMigrationPreservesDurableIdentityAndAssignedIds(@TempDir Path directory) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Migration"); + ProviderClient first = client(stub, directory, configuration, new ProviderClientTest.FakeTransport()); + JsonObject registration; + try { registration = first.start().get(20, TimeUnit.SECONDS); } + finally { first.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + JsonObject previous; + try (ProviderStateStore store = new ProviderStateStore(directory)) { + previous = store.read(); JsonObject legacy = previous.deepCopy(); + legacy.remove("protocol"); legacy.remove("profile"); + legacy.getAsJsonObject("registration").addProperty("protocol", "legacy-protocol-fixture"); + legacy.getAsJsonObject("registration").addProperty("profile", "legacy-profile-fixture"); + store.write(legacy); + } + ProviderClient resumed = client(stub, directory, configuration, new ProviderClientTest.FakeTransport()); + try { + JsonObject migrated = resumed.start().get(20, TimeUnit.SECONDS); + for (String field : new String[]{"instanceId", "serviceId", "registrationId", "keyId"}) + assertEquals(registration.get(field), migrated.get(field)); + assertEquals(1, stub.registrations); assertEquals(2, stub.generation); + } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + try (ProviderStateStore store = new ProviderStateStore(directory)) { + JsonObject current = store.read(); + assertEquals(previous.get("privateKey"), current.get("privateKey")); + assertEquals(previous.get("publicKeyJwk"), current.get("publicKeyJwk")); + assertEquals("nxs-admission-v1", current.get("profile").getAsString()); + } + } + } + + @Test void requiredUnknownExtensionFailsBeforeCredentialTransmission(@TempDir Path directory) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.extensionMetadata = JsonParser.parseString("{\"org.example.required\":{\"version\":1,\"critical\":true,\"data\":{}}}").getAsJsonObject(); + var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example", + ProviderClient.NEW_SERVICE, ProviderClient.BEARER_TOKEN, "independent-provider-token", null, null, Map.of()); + ProviderClient instance = client(stub, directory, configuration, new ProviderClientTest.FakeTransport()); + try { + assertThrows(java.util.concurrent.ExecutionException.class, () -> instance.start().get(20, TimeUnit.SECONDS)); + assertNull(stub.challengeAuthorization); assertEquals(0, stub.registrations); + } finally { instance.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } +} diff --git a/gradle.properties b/gradle.properties index ae232a43..a44c19ec 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,3 +15,7 @@ # # We follow JBoss versioning https://developer.jboss.org/docs/DOC-10725 version=1.1.0.CR1-SNAPSHOT + +# Temporary maintained native binding; final immutable revision is recorded before publication. +nativeJavaGroup=tel.schich +nativeJavaVersion=0.24.1.1 diff --git a/settings.gradle.kts b/settings.gradle.kts index b4fb593e..669f3c45 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,4 @@ plugins { include("transport-raknet") include("transport-nethernet") +include("external-signalling") From 9bf6674c43e6d59eba2805283e94b662d4b6c0ee Mon Sep 17 00:00:00 2001 From: Zulu Date: Sat, 5 Sep 2026 21:10:27 +0100 Subject: [PATCH 04/15] Implement bounded NXS stateless native host admission Port validated native admission and teardown work into the open profile, with new NXS1 cryptographic domains and independent fixtures. Validate STUN before peer allocation, preserve capacity through native teardown, deliver the first authenticated datagram, and separate bind from the explicit advertised candidate. Move the protocol-specific primitive probe downstream from the general JNI library. --- .../admission/NativeProviderTransport.java | 115 +++++++ .../StatelessAdmissionValidator.java | 106 +++++++ .../admission/AdmissionGateTest.java | 98 ++++++ .../admission/AdmissionPrimitiveProbe.java | 205 ++++++++++++ .../admission/NativeAdmissionBench.java | 107 +++++++ .../NativeAdmissionIntegrationTest.java | 294 ++++++++++++++++++ .../admission/ProviderNativeBench.java | 78 +++++ .../StatelessAdmissionValidatorTest.java | 119 +++++++ .../admission/TestSignallingProvider.java | 37 +++ transport-nethernet/build.gradle.kts | 3 +- .../nethernet/admission/AdmissionGate.java | 127 ++++++++ .../admission/AdmissionPrincipal.java | 8 + .../admission/AdmissionValidator.java | 8 + .../AdmittedNetherNetChildChannel.java | 164 ++++++++++ .../NativeAdmissionServerChannel.java | 184 +++++++++++ .../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 | 50 +++ .../admission/NetherNetFrameDecoderTest.java | 34 ++ 22 files changed, 1911 insertions(+), 1 deletion(-) create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionBench.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/TestSignallingProvider.java 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 diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java new file mode 100644 index 00000000..1ad5c258 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java @@ -0,0 +1,115 @@ +package org.cloudburstmc.netty.signalling.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.signalling.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.*; + +/** Profile adapter: background key/profile lifecycle only; no per-join metadata input is used. */ +public final class NativeProviderTransport implements ProviderTransport { + public static final String CAPABILITY = "nethernet.stateless-admission.v1"; + private record Epoch(String id, long notBefore, long retireAfter) {} + private final NativeAdmissionServerChannel channel; + private final StatelessAdmissionValidator validator; + private final String incarnation; + private final InetSocketAddress advertisedAddress; + private final ScheduledFuture retireTask; + private List epochs = List.of(); + private boolean draining, closed; + + private NativeProviderTransport(NativeAdmissionServerChannel channel, StatelessAdmissionValidator validator, String incarnation, InetSocketAddress advertisedAddress) { + this.channel = channel; this.validator = validator; this.incarnation = incarnation; + this.advertisedAddress = advertisedAddress; + 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) { + return open(bootstrap, bind, bind, certificate, privateKey, limits); + } + /** Explicit advertised candidate supports wildcard/local binds and operator-provisioned NAT mappings. */ + public static CompletionStage open(ServerBootstrap bootstrap, InetSocketAddress bind, InetSocketAddress advertised, Path certificate, Path privateKey, AdmissionGate.Limits limits) { + CompletableFuture result = new CompletableFuture<>(); + try { + if (advertised == null || advertised.isUnresolved() || advertised.getPort() == 0 || advertised.getAddress().isAnyLocalAddress()) + throw new IllegalArgumentException("Concrete advertised UDP address and fixed port required"); + 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, true); + bootstrap.clone().channelFactory(() -> endpoint).bind(bind).addListener(future -> { + if (future.isSuccess()) result.complete(new NativeProviderTransport(endpoint, validator, incarnation, advertised)); + 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 "nxs-stateless-host-v1/" + 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(); + // The provider 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 = advertisedAddress; + 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/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java new file mode 100644 index 00000000..b89728f3 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java @@ -0,0 +1,106 @@ +package org.cloudburstmc.netty.signalling.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.*; + +/** NXS1 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("nxs-stateless-aead-v1\0" + audience)), secret, key.notBefore(), key.retireAfter())); + } + Map previous = keys; keys = Map.copyOf(next); + 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(); } + + @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("NXS1")) 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("nxs-stateless-admission-v1\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); + if (sctp < 1 || max < 1 || max > 262144 || !remotePassword.matches("[A-Za-z0-9+/]{22,91}")) return null; + String localPassword = BASE64.encodeToString(Arrays.copyOf(hmac("HmacSHA256", key.secret(), utf8("nxs-stateless-ice-v1\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/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java new file mode 100644 index 00000000..077f128e --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java @@ -0,0 +1,98 @@ +package org.cloudburstmc.netty.signalling.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 pendingLimitWarningsAreAuthenticatedAggregatedAndRateLimited() throws Exception { + assertEquals(1024, AdmissionGate.Limits.defaults().pending()); + var trusted = validator().validate(valid, StunBinding.parse(valid), now); + var v = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); + v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); + var gate = new AdmissionGate(new AdmissionGate.Limits(2, 4, 1, 1000), v); + var work = new ArrayBlockingQueue(1); + var answer1 = TestSignallingProvider.answer(trusted.remoteDescription(), trusted.remoteFingerprint(), 49199, now + 30_000, TestSignallingProvider.AUDIENCE, false); + var answer2 = TestSignallingProvider.answer(trusted.remoteDescription(), trusted.remoteFingerprint(), 49199, now + 30_000, TestSignallingProvider.AUDIENCE, false); + byte[] packet1 = binding(answer1.token() + ":" + remote, answer1.password()); + byte[] packet2 = binding(answer2.token() + ":" + remote, answer2.password()); + gate.ingress(packet1, first, now, 0, work::add); var r = work.remove(); + byte[] invalid = binding(answer2.token() + ":" + remote, "wrong-password-000000000000"); + assertFalse(gate.ingress(invalid, other, now, 0, work::add)); + assertNull(gate.pollPendingLimitWarning(0)); + for (int i = 0; i < 3; i++) assertFalse(gate.ingress(packet2, other, now, 0, work::add)); + assertEquals(new AdmissionGate.PendingLimitWarning(1, 1, 3), gate.pollPendingLimitWarning(0)); + assertTrue(work.isEmpty()); assertEquals(1, gate.stats().claims()); + for (int i = 0; i < 2; i++) assertFalse(gate.ingress(packet2, other, now, 0, work::add)); + assertNull(gate.pollPendingLimitWarning(4_999_999_999L)); + assertArrayEquals(packet1, gate.ready(r)); + // A short burst must still be reported even after the queue has drained. + assertEquals(new AdmissionGate.PendingLimitWarning(1, 1, 2), gate.pollPendingLimitWarning(5_000_000_000L)); + assertNull(gate.pollPendingLimitWarning(10_000_000_000L)); + assertFalse(gate.ingress(packet2, other, now, 0, work::add)); + assertEquals(1, work.size()); assertEquals(1, gate.stats().pending()); + assertEquals(5, gate.stats().capacityRejected()); + } + @Test void firstAuthenticatedPacketIsOwnedDeferredAndTransferredOnlyOnce() { + var gate = gate(); var work = new ArrayBlockingQueue(1); + byte[] input = valid.clone(); + assertFalse(gate.ingress(input, first, now, 0, work::add)); + var r = work.remove(); Arrays.fill(input, (byte)0); + for (int i = 0; i < 10; i++) assertFalse(gate.ingress(valid, first, now, 0, work::add)); + assertTrue(work.isEmpty()); assertEquals(1, gate.stats().pending()); + assertArrayEquals(valid, gate.ready(r)); assertEquals(0, gate.stats().pending()); + assertNull(gate.ready(r)); // duplicate readiness cannot replay again + } + @Test void closedAndTimedOutReservationsNeverReleaseDeferredPackets() { + var gate = gate(); var work = new ArrayBlockingQueue(1); + gate.ingress(valid, first, now, 0, work::add); var r = work.remove(); + gate.close(); + assertNull(gate.ready(r)); assertNull(gate.admission(r)); + gate = gate(); var timed = gate; + timed.ingress(valid, first, now, 0, work::add); r = work.remove(); + timed.sweep(now + 1000, 1_000_000_000L); + assertNull(timed.ready(r)); + } + @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()); assertArrayEquals(valid, 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)); + assertNull(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/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java new file mode 100644 index 00000000..47aa9ae9 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java @@ -0,0 +1,205 @@ +package org.cloudburstmc.netty.signalling.admission; + +import tel.schich.libdatachannel.*; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.net.*; +import java.nio.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.*; +import java.security.cert.CertificateFactory; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +/** Bounded feasibility probe, not the production NXS policy implementation. */ +public final class AdmissionPrimitiveProbe { + static final String AUDIENCE="nxs-stateless-host-v1/0123456789abcdef0123456789abcdef"; + static final String SECRET="stateless-fixture-secret-32-bytes-minimum"; + static final String HEADER="NXS1K001"; + static final Base64.Encoder B64=Base64.getEncoder().withoutPadding(); + static final InetAddress LOOPBACK=InetAddress.getLoopbackAddress(); + static final int PORT=49184; + static byte[] bytes(String s) { return s.getBytes(StandardCharsets.UTF_8); } + static String field(String sdp,String name) { + return sdp.lines().filter(x->x.startsWith("a="+name+":")).findFirst().orElseThrow().substring(name.length()+3).trim(); + } + static byte[] hmac(String algorithm,byte[] key,byte[] input) throws Exception { + Mac mac=Mac.getInstance(algorithm); mac.init(new SecretKeySpec(key,algorithm)); return mac.doFinal(input); + } + static byte[] encryptionKey() throws Exception { + return hmac("HmacSHA256",bytes(SECRET),bytes("nxs-stateless-aead-v1\0"+AUDIENCE)); + } + static String password(String token) throws Exception { + return B64.encodeToString(Arrays.copyOf(hmac("HmacSHA256",bytes(SECRET),bytes("nxs-stateless-ice-v1\0"+AUDIENCE+"\0"+token)),24)); + } + static byte[] aad(String clientUfrag) { return bytes("nxs-stateless-admission-v1\0"+HEADER+"\0"+AUDIENCE+"\0"+clientUfrag); } + // Signalling side only. Host receives NONE of these arguments out of band. + static String mint(String offer,int passwordLength,boolean wrongFingerprint) throws Exception { + String pwd=field(offer,"ice-pwd"); + check(pwd.length()==passwordLength,"client password length"); + byte[] fingerprint=HexFormat.of().parseHex(field(offer,"fingerprint").substring(8).replace(":","")); + if(wrongFingerprint) fingerprint[0]^=1; + ByteBuffer plain=ByteBuffer.allocate(67+pwd.length()); + plain.putInt((int)(System.currentTimeMillis()/1000+30)).put(fingerprint).putShort((short)5000).putInt(262144); + plain.put(new byte[16]).putLong(42).put((byte)pwd.length()).put(bytes(pwd)); + byte[] nonce=new byte[12]; new SecureRandom().nextBytes(nonce); + Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(encryptionKey(),"AES"),new GCMParameterSpec(128,nonce)); + cipher.updateAAD(aad(field(offer,"ice-ufrag"))); + byte[] encrypted=cipher.doFinal(plain.array()); Arrays.fill(plain.array(),(byte)0); + return HEADER+B64.encodeToString(ByteBuffer.allocate(12+encrypted.length).put(nonce).put(encrypted).array()); + } + record Admission(String token,String clientUfrag,String clientPassword,String fingerprint,int sctp,int max,String tuple,long firstValidNanos) { + @Override public String toString() { return "Admission[redacted]"; } + String offer() { + 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:"+clientUfrag+"\r\na=ice-pwd:"+clientPassword+"\r\na=fingerprint:sha-256 "+fingerprint+ + "\r\na=sctp-port:"+sctp+"\r\na=max-message-size:"+max+"\r\n"; + } + } + // Host uses only the packet plus background key/profile. No offer or client cache input. + static Admission validate(byte[] packet,String tuple) throws Exception { + 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) return null; + String username=null; int integrity=-1; + for(int i=20;ipacket.length) return null; + int type=Short.toUnsignedInt(b.getShort(i)), len=Short.toUnsignedInt(b.getShort(i+2)); + if(i+4+len>packet.length) return null; + if(type==6) { if(username!=null || integrity!=-1) return null; username=new String(packet,i+4,len,StandardCharsets.US_ASCII); } + if(type==8) { if(integrity!=-1 || len!=20 || username==null) return null; integrity=i; } + i+=4+((len+3)&~3); if(i>packet.length) return null; + } + if(username==null || integrity<0) return null; + String[] names=username.split(":",-1); + if(names.length!=2 || names[0].length()>256 || !names[0].startsWith(HEADER) || !names[1].matches("[A-Za-z0-9+/]{4,256}")) return null; + byte[] envelope=Base64.getDecoder().decode(names[0].substring(8)); + if(envelope.length<117 || !B64.encodeToString(envelope).equals(names[0].substring(8))) return null; + Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE,new SecretKeySpec(encryptionKey(),"AES"),new GCMParameterSpec(128,Arrays.copyOf(envelope,12))); + cipher.updateAAD(aad(names[1])); + byte[] raw=cipher.doFinal(Arrays.copyOfRange(envelope,12,envelope.length)); + try { + ByteBuffer plain=ByteBuffer.wrap(raw); + long expiry=Integer.toUnsignedLong(plain.getInt())*1000; + if(expiry<=System.currentTimeMillis() || expiry>System.currentTimeMillis()+60000) return null; + byte[] fp=new byte[32]; plain.get(fp); + int sctp=Short.toUnsignedInt(plain.getShort()), max=plain.getInt(); + plain.position(66); int len=Byte.toUnsignedInt(plain.get()); + if(len<22 || len>91 || plain.remaining()!=len || sctp==0 || max<1 || max>262144) return null; + byte[] pwd=new byte[len];plain.get(pwd); + String remotePassword=new String(pwd,StandardCharsets.US_ASCII);Arrays.fill(pwd,(byte)0); + if(!remotePassword.matches("[A-Za-z0-9+/]{22,91}")) return null; + byte[] signed=Arrays.copyOf(packet,integrity); + ByteBuffer.wrap(signed).putShort(2,(short)(integrity+24-20)); + byte[] expected=hmac("HmacSHA1",bytes(password(names[0])),signed); + if(!MessageDigest.isEqual(expected,Arrays.copyOfRange(packet,integrity+4,integrity+24))) return null; + return new Admission(names[0],names[1],remotePassword,HexFormat.ofDelimiter(":").withUpperCase().formatHex(fp),sctp,max,tuple,System.nanoTime()); + } finally {Arrays.fill(raw,(byte)0);} + } + static void check(boolean ok,String message) { if(!ok) throw new AssertionError(message); } + public static void main(String[] args) throws Exception { + Path certificate=Path.of(args[0]), key=Path.of(args[1]); + byte[] der; + try(var input=Files.newInputStream(certificate)) { der=CertificateFactory.getInstance("X.509").generateCertificate(input).getEncoded(); } + String hostFingerprint=HexFormat.ofDelimiter(":").withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(der)); + for(int passwordLength:new int[]{24,32,91}) run(certificate,key,hostFingerprint,passwordLength,false); + run(certificate,key,hostFingerprint,24,true); + } + static void run(Path certificate,Path key,String hostFingerprint,int passwordLength,boolean wrongFingerprint) throws Exception { + ArrayBlockingQueue work=new ArrayBlockingQueue<>(4); + Set approved=ConcurrentHashMap.newKeySet(), claimed=ConcurrentHashMap.newKeySet(); + AtomicInteger rejected=new AtomicInteger(),created=new AtomicInteger(),rawPackets=new AtomicInteger(); + AtomicReference failure=new AtomicReference<>(); + AtomicReference initialPacket=new AtomicReference<>(); + AtomicInteger initialPort=new AtomicInteger(); + List hosts=new ArrayList<>(); + CountDownLatch messages=new CountDownLatch(2), opened=new CountDownLatch(2); + AtomicInteger channelMask=new AtomicInteger(), callbackCloseGuards=new AtomicInteger(); + CountDownLatch hostFailed=new CountDownLatch(1); + try(RawUdpMuxListener mux=new RawUdpMuxListener(LOOPBACK,PORT,(packet,address,port)->{ + rawPackets.incrementAndGet(); String tuple=address+":"+port; + if(approved.contains(tuple)) return true; + try { + Admission admission=validate(packet,tuple); + if(admission==null) {rejected.incrementAndGet();return false;} + if(claimed.add(admission.token())) { + initialPacket.set(packet); initialPort.set(port); + check(work.offer(admission),"bounded creation queue"); + } + } catch(Exception error) {rejected.incrementAndGet();} + return false; + });PeerConnection client=PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(LOOPBACK))) { + long baselineNativeAttempts=PeerConnection.nativeCreationAttempts(); + check(mux.stats()[2]==0,"host has zero agents before any client packet"); + try(DatagramSocket invalid=new DatagramSocket()) { + byte[] noise=new byte[40];invalid.send(new DatagramPacket(noise,noise.length,LOOPBACK,PORT)); + for(int i=0;i<100 && rejected.get()==0;i++) Thread.sleep(5); + check(rejected.get()>0 && mux.stats()[2]==0 && mux.stats()[3]==0,"invalid datagram created no native state"); + } + List clientChannels=new ArrayList<>(); + for(int channel=0;channel<2;channel++) { + String label=channel==0?"ReliableDataChannel":"UnreliableDataChannel"; + var init=DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(channel==1,channel==1,0,0)); + var dc=client.createDataChannel(label,init);clientChannels.add(dc); + dc.onOpen.register(d->{ + try { client.closeAndAwait(java.time.Duration.ofMillis(1)); failure.set(new AssertionError("teardown wait must reject callback context")); } + catch(IllegalStateException expected) { callbackCloseGuards.incrementAndGet(); } + opened.countDown();ByteBuffer message=ByteBuffer.allocateDirect(2);message.put((byte)0).put((byte)(label.startsWith("Reliable")?1:2)).flip();d.sendMessage(message);}); + } + client.setLocalDescription("offer","clientFixtureUf","p".repeat(passwordLength)); + String token=mint(client.localDescription(),passwordLength,wrongFingerprint); + check(token.length()==8+(int)Math.ceil((95+passwordLength)*4.0/3),"token byte budget"); + // NXS-generated answer: never obtained from a native server peer. + String answer="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(token)+"\r\na=fingerprint:sha-256 "+hostFingerprint+ + "\r\na=sctp-port:5000\r\na=max-message-size:262144\r\na=candidate:1 1 UDP 2130706431 127.0.0.1 "+PORT+" typ host\r\na=end-of-candidates\r\n"; + client.setRemoteDescription(answer,SessionDescriptionType.ANSWER); + Admission admitted=work.poll(10,TimeUnit.SECONDS);check(admitted!=null,"valid raw STUN reaches endpoint without control delivery"); + check(mux.stats()[2]==0 && PeerConnection.nativeCreationAttempts()==baselineNativeAttempts,"token validation precedes all native creation attempts"); + PeerConnection host=PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(LOOPBACK) + .withEnableIceUdpMux(true).withPortRangeBegin((short)PORT).withPortRangeEnd((short)PORT),Runnable::run,certificate,key); + hosts.add(host);created.incrementAndGet(); + check(PeerConnection.nativeCreationAttempts()==baselineNativeAttempts+1,"exactly one native creation attempt"); + check(System.nanoTime()>admitted.firstValidNanos(),"monotonic validation before creation"); + host.onStateChange.register((p,state)->{if(state==PeerState.RTC_FAILED) hostFailed.countDown();}); + host.onDataChannel.register((p,dc)->{ + String label=dc.label();int bit=label.equals("ReliableDataChannel")?1:label.equals("UnreliableDataChannel")?2:0; + if(bit==0){failure.set(new AssertionError("unexpected label"));return;} + channelMask.getAndUpdate(mask->mask|bit); + dc.onMessage.register(DataChannelCallback.Message.handleBinary((d,buffer)->{ + try {check(buffer.remaining()==2 && buffer.get()==0 && buffer.get()==bit,"channel identity and payload");messages.countDown();} + catch(Throwable error){failure.set(error);} + })); + }); + host.setRemoteDescription(admitted.offer(),SessionDescriptionType.OFFER); + host.setLocalDescription("answer",admitted.token(),password(admitted.token())); + check(field(host.localDescription(),"fingerprint").equals("sha-256 "+hostFingerprint),"published native certificate identity"); + check(field(host.localDescription(),"ice-ufrag").equals(token),"native did not truncate token"); + approved.add(admitted.tuple()); + mux.replay(initialPacket.getAndSet(null),LOOPBACK,initialPort.get()); + if(wrongFingerprint) { + check(hostFailed.await(15,TimeUnit.SECONDS),"DTLS rejects authenticated token with wrong client fingerprint"); + check(channelMask.get()==0 && opened.getCount()==2,"wrong certificate opens no channels"); + System.out.println("native-spike PASS wrongClientFingerprint=dtls-rejected channels=0 perJoinControl=0"); + for(PeerConnection peer:hosts) check(peer.closeAndAwait(java.time.Duration.ofSeconds(5)),"native teardown completes before releasing capacity");hosts.clear(); + return; + } + check(opened.await(10,TimeUnit.SECONDS),"both client channels open"); + check(messages.await(10,TimeUnit.SECONDS),"both channels deliver distinct binary messages"); + check(failure.get()==null && callbackCloseGuards.get()==2,"native callbacks completed without failure and cannot wait on themselves"); + check(created.get()==1 && work.isEmpty() && channelMask.get()==3,"one lazy peer and both channels"); + long[] stats=mux.stats();check(stats[2]==1 && stats[3]==1,"one fixed-port agent and tuple"); + System.out.println("native-spike PASS ufragChars="+token.length()+" passwordBytes="+passwordLength+" hostPeers="+created.get()+" rawPackets="+rawPackets.get()+" channels=3 perJoinControl=0"); + for(PeerConnection peer:hosts) check(peer.closeAndAwait(java.time.Duration.ofSeconds(5)),"native teardown completes before releasing capacity");hosts.clear(); + } finally {for(PeerConnection peer:hosts) check(peer.closeAndAwait(java.time.Duration.ofSeconds(5)),"native teardown completes before releasing capacity");} + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionBench.java new file mode 100644 index 00000000..c37b387f --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionBench.java @@ -0,0 +1,107 @@ +package org.cloudburstmc.netty.signalling.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.signalling.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(), "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); + 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 NativeProviderTransport.open(bootstrap, bind, bind, + id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get()); + host = NativeProviderTransport.open(bootstrap, bind, advertised, id.certificate(), id.privateKey(), + AdmissionGate.Limits.defaults()).toCompletableFuture().get(10, TimeUnit.SECONDS); + host.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey( + "K001", TestSignallingProvider.SECRET, 0, Long.MAX_VALUE))).toCompletableFuture().get(); + var profile = host.hostProfile().toCompletableFuture().get(); + var candidate = profile.getAsJsonArray("candidates").get(0).getAsJsonObject(); + assertEquals("127.0.0.1", candidate.get("address").getAsString()); + assertEquals(49189, candidate.get("port").getAsInt()); + assertEquals("nethernet.stateless-admission.v1", profile.getAsJsonObject("statelessAdmission").get("capability").getAsString()); + try (var socket = new DatagramSocket()) { + byte[] packet = new byte[40]; socket.send(new DatagramPacket(packet, packet.length, advertised)); + } + var endpoint = host.channel(); await(() -> endpoint.admissionStats().invalid() > 0); + assertEquals(0, endpoint.creationAttempts()); + } finally { + if (host != null) host.close().toCompletableFuture().get(10, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + try (var socket = new DatagramSocket(bind)) { assertEquals(49189, socket.getLocalPort()); } + } + @Test @Timeout(20) void firstAuthenticatedDatagramGetsMatchingResponseWithoutRetry() throws Exception { + var id = identity(); var loopback = InetAddress.getByName("127.0.0.1"); int port = 49199; + var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); + var group = new DefaultEventLoopGroup(1); + var endpoint = new NativeAdmissionServerChannel(id, validator, new AdmissionGate.Limits(2, 4, 1, 10_000)); + try (var socket = new DatagramSocket(new InetSocketAddress(loopback, 0)); + var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(loopback), Runnable::run)) { + new ServerBootstrap().group(group).channelFactory(() -> endpoint) + .childHandler(new ChannelInboundHandlerAdapter()).bind(loopback, port).sync(); + client.createDataChannel("ReliableDataChannel"); + client.setLocalDescription("offer", "singleCheckClient", "p".repeat(32)); + var answer = TestSignallingProvider.answer(client.localDescription(), id.fingerprint(), port, + System.currentTimeMillis() + 30_000, TestSignallingProvider.AUDIENCE, false); + // Never apply the answer to the native client: only this socket sends one check. + byte[] request = nominatedBinding(answer.token() + ":singleCheckClient", answer.password()); + socket.setSoTimeout(2000); + long started = System.nanoTime(); + socket.send(new DatagramPacket(request, request.length, loopback, port)); + byte[] bytes = new byte[2048]; var response = new DatagramPacket(bytes, bytes.length); + socket.receive(response); + double elapsedMs = (System.nanoTime() - started) / 1_000_000.0; + assertEquals(loopback, response.getAddress()); assertEquals(port, response.getPort()); + assertEquals(0x0101, Short.toUnsignedInt(ByteBuffer.wrap(bytes).getShort())); + assertArrayEquals(Arrays.copyOfRange(request, 8, 20), Arrays.copyOfRange(bytes, 8, 20)); + assertEquals(1, endpoint.creationAttempts()); assertEquals(1, endpoint.nativeStats()[3]); + System.out.printf(Locale.ROOT, "first-stun PASS requestsSent=1 matchingSuccess=true responseMs=%.3f%n", elapsedMs); + } finally { + endpoint.close().awaitUninterruptibly(); endpoint.termination().toCompletableFuture().get(6, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } + private static byte[] nominatedBinding(String username, String password) throws Exception { + byte[] minimal = AdmissionFixture.binding(username, password); + int integrityOffset = minimal.length - 24; + ByteBuffer packet = ByteBuffer.allocate(minimal.length + 24); + packet.put(minimal, 0, integrityOffset); + packet.putShort((short)0x24).putShort((short)4).putInt(1853693695); + packet.putShort((short)0x802a).putShort((short)8).putLong(42); + packet.putShort((short)0x25).putShort((short)0); + int signedLength = packet.position(); + packet.putShort((short)8).putShort((short)20); + packet.putShort(2, (short)(packet.capacity() - 20)); + byte[] transaction = new byte[12]; new java.security.SecureRandom().nextBytes(transaction); + System.arraycopy(transaction, 0, packet.array(), 8, transaction.length); + var mac = javax.crypto.Mac.getInstance("HmacSHA1"); + mac.init(new javax.crypto.spec.SecretKeySpec(password.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA1")); + packet.put(mac.doFinal(Arrays.copyOf(packet.array(), signedLength))); + return packet.array(); + } + @Test @Timeout(45) void noControlLazyJoinBothChannelsReplayAndCleanup() throws Exception { + var id = identity(); var loopback = InetAddress.getByName("127.0.0.1"); int port = 49190; + var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE,60_000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001",TestSignallingProvider.SECRET))); + var group = new DefaultEventLoopGroup(2); + var rakGroup = new NioEventLoopGroup(1); + Channel rak = new ServerBootstrap().group(rakGroup).channelFactory(RakChannelFactory.server(NioDatagramChannel.class)) + .childHandler(new ChannelInboundHandlerAdapter()).bind("127.0.0.1",49191).sync().channel(); + var endpoint = new NativeAdmissionServerChannel(id,validator,new AdmissionGate.Limits(4,8,2,10_000)); + AtomicInteger inboundMask = new AtomicInteger(); AtomicReference 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(); } + } + @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.signalling.ProviderTransport.TicketKey("K001",TestSignallingProvider.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.signalling.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.signalling.ProviderTransport.TicketKey("K001",TestSignallingProvider.SECRET,0,System.currentTimeMillis()+60_000), + new org.cloudburstmc.netty.signalling.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.signalling.ProviderTransport.TicketKey("K001",TestSignallingProvider.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(); } + } + + @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(TestSignallingProvider.AUDIENCE,60000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001",TestSignallingProvider.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(TestSignallingProvider.answer(client.localDescription(),id.fingerprint(),49198,System.currentTimeMillis()+30000,TestSignallingProvider.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(); + } + } + +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java new file mode 100644 index 00000000..d62e952f --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java @@ -0,0 +1,78 @@ +package org.cloudburstmc.netty.signalling.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.signalling.*; +import tel.schich.libdatachannel.PeerConnection; +import java.net.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** Real provider/native composition; loopback fixture identity, never gameplay evidence. */ +public final class ProviderNativeBench { + 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(event); System.out.flush(); + } + public static void main(String[] args) throws Exception { + URI origin = URI.create(args[0]); + if (!Set.of("localhost", "127.0.0.1", "[::1]").contains(origin.getHost())) throw new IllegalArgumentException("Loopback bench only"); + Path state = Path.of(args[1]), stop = Path.of(args[3]); + int port = Integer.parseInt(args[2]); + var group = new DefaultEventLoopGroup(2); + AtomicInteger delivered = new AtomicInteger(); + 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)); + } + }); + } + }); + NativeProviderTransport nativeHost = null; ProviderClient provider = null; + try { + nativeHost = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1", port), + state.resolve("host-cert.pem"), state.resolve("host-key.pem"), new AdmissionGate.Limits(4, 8, 2, 10_000)).toCompletableFuture().get(10, TimeUnit.SECONDS); + provider = new ProviderClient(new ProviderClient.Configuration(origin, "nxs-admission-v1", "Provider native integration"), + new ProviderStateStore(state), nativeHost, + () -> new ServerStatus("Automatic native server", 1234, "fixture-only", "Integration", 0, 4, 0), + () -> new ProviderClient.Health(true, 4, 0, "nethernet", "provider-native-bench"), System.err::println); + JsonObject registration = provider.start().get(45, TimeUnit.SECONDS); + // Emit assigned IDs only; optional metadata and credentials are excluded. + emit("registered", Map.of("serviceId", registration.get("serviceId").getAsString(), "instanceId", registration.get("instanceId").getAsString())); + emit("profile", nativeHost.hostProfile().toCompletableFuture().get()); + emit("readiness", provider.readiness().get(10, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(3); + boolean updated = false; + while (!Files.exists(stop)) { + if (System.nanoTime() > deadline) throw new IllegalStateException("Provider native bench deadline"); + if (!updated && Files.exists(state.resolve("update-status"))) { + provider.setServerStatus(new ServerStatus("Updated native server", 1235, "fixture-updated", "Updated level", 1, 8, 1)); updated = true; + } + var endpoint = nativeHost.channel(); + emit("stats", Map.of("admission", endpoint.admissionStats(), "native", endpoint.nativeStats(), "nativeCreationAttempts", PeerConnection.nativeCreationAttempts(), "hostCreations", endpoint.creationAttempts(), "deliveredChannels", delivered.get())); + Thread.sleep(100); + } + provider.stop().toCompletableFuture().get(20, TimeUnit.SECONDS); provider = null; + try (var reuse = new DatagramSocket(new InetSocketAddress("127.0.0.1", port))) { + emit("closed", Map.of("udpReleased", reuse.getLocalPort() == port)); + } + } finally { + if (provider != null) provider.stop().toCompletableFuture().get(20, TimeUnit.SECONDS); + if (nativeHost != null) nativeHost.close().toCompletableFuture().get(10, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java new file mode 100644 index 00000000..75c4a94d --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java @@ -0,0 +1,119 @@ +package org.cloudburstmc.netty.signalling.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("/nxs/" + name)), StandardCharsets.UTF_8)) { + return JsonParser.parseReader(in).getAsJsonObject(); + } catch (Exception e) { throw new AssertionError(e); } + } + final JsonObject f = fixture("stateless-admission-v1.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 canonicalJavaScriptTokenAndPacketIntegrityAgree() { + 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("callerContextHashHex").getAsString(), a.callerContextHash()); + 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"); + assertEquals("urn:nethernet:external-signalling:v1", provenance.get("specification").getAsString()); + for (var entry : provenance.getAsJsonObject("files").entrySet()) { + try (var in = Objects.requireNonNull(getClass().getResourceAsStream("/nxs/" + 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/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/TestSignallingProvider.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/TestSignallingProvider.java new file mode 100644 index 00000000..20453d2b --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/TestSignallingProvider.java @@ -0,0 +1,37 @@ +package org.cloudburstmc.netty.signalling.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 TestSignallingProvider { + 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),"nxs-stateless-aead-v1\0"+audience),"AES"),new GCMParameterSpec(128,nonce)); + cipher.updateAAD(utf8("nxs-stateless-admission-v1\0NXS1K001\0"+audience+"\0"+ufrag)); + byte[] sealed = cipher.doFinal(claims.array());Arrays.fill(claims.array(),(byte)0); + var base64 = Base64.getEncoder().withoutPadding(); + String token = "NXS1K001" + base64.encodeToString(ByteBuffer.allocate(12+sealed.length).put(nonce).put(sealed).array()); + String password = base64.encodeToString(Arrays.copyOf(hmac(utf8(SECRET),"nxs-stateless-ice-v1\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/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts index 144f8910..6727bc69 100644 --- a/transport-nethernet/build.gradle.kts +++ b/transport-nethernet/build.gradle.kts @@ -4,7 +4,8 @@ dependencies { api(libs.bundles.netty) api(libs.netty.codec.http) api(libs.expiringmap) - api(libs.libdatachannel.java) + api("${rootProject.property("nativeJavaGroup")}:libdatachannel-java:${rootProject.property("nativeJavaVersion")}") + testRuntimeOnly("${rootProject.property("nativeJavaGroup")}:libdatachannel-java:${rootProject.property("nativeJavaVersion")}: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..3fca0fc2 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionGate.java @@ -0,0 +1,127 @@ +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, 1024, 15_000); } + } + public static final class Reservation { + private VerifiedAdmission admission; + private byte[] initialPacket; + 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, byte[] packet) { + this.admission = admission; this.tokenId = admission.tokenId(); this.tuple = tuple; + this.expiresAt = admission.expiresAt(); this.acceptedNanos = nanos; + this.initialPacket = packet.clone(); + } + 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) {} + public record PendingLimitWarning(int pending, int limit, long rejected) {} + private static final long WARNING_INTERVAL_NANOS = 5_000_000_000L; + 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; + private long pendingLimitRejected, lastPendingWarningNanos; + private int pendingAtRejection; + private boolean pendingWarningEmitted; + + 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) { capacityRejected++; return false; } + if (pending >= limits.pending()) { + capacityRejected++; pendingLimitRejected++; pendingAtRejection = pending; + return false; + } + if (tuples.size() >= limits.sessions() || claims.size() >= limits.claims()) { capacityRejected++; return false; } + // Only authenticated, capacity-admitted requests are retained. The parser + // caps each at 2048 bytes and pending reservations bound the number held. + Reservation r = new Reservation(a, tuple, nowNanos, packet); + claims.put(r.tokenId, r); tuples.put(tuple, r); pending++; accepted++; + try { enqueue.accept(r); } + catch (RuntimeException rejected) { finish(r); capacityRejected++; } + return false; // defer this packet until creation; never wait for a client retry + } + + public synchronized VerifiedAdmission admission(Reservation r) { return current(r) ? r.admission : null; } + /** Activate and transfer the first packet once, atomically releasing its pending slot. */ + public synchronized byte[] ready(Reservation r) { + if (!current(r) || r.ready) return null; + byte[] packet = r.initialPacket; + r.initialPacket = null; + r.ready = true; + pending--; + return packet; + } + 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; r.initialPacket = 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); } + /** Drain an aggregate on the owner thread; never invoke a logger in raw ingress. */ + public synchronized PendingLimitWarning pollPendingLimitWarning(long nowNanos) { + if (pendingLimitRejected == 0 || (pendingWarningEmitted && nowNanos - lastPendingWarningNanos < WARNING_INTERVAL_NANOS)) return null; + var warning = new PendingLimitWarning(pendingAtRejection, limits.pending(), pendingLimitRejected); + pendingLimitRejected = 0; lastPendingWarningNanos = nowNanos; pendingWarningEmitted = true; + return warning; + } +} 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..3992a511 --- /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 callerContextHash, 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..45301a54 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java @@ -0,0 +1,164 @@ +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; +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 { + 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 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() { + 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 { + 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"); + } + } + 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 new file mode 100644 index 00000000..9f5c1eac --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java @@ -0,0 +1,184 @@ +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 io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +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 { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NativeAdmissionServerChannel.class); + 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 boolean allowWildcardBind; + 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, validator, limits, false); + } + /** Wildcard binding is safe only when the caller publishes a separately validated concrete candidate. */ + public NativeAdmissionServerChannel(NativeHostIdentity identity, AdmissionValidator validator, AdmissionGate.Limits limits, boolean allowWildcardBind) { + this.identity = Objects.requireNonNull(identity); gate = new AdmissionGate(limits, validator); pending = new ArrayBlockingQueue<>(limits.pending()); maxNativePeers = limits.sessions(); + this.allowWildcardBind = allowWildcardBind; + } + @Override protected void doBind(SocketAddress socketAddress) throws Exception { + if (!(socketAddress instanceof InetSocketAddress a) || a.isUnresolved() || a.getPort() == 0 || (!allowWildcardBind && 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; } + var warning = gate.pollPendingLimitWarning(System.nanoTime()); + if (warning != null) log.warn("Pending admission limit reached: pending={}, limit={}, rejectedSinceLastWarning={}", + warning.pending(), warning.limit(), warning.rejected()); + 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 && 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()) { + 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.callerContextHash(), 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); gate.drain(); } + else liveNativePeers.decrementAndGet(); + nativeClosures.remove(session.closed); + }); + // 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; + 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(); + byte[] initialPacket = gate.ready(reservation); + if (initialPacket == null) { finish(reservation, "cancelled"); return; } + mux.replay(initialPacket, reservation.tuple().getAddress(), reservation.tuple().getPort()); + 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) { + 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()); + } + } + 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..bf104660 --- /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 callerContextHash, 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..d8da8904 --- /dev/null +++ b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java @@ -0,0 +1,50 @@ +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 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)); + 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)); + } +} From 4ac17d6de7f720b05439b807098b80288cf8927a Mon Sep 17 00:00:00 2001 From: Zulu Date: Sat, 5 Sep 2026 21:20:39 +0100 Subject: [PATCH 05/15] Pin and verify the maintained native stack and proposal builds Record exact Java/C/ICE dependencies and build them into a local Maven repository with artifact-hash checks. Add the aggregate fork CI gates, protect inherited publishing jobs from fork execution, preserve imported probe licensing and document contribution provenance. Keep application extension handoff private and transient in standalone integration fixtures. Complete interrupted enrollment through the same verified recovery path as a durable registration, with a lost-response regression for anonymous and bearer attachment journeys. Repair three stale upstream test lines against the existing API and retry behavior, retaining production RakNet and codec trees exactly. --- .github/workflows/deploy-feature-snapshot.yml | 1 + .github/workflows/deploy-release.yml | 1 + .github/workflows/deploy-snapshot.yml | 1 + .github/workflows/deploy.yml | 1 + .github/workflows/nxs-conformance.yml | 36 ++ .gitignore | 3 + LICENSES/MPL-2.0.txt | 373 ++++++++++++++++++ README.md | 15 + build.gradle.kts | 13 + docs/contribution-provenance.md | 67 ++++ external-signalling/build.gradle.kts | 2 +- .../netty/signalling/ProviderClient.java | 42 +- .../signalling/ExtensionFixtureFile.java | 25 ++ .../signalling/IndependentProviderStub.java | 14 +- .../netty/signalling/ProviderBench.java | 8 +- .../netty/signalling/ProviderClientTest.java | 6 +- .../signalling/ProviderJourneysTest.java | 34 ++ .../admission/AdmissionPrimitiveProbe.java | 3 + .../admission/ProviderNativeBench.java | 3 +- gradle.properties | 4 - native-dependencies.properties | 10 + scripts/bootstrap-native-admission.sh | 36 ++ .../cloudburstmc/netty/RakThrottleTests.java | 1 + .../netty/SplitPacketHelperTests.java | 4 +- 24 files changed, 677 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/nxs-conformance.yml create mode 100644 LICENSES/MPL-2.0.txt create mode 100644 docs/contribution-provenance.md create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ExtensionFixtureFile.java create mode 100644 native-dependencies.properties create mode 100755 scripts/bootstrap-native-admission.sh diff --git a/.github/workflows/deploy-feature-snapshot.yml b/.github/workflows/deploy-feature-snapshot.yml index 550e54df..e3983805 100644 --- a/.github/workflows/deploy-feature-snapshot.yml +++ b/.github/workflows/deploy-feature-snapshot.yml @@ -38,6 +38,7 @@ jobs: echo "Publishing ${publish_version}" deploy: + if: github.repository == 'CloudburstMC/Network' needs: version uses: ./.github/workflows/deploy.yml with: diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index b3578d66..29c77c2b 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -6,6 +6,7 @@ on: jobs: deploy: + if: github.repository == 'CloudburstMC/Network' uses: CloudburstMC/Network/.github/workflows/deploy.yml@develop with: deploy-url: "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" diff --git a/.github/workflows/deploy-snapshot.yml b/.github/workflows/deploy-snapshot.yml index 59e72d2b..8ab40949 100644 --- a/.github/workflows/deploy-snapshot.yml +++ b/.github/workflows/deploy-snapshot.yml @@ -6,6 +6,7 @@ on: jobs: deploy: + if: github.repository == 'CloudburstMC/Network' uses: CloudburstMC/Network/.github/workflows/deploy.yml@develop with: deploy-url: "https://repo.opencollab.dev/maven-snapshots/" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 48964a0c..d9db4557 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,7 @@ on: jobs: publish: + if: github.repository == 'CloudburstMC/Network' runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/nxs-conformance.yml b/.github/workflows/nxs-conformance.yml new file mode 100644 index 00000000..07a7c1b0 --- /dev/null +++ b/.github/workflows/nxs-conformance.yml @@ -0,0 +1,36 @@ +name: External signalling conformance +on: + push: + branches: [nxs-dev] + pull_request: + branches: [upstream, nxs-dev] + workflow_dispatch: +permissions: + contents: read +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 8 + 17 + 21 + - uses: gradle/actions/setup-gradle@v4 + - name: Verify canonical protocol fixtures + run: node docs/external-signalling/fixtures.mjs + - name: Build exact native dependency chain + run: bash scripts/bootstrap-native-admission.sh + - name: Validate transport and independent provider + run: ./gradlew --max-workers=2 build :external-signalling:nativeAdmissionTest + - uses: actions/upload-artifact@v4 + if: always() + with: + name: nxs-conformance + path: | + **/build/test-results/** + **/build/reports/tests/** + .native-deps/maven/**/provenance.json diff --git a/.gitignore b/.gitignore index f9839b3b..b88f135a 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,6 @@ gradle-app.setting *.hprof # End of https://www.toptal.com/developers/gitignore/api/java,gradle,eclipse,netbeans,intellij+all + +# Locally rebuilt immutable native development artifacts +.native-deps/ diff --git a/LICENSES/MPL-2.0.txt b/LICENSES/MPL-2.0.txt new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/LICENSES/MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index 7d7e1ddb..d9e7741f 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,18 @@ repositories { + +## NetherNet and external signalling + +`transport-nethernet` provides the attributed NetherNet transport and local HTTP +signalling integration. `external-signalling` implements the open +[NetherNet External Signalling v1 contract](docs/external-signalling/README.md), +including registration, background lifecycle and stateless admission. +See [contribution provenance and intended submission slices](docs/contribution-provenance.md). + +Build the pinned native development chain with `bash scripts/bootstrap-native-admission.sh`, +then run `./gradlew --max-workers=2 build :external-signalling:nativeAdmissionTest`. +An existing local Maven repository can be selected with +`-PnativeMavenRepository=/absolute/path/to/maven`. The development native artifacts +currently target Linux x86_64 and system OpenSSL; cross-platform release packaging +remains a separate release gate. diff --git a/build.gradle.kts b/build.gradle.kts index ffa7cc97..8d4e0391 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,13 @@ * under the License. */ +val nativeDependencies = java.util.Properties().apply { + rootProject.file("native-dependencies.properties").inputStream().use { load(it) } +} +for (key in listOf("nativeJavaGroup", "nativeJavaVersion")) { + if (!rootProject.hasProperty(key)) rootProject.extra[key] = nativeDependencies.getProperty(key) +} + val networkVersion = System.getenv("NETWORK_PUBLISH_VERSION") ?.trim() ?.takeIf { it.isNotEmpty() } @@ -28,6 +35,12 @@ subprojects { version = networkVersion repositories { + maven { + name = "maintainedNativeDevelopment" + url = uri(rootProject.providers.gradleProperty("nativeMavenRepository") + .getOrElse(rootProject.file(".native-deps/maven").toURI().toString())) + content { includeGroup("io.github.teamziax") } + } mavenLocal() mavenCentral() } diff --git a/docs/contribution-provenance.md b/docs/contribution-provenance.md new file mode 100644 index 00000000..1154b892 --- /dev/null +++ b/docs/contribution-provenance.md @@ -0,0 +1,67 @@ +# Contribution provenance and proposed submission slices + +This maintained proposal targets CloudburstMC/Network `develop` at +`508ed83c8a1fe1ce4287ad6d192c9d38bb0d2ffd`. `upstream` mirrors that exact baseline; +`nxs-dev` is the integrated development proposal. An aggregate internal draft +against `upstream` is for review and must not be merged into the mirror. + +| Slice | Origin and retained source | Reconstruction commit | Dependencies | +| --- | --- | --- | --- | +| N1: NetherNet prerequisites | Kas-tle/NetworkCompatible transport subtree at `9f3c0b7e72fb6f8934a7d36a518c74afe02c8a6d`, including introduction `77489fb0e4b86f5ffffef69230c81c7f4a857fa8` and SDP fixes `dcea2bab06ab35a53912b686a7dc59e0eb5bf62a` | `04ac4abb4d4e9d6f369c38de78ef2a40952ddecc` | Cloudburst baseline; original historical backend is replaced by N2 | +| N2: HTTP signalling and native backend | rtm516/NetworkCompatible transport subtree at `8d1c989ee6fb5eb7a28a9130573d30634f39cba4`; HTTP introduction `37f82650d6268a3470459d758ad89c3cc2026f2e`, backend migration `f4ba1e7b826e932e12974d5a6f329647eab6ee05` | `b96f3094ad1139848cd94f1984bf4122b5136348` | N1 and libdatachannel Java binding | +| N3: Open NXS contract and lifecycle client | teamziax/NetworkCompatible provider work through `01df7a0b4d3629306a72d78955f9b2645c174b1f`, adapted into a new negotiated specification and neutral client | `fc7dcc3534d34a0268e9a26ead49a48794b32c0b` | N2 for integrated transport; specification independently reviewable | +| N4: Stateless native admission | teamziax admission/teardown/first-datagram work at the same source tip, plus separate advertised-address support and downstream primitive probe | `9bf6674c43e6d59eba2805283e94b662d4b6c0ee` | N3 and the maintained mux/lifecycle/identity/ICE binding chain | +| N5: Integration and maintained dependency packaging | Exact dependency manifest, neutral bootstrap, conformance workflow, fixture handoff and final validation | Following integration commit(s) in this proposal | N1–N4; cross-repository pins in `native-dependencies.properties` | + +Cloudburst is the foundation, not newly authored work in this proposal. The two +histories share ancestor `1e26b20d9b9e13726edac99ee47711c958196fa2`; the source +proposal has 100 commits absent from current Cloudburst, while Cloudburst has 36 +commits absent from that source. Only the required transport subtree was imported. +Cloudburst's production `transport-raknet/src/main`, `codec-query` and `codec-rcon` +trees are preserved exactly. Two existing split-helper test constructor calls are +adapted to the upstream `(partId, expectedLength)` API so the baseline suite can +compile; this is the same minimal test repair previously recorded in +`ziaxzulu/Network` commit `2910ce99fcf96075da6b74d84c6d614bc2e3b207`. +Its throttle fixture also limits clients to one attempt, as recorded in +`aa3055bd4c6b3880913cd74911cba50ec4f1cea3`, so retries beyond the test's one-second +window do not invalidate its immediate-throttle assertion. No production RakNet +changes accompany these three test-line repairs. +The fork's removed codecs, unrelated RakNet compatibility behavior, +publishing configuration and historical merge topology were not transplanted. + +N1 removes the source module's additional publishing plugin and uses Cloudburst's +existing Gradle and Maven conventions. N2 adapts the inherited TLS detection call +to Cloudburst's existing Netty API; HTTP behaviour still rejects encrypted traffic +on the plaintext handler. It does not upgrade unrelated RakNet dependencies. +Original contributor names and file notices remain in the source and commit trailers. + +N3 replaces product-labelled packages, signature headers, registration/profile IDs +and domains with explicitly negotiated NXS v1 values. Canonical bytes are versioned; +the change is not a relabelling of old signatures. Durable same-instance recovery +preserves IDs and signing material through explicit recovery/activation. The client +implements no individual join control command and treats extension metadata as +transient, bounded opaque data. An optional application adapter owns its own +account actions. The independent provider exercises the full host lifecycle and +four authorization/placement journeys without such an adapter. + +N4 authenticates raw STUN before promotion/allocation, bounds replay and capacity, +keeps creation outside mux locks, and retains capacity until native teardown. +The admission token binds the client fingerprint, ICE credentials, endpoint +incarnation, expiry and opaque caller context. Native integration tests cover +both data channels, first-datagram response, invalid ingress, replay, key retirement, +concurrency and shutdown. These tests do not establish stock-client gameplay. +The moved `AdmissionPrimitiveProbe` preserves its original MPL-2.0 file license; +the license text is in `LICENSES/MPL-2.0.txt`. + +Native patch acceptance is not required to build this proposal: the manifest pins +maintained forks. Fork packaging is intentionally separated from generic API +contributions. Native `master` already provides certificate and ICE-credential C +APIs; the Java binding uses those upstream APIs rather than duplicate entrypoints. +The native artifact provenance records source revisions, platform, ABI assumptions +and SHA-256 hashes. A new native chain must be rebuilt together with its JNI headers. + +Before eventual external submission, refresh the mirror, remove already accepted +slices, review current contribution rules, and describe dependencies explicitly. +NetworkCompatible belongs to a different GitHub fork network from Cloudburst; +this local Git ancestry reconstruction does not change repository ownership or +create an external PR. No repository transfer is part of this maintained proposal. diff --git a/external-signalling/build.gradle.kts b/external-signalling/build.gradle.kts index 5ecb454e..3f521d42 100644 --- a/external-signalling/build.gradle.kts +++ b/external-signalling/build.gradle.kts @@ -38,7 +38,7 @@ tasks.register("providerBench") { dependsOn(tasks.testClasses) classpath = sourceSets.test.get().runtimeClasspath mainClass.set("org.cloudburstmc.netty.signalling.ProviderBench") - listOf("providerOrigin", "providerState", "providerMode", "providerGrantFile", "providerToken", "providerRegistrationMode", "providerRegion", "providerPool", "providerTags", "providerHoldSeconds").forEach { name -> + listOf("providerOrigin", "providerState", "providerMode", "providerToken", "providerRegistrationMode", "providerRegion", "providerPool", "providerTags", "providerHoldSeconds", "providerStopFile", "providerExtensionsFile").forEach { name -> providers.gradleProperty(name).orNull?.let { systemProperty(name, it) } } } diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java index 9fdbe943..48c27dae 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java @@ -134,21 +134,42 @@ private void validateDiscovery() throws IOException { || limits.get("clockSkewMs").getAsLong() < 0 || limits.get("clockSkewMs").getAsLong() > 60000) throw new IOException("Unsupported provider limits"); } - private void recoverExisting() throws Exception { - JsonObject recovery = new JsonObject(); recovery.addProperty("registrationId", registration("registrationId")); + private JsonObject recoveryRequest(String registrationId) { + JsonObject recovery = new JsonObject(); recovery.addProperty("registrationId", registrationId); recovery.addProperty("protocol", ProviderCrypto.PROTOCOL); recovery.addProperty("profile", config.profile()); - JsonObject challenge = unsigned("recover", recovery); + return recovery; + } + private void recoverExisting() throws Exception { + String registrationId = registration("registrationId"); + completeRecovery(unsigned("recover", recoveryRequest(registrationId)), registrationId); + } + private void completeRecovery(JsonObject challenge, String registrationId) throws Exception { + ProviderContract.require("challenge", challenge); + JsonObject context = challenge.getAsJsonObject("context"); + if (!origin.equals(challenge.get("audience").getAsString()) || !ProviderCrypto.PROTOCOL.equals(challenge.get("protocol").getAsString()) + || !ProviderCrypto.SIGNATURE.equals(challenge.get("signature").getAsString()) || !"recover".equals(context.get("mode").getAsString()) + || !config.profile().equals(context.get("profile").getAsString()) || !registrationId.equals(context.get("registrationId").getAsString()) + || !ProviderCrypto.contextDigest(context).equals(challenge.get("contextDigest").getAsString()) + || challenge.get("expiresAt").getAsLong() <= System.currentTimeMillis() + || !"sha256-leading-zero-bits-v0".equals(challenge.getAsJsonObject("pow").get("algorithm").getAsString()) + || challenge.getAsJsonObject("pow").get("difficulty").getAsInt() != 0) + throw new IOException("Unbound recovery challenge"); String thumbprint = challenge.get("thumbprint").getAsString(); boolean pending = state.has("pendingPublicKeyJwk") && ProviderCrypto.thumbprint(state.getAsJsonObject("pendingPublicKeyJwk")).equals(thumbprint); PrivateKey key = pending ? ProviderCrypto.privateKey(state.get("pendingPrivateKey").getAsString()) : privateKey; if (!pending && !ProviderCrypto.thumbprint(state.getAsJsonObject("publicKeyJwk")).equals(thumbprint)) throw new IOException("Recovery key does not match durable state"); - if (!origin.equals(challenge.get("audience").getAsString()) || !ProviderCrypto.PROTOCOL.equals(challenge.get("protocol").getAsString())) throw new IOException("Recovery audience mismatch"); String intent = UUID.randomUUID().toString(); JsonObject completion = new JsonObject(); completion.addProperty("protocol", ProviderCrypto.PROTOCOL); completion.addProperty("challengeId", challenge.get("challengeId").getAsString()); completion.addProperty("proofNonce", "0"); completion.addProperty("idempotencyKey", intent); completion.addProperty("signature", ProviderCrypto.sign(key, ProviderCrypto.proof(challenge, "0", intent))); JsonObject recovered = unsigned("complete", completion); validateRegistration(recovered); - registrationExtensions = ProtocolExtensions.copy(recovered); recovered.remove("extensions"); + if (!registrationId.equals(recovered.get("registrationId").getAsString())) throw new IOException("Recovered registration changed"); + if (state.has("registration")) for (String field : List.of("instanceId", "serviceId", "registrationId")) + if (!state.getAsJsonObject("registration").get(field).equals(recovered.get(field))) throw new IOException("Recovered instance identity changed"); + registrationExtensions = ProtocolExtensions.copy(recovered); recovered.remove("extensions"); recovered.remove("ticketKey"); state.add("registration", recovered); state.addProperty("generation", recovered.get("leaseGeneration").getAsLong()); + if (!state.has("sequence")) state.addProperty("sequence", 0); + if (!state.has("ticketKeys")) state.add("ticketKeys", new JsonArray()); + state.remove("challenge"); // Sequence is monotonic within a generation; the previous durable reservation is retained. if (pending) { state.add("privateKey", state.remove("pendingPrivateKey")); state.add("publicKeyJwk", state.remove("pendingPublicKeyJwk")); privateKey = key; } save(); @@ -156,9 +177,12 @@ private void recoverExisting() throws Exception { private void enroll() throws Exception { JsonObject challenge; if (state.has("challenge")) { - JsonObject recovery = new JsonObject(); recovery.addProperty("registrationId", state.getAsJsonObject("challenge").get("challengeId").getAsString()); - try { challenge = unsigned("recover", recovery); } - catch (ProviderException e) { if (e.status != 403) throw e; challenge = state.getAsJsonObject("challenge"); } + String registrationId = state.getAsJsonObject("challenge").get("challengeId").getAsString(); + JsonObject recoveredChallenge = null; + try { recoveredChallenge = unsigned("recover", recoveryRequest(registrationId)); } + catch (ProviderException e) { if (e.status != 403) throw e; } + if (recoveredChallenge != null) { completeRecovery(recoveredChallenge, registrationId); return; } + challenge = state.getAsJsonObject("challenge"); } else { JsonObject request = new JsonObject(); request.addProperty("protocol", ProviderCrypto.PROTOCOL); request.addProperty("mode", config.registrationMode()); request.addProperty("profile", config.profile()); request.add("publicKeyJwk", state.get("publicKeyJwk")); if (config.label() != null) request.addProperty("label", config.label()); @@ -188,7 +212,7 @@ private void enroll() throws Exception { JsonObject registration = unsigned("complete", completion); ProviderContract.require("registration", registration); validateRegistration(registration); registrationExtensions = ProtocolExtensions.copy(registration); registration.remove("extensions"); state.add("registration", registration); state.addProperty("generation", registration.get("leaseGeneration").getAsLong()); state.addProperty("sequence", 0); - state.add("ticketKeys", new JsonArray()); + state.add("ticketKeys", new JsonArray()); state.remove("challenge"); if (registration.has("ticketKey")) { state.getAsJsonArray("ticketKeys").add(registration.remove("ticketKey")); } save(); } diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ExtensionFixtureFile.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ExtensionFixtureFile.java new file mode 100644 index 00000000..7ce6a942 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ExtensionFixtureFile.java @@ -0,0 +1,25 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Set; + +/** Explicit, short-lived fixture handoff; never part of durable instance identity or stdout. */ +public final class ExtensionFixtureFile { + private ExtensionFixtureFile() {} + public static void write(Path path, JsonObject extensions) throws IOException { + JsonObject document = new JsonObject(); document.add("extensions", extensions); ProtocolExtensions.validate(document); + try (FileChannel file = FileChannel.open(path, Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")))) { + ByteBuffer bytes = StandardCharsets.UTF_8.encode(extensions.toString()); + while (bytes.hasRemaining()) file.write(bytes); + file.force(true); + } + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java index 0e9e42f0..e511c7ba 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java @@ -15,6 +15,7 @@ public final class IndependentProviderStub implements AutoCloseable { final Map challenges = new HashMap<>(), keys = new HashMap<>(), placements = new HashMap<>(); JsonObject registration; volatile JsonObject lastHeartbeat; volatile int failHeartbeats; + volatile boolean loseCompletionResponse; volatile long checkInMillis; volatile int controlPolls; final java.util.List events = new java.util.concurrent.CopyOnWriteArrayList<>(); @@ -51,6 +52,7 @@ private JsonObject dispatch(HttpExchange e) throws Exception { } if (path.equals("/example/challenges") || path.equals("/example/recover")) { boolean recovery = path.endsWith("recover"); + if (!ProviderCrypto.PROTOCOL.equals(body.get("protocol").getAsString()) || !"nxs-admission-v1".equals(body.get("profile").getAsString())) throw new Failure(400, "unsupported_profile"); if (recovery && (registration == null || !registration.get("registrationId").equals(body.get("registrationId")))) throw new Failure(403, "recovery_unavailable"); JsonObject key = recovery ? keys.get(registration.get("keyId").getAsString()) : body.getAsJsonObject("publicKeyJwk"); String authorization = recovery ? "recovery" : body.getAsJsonObject("authorization").get("scheme").getAsString(); @@ -60,7 +62,7 @@ private JsonObject dispatch(HttpExchange e) throws Exception { if (!"Bearer independent-provider-token".equals(challengeAuthorization)) throw new Failure(401, "invalid_bearer_token"); } JsonObject c = new JsonObject(); c.addProperty("protocol", ProviderCrypto.PROTOCOL); c.addProperty("signature", ProviderCrypto.SIGNATURE); c.addProperty("challengeId", UUID.randomUUID().toString()); c.addProperty("audience", origin); c.addProperty("nonce", UUID.randomUUID().toString()); c.addProperty("thumbprint", ProviderCrypto.thumbprint(key)); c.addProperty("expiresAt", System.currentTimeMillis() + 60000); c.addProperty("serverTime", System.currentTimeMillis()); - JsonObject context = new JsonObject(); for (String f : List.of("label", "authorizationId", "serviceId", "region", "pool", "registrationId")) context.addProperty(f, ""); context.addProperty("mode", recovery ? "recover" : body.get("mode").getAsString()); context.addProperty("profile", "nxs-admission-v1"); + JsonObject context = new JsonObject(); for (String f : List.of("label", "authorizationId", "serviceId", "region", "pool", "registrationId")) context.addProperty(f, ""); context.addProperty("mode", recovery ? "recover" : body.get("mode").getAsString()); context.addProperty("profile", "nxs-admission-v1"); if (recovery) context.add("registrationId", body.get("registrationId")); if (!recovery && authorization.equals("bearer-token")) { context.addProperty("authorizationId", "independent-authority"); JsonObject selected = new JsonObject(); selected.addProperty("scheme", authorization); selected.addProperty("reference", "independent-authority"); c.add("authorization", selected); } if (!recovery && body.has("placement")) { JsonObject placement = body.getAsJsonObject("placement"); context.add("region", placement.get("region")); context.add("pool", placement.get("pool")); if (placement.has("tags")) { Map tags = new TreeMap<>(); for (var tag : placement.getAsJsonObject("tags").entrySet()) tags.put(tag.getKey(), tag.getValue().getAsString()); context.addProperty("tagsDigest", ProviderCrypto.tagsDigest(tags)); } } c.add("context", context); c.addProperty("contextDigest", ProviderCrypto.contextDigest(context)); JsonObject pow = new JsonObject(); pow.addProperty("algorithm", "sha256-leading-zero-bits-v0"); challengeDifficulty = recovery || authorization.equals("bearer-token") ? 0 : 2; pow.addProperty("difficulty", challengeDifficulty); c.add("pow", pow); @@ -76,14 +78,18 @@ private JsonObject dispatch(HttpExchange e) throws Exception { challenges.remove(id); if (c.getAsJsonObject("context").get("mode").getAsString().equals("recover")) { JsonObject r = registration.deepCopy(); r.remove("ticketKey"); r.addProperty("leaseGeneration", generation); return r; } if (registration != null) throw new Failure(409, "already_registered"); registrations++; - registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", 0); registration.addProperty("leaseDeadline", 0); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); return registration.deepCopy(); + registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", 0); registration.addProperty("leaseDeadline", 0); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); if (loseCompletionResponse) { loseCompletionResponse = false; e.close(); throw new Failure(503, "completion_response_lost"); } return registration.deepCopy(); } if (path.equals("/example/heartbeat") && failHeartbeats-- > 0) throw new Failure(503, "fixture_transient"); authenticate(e, raw); JsonObject ok = new JsonObject(); ok.addProperty("accepted", true); switch (path) { - case "/example/activate" -> { generation++; sequence = 0; draining = false; ok.addProperty("leaseGeneration", generation); ok.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); } - case "/example/host-profile" -> ok.addProperty("revision", "example-profile-revision"); + case "/example/activate" -> { if (!"nxs-admission-v1".equals(body.get("profile").getAsString())) throw new Failure(400, "unsupported_profile"); generation++; sequence = 0; draining = false; ok.addProperty("leaseGeneration", generation); ok.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); } + case "/example/host-profile" -> { + if (keyAcknowledgements == 0 || !"nethernet.stateless-admission.v1".equals(body.getAsJsonObject("statelessAdmission").get("capability").getAsString()) + || !body.get("dtlsFingerprint").getAsString().matches("sha-256 [0-9A-F]{2}(?::[0-9A-F]{2}){31}")) throw new Failure(400, "invalid_host_profile"); + ok.addProperty("revision", "example-profile-revision"); + } case "/example/heartbeat" -> { if (draining) throw new Failure(403, "draining"); lastHeartbeat = body; heartbeats++; if (checkInMillis > 0 && body.has("checkInVersion")) { long now = System.currentTimeMillis(); JsonObject schedule = new JsonObject(); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java index bb6eb503..7d5285e0 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java @@ -15,11 +15,11 @@ public static void main(String[] args) throws Exception { if (System.getProperty("providerMode", "once").equals("identity")) { try (var store = new ProviderStateStore(state)) { System.out.println(ProviderIdentity.initialize(store, provider)); } return; } String token = System.getProperty("providerToken"); ProviderTransport transport = new ProviderTransport() { - String keyId; + String keyId; final String incarnation = UUID.randomUUID().toString().replace("-", ""); public CompletionStage installTicketKeys(List keys) { keyId = keys.getLast().keyId(); return CompletableFuture.completedFuture(null); } public CompletionStage hostProfile() { JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", keyId); p.addProperty("dtlsFingerprint", "sha-256 " + String.join(":", Collections.nCopies(32, "11"))); p.addProperty("sctpPort", 5000); p.addProperty("maxMessageSize", 262144); - JsonObject c = new JsonObject(); c.addProperty("address", "127.0.0.1"); c.addProperty("port", 19133); c.addProperty("foundation", "fixture"); c.addProperty("component", 1); c.addProperty("priority", 100); c.addProperty("protocol", "udp"); c.addProperty("type", "host"); JsonArray candidates = new JsonArray(); candidates.add(c); p.add("candidates", candidates); return CompletableFuture.completedFuture(p); + JsonObject c = new JsonObject(); c.addProperty("address", "127.0.0.1"); c.addProperty("port", 19133); c.addProperty("foundation", "fixture"); c.addProperty("component", 1); c.addProperty("priority", 100); c.addProperty("protocol", "udp"); c.addProperty("type", "host"); JsonArray candidates = new JsonArray(); candidates.add(c); p.add("candidates", candidates); JsonObject capability = new JsonObject(); capability.addProperty("capability", "nethernet.stateless-admission.v1"); capability.addProperty("incarnation", incarnation); p.add("statelessAdmission", capability); return CompletableFuture.completedFuture(p); } public CompletionStage applyControl(JsonObject c) { return CompletableFuture.completedFuture(ApplyResult.REJECTED); } public List pollEvents() { return List.of(); } @@ -37,8 +37,10 @@ public CompletionStage hostProfile() { var client = new ProviderClient(config, new ProviderStateStore(state), transport, () -> new ServerStatus("Java bench", 1234, "fixture-only", "Fixture", 2, 50, 0), () -> new ProviderClient.Health(true, 100, 0.02, "nethernet", "java-conformance"), System.err::println); try { JsonObject registration = client.start().get(30, TimeUnit.SECONDS); + String extensionsFile = System.getProperty("providerExtensionsFile"); + if (extensionsFile != null) ExtensionFixtureFile.write(Path.of(extensionsFile), client.extensions().get(10, TimeUnit.SECONDS)); System.out.println("instance=" + registration.get("instanceId").getAsString() + " service=" + registration.get("serviceId").getAsString()); - System.out.println(client.readiness().get(10, TimeUnit.SECONDS)); + JsonObject readiness = client.readiness().get(10, TimeUnit.SECONDS); readiness.remove("extensions"); System.out.println(readiness); long hold = Long.parseLong(System.getProperty("providerHoldSeconds", "0")); String stopFile = System.getProperty("providerStopFile"); if (stopFile != null) { long until = System.currentTimeMillis() + 180000; while (!Files.exists(Path.of(stopFile)) && System.currentTimeMillis() < until) Thread.sleep(100); } diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java index 43d46382..007d90d5 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java @@ -89,9 +89,11 @@ static final class FakeTransport implements ProviderTransport { final CompletableFuture closed = new CompletableFuture<>(); final java.util.Queue events = new java.util.concurrent.ConcurrentLinkedQueue<>(); volatile int installed, applied, admissions, drains; - boolean stateless; + boolean stateless = true; volatile ApplyResult result = ApplyResult.APPLIED; - public CompletionStage hostProfile() { JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", "T001"); p.addProperty("dtlsFingerprint", "fixture-native-profile"); if (stateless) p.add("statelessAdmission", new JsonObject()); return CompletableFuture.completedFuture(p); } + public CompletionStage hostProfile() { JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", "T001"); p.addProperty("dtlsFingerprint", "sha-256 " + String.join(":", Collections.nCopies(32, "11"))); p.addProperty("sctpPort", 5000); p.addProperty("maxMessageSize", 262144); + JsonObject c = new JsonObject(); c.addProperty("foundation", "fixture"); c.addProperty("component", 1); c.addProperty("protocol", "udp"); c.addProperty("priority", 100); c.addProperty("address", "127.0.0.1"); c.addProperty("port", 19133); c.addProperty("type", "host"); JsonArray candidates = new JsonArray(); candidates.add(c); p.add("candidates", candidates); + if (stateless) { JsonObject cap = new JsonObject(); cap.addProperty("capability", "nethernet.stateless-admission.v1"); cap.addProperty("incarnation", "0123456789abcdef0123456789abcdef"); p.add("statelessAdmission", cap); } return CompletableFuture.completedFuture(p); } public CompletionStage installTicketKeys(List keys) { installed = keys.size(); return CompletableFuture.completedFuture(null); } public CompletionStage applyControl(JsonObject c) { applied++; String kind = c.get("kind").getAsString(); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java index bb42aa8a..370d5a26 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java @@ -95,4 +95,38 @@ private static ProviderClient client(IndependentProviderStub stub, Path director } finally { instance.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } } } + + @Test void recoversCommittedRegistrationWhenCompletionResponseIsLost(@TempDir Path directory) throws Exception { + // Exercise bearer attachment too: a recovery challenge intentionally has neither enrollment + // placement nor bearer authorization, while the recovered registration retains both bindings. + for (boolean attach : new boolean[]{false, true}) try (IndependentProviderStub stub = new IndependentProviderStub()) { + Path statePath = directory.resolve(attach ? "attached" : "standalone"); + var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Lost completion", + attach ? ProviderClient.ATTACH_INSTANCE : ProviderClient.NEW_SERVICE, + attach ? ProviderClient.BEARER_TOKEN : ProviderClient.ANONYMOUS_PROOF_OF_WORK, + attach ? "independent-provider-token" : null, attach ? "EU" : null, attach ? "proxy" : null, + attach ? Map.of("location", "london") : Map.of()); + stub.loseCompletionResponse = true; + ProviderClient first = client(stub, statePath, configuration, new ProviderClientTest.FakeTransport()); + try { assertThrows(java.util.concurrent.ExecutionException.class, () -> first.start().get(20, TimeUnit.SECONDS)); } + finally { first.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + JsonObject before; + try (ProviderStateStore state = new ProviderStateStore(statePath)) { + before = state.read(); assertTrue(before.has("challenge")); assertFalse(before.has("registration")); + } + assertEquals(1, stub.registrations, "The provider committed despite the lost HTTP response"); + ProviderClient resumed = client(stub, statePath, configuration, new ProviderClientTest.FakeTransport()); + try { + JsonObject registration = resumed.start().get(20, TimeUnit.SECONDS); + assertEquals(stub.registration.get("registrationId"), registration.get("registrationId")); + assertEquals(1, stub.registrations); assertEquals(1, stub.generation); + assertTrue(resumed.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); + assertTrue(stub.keyAcknowledgements > 0, "Lost one-time key material is freshly provisioned"); + } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + try (ProviderStateStore state = new ProviderStateStore(statePath)) { + JsonObject after = state.read(); assertFalse(after.has("challenge")); + assertEquals(before.get("privateKey"), after.get("privateKey")); + } + } + } } diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java index 47aa9ae9..9a04ae69 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: MPL-2.0 +// Adapted from teamziax/libdatachannel-java at 40f2c329dcb63a762a701b987dc9995d76fd18c7. +// The original file license is preserved; see LICENSES/MPL-2.0.txt. package org.cloudburstmc.netty.signalling.admission; import tel.schich.libdatachannel.*; diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java index d62e952f..0c8a2379 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java @@ -50,10 +50,11 @@ public static void main(String[] args) throws Exception { () -> new ServerStatus("Automatic native server", 1234, "fixture-only", "Integration", 0, 4, 0), () -> new ProviderClient.Health(true, 4, 0, "nethernet", "provider-native-bench"), System.err::println); JsonObject registration = provider.start().get(45, TimeUnit.SECONDS); + if (args.length > 4) ExtensionFixtureFile.write(Path.of(args[4]), provider.extensions().get(10, TimeUnit.SECONDS)); // Emit assigned IDs only; optional metadata and credentials are excluded. emit("registered", Map.of("serviceId", registration.get("serviceId").getAsString(), "instanceId", registration.get("instanceId").getAsString())); emit("profile", nativeHost.hostProfile().toCompletableFuture().get()); - emit("readiness", provider.readiness().get(10, TimeUnit.SECONDS)); + JsonObject readiness = provider.readiness().get(10, TimeUnit.SECONDS); readiness.remove("extensions"); emit("readiness", readiness); long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(3); boolean updated = false; while (!Files.exists(stop)) { diff --git a/gradle.properties b/gradle.properties index a44c19ec..ae232a43 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,3 @@ # # We follow JBoss versioning https://developer.jboss.org/docs/DOC-10725 version=1.1.0.CR1-SNAPSHOT - -# Temporary maintained native binding; final immutable revision is recorded before publication. -nativeJavaGroup=tel.schich -nativeJavaVersion=0.24.1.1 diff --git a/native-dependencies.properties b/native-dependencies.properties new file mode 100644 index 00000000..912b7178 --- /dev/null +++ b/native-dependencies.properties @@ -0,0 +1,10 @@ +# Maintained fork integration pins; branch names never select build inputs. +java.repository=teamziax/libdatachannel-java +java.commit=c94d932c03ec6f12eb9d9b629dd2689ffa3f424e +nativeJavaGroup=io.github.teamziax +nativeJavaVersion=0.24.5.0-dev.c94d932c03ec6f12eb9d9b629dd2689ffa3f424e +datachannel.repository=teamziax/libdatachannel +datachannel.commit=bd9090f775f2354cc35716ec04b24110562e6ab3 +juice.repository=teamziax/libjuice +juice.commit=4ffdcc321fee1c6d743bc98882cb2a6544e7b5f2 +platform=linux-x86_64 diff --git a/scripts/bootstrap-native-admission.sh b/scripts/bootstrap-native-admission.sh new file mode 100755 index 00000000..2abc7be3 --- /dev/null +++ b/scripts/bootstrap-native-admission.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +network_root=$(cd "$(dirname "$0")/.." && pwd) +read_pin() { sed -n "s/^$1=//p" "$network_root/native-dependencies.properties"; } +java_revision=$(read_pin 'java.commit') +datachannel_revision=$(read_pin 'datachannel.commit') +juice_revision=$(read_pin 'juice.commit') +output=${1:-"$network_root/.native-deps/maven"} +output=$(realpath -m "$output") +java_checkout=${NATIVE_JAVA_CHECKOUT:-"$network_root/.native-deps/libdatachannel-java-$java_revision"} +if [[ ! -d "$java_checkout/.git" ]]; then + if [[ -n "${NATIVE_JAVA_CHECKOUT:-}" ]]; then + echo 'Supplied native Java checkout is missing' >&2 + exit 1 + fi + mkdir -p "$(dirname "$java_checkout")" + git clone --no-checkout "https://github.com/$(read_pin 'java.repository').git" "$java_checkout" + git -C "$java_checkout" checkout --detach "$java_revision" + git -C "$java_checkout" submodule update --init --recursive +fi +[[ $(git -C "$java_checkout" rev-parse HEAD) == "$java_revision" ]] || { echo 'Native Java revision mismatch' >&2; exit 1; } +[[ $(git -C "$java_checkout/jni/libdatachannel" rev-parse HEAD) == "$datachannel_revision" ]] || { echo 'Native transport revision mismatch' >&2; exit 1; } +[[ $(git -C "$java_checkout/jni/libdatachannel/deps/libjuice" rev-parse HEAD) == "$juice_revision" ]] || { echo 'Native ICE revision mismatch' >&2; exit 1; } +bash "$java_checkout/scripts/package-development.sh" "$output" +python3 - "$output" "$network_root/native-dependencies.properties" <<'PY' +import hashlib, json, pathlib, sys +root, manifest = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) +pins = dict(line.split('=', 1) for line in manifest.read_text().splitlines() if line and not line.startswith('#')) +folder = root / pins['nativeJavaGroup'].replace('.', '/') / 'libdatachannel-java' / pins['nativeJavaVersion'] +provenance = json.loads((folder / 'provenance.json').read_text()) +for field, pin in [('bindingRevision','java.commit'),('libdatachannelRevision','datachannel.commit'),('libjuiceRevision','juice.commit')]: + if provenance[field] != pins[pin]: raise SystemExit('Native provenance mismatch: ' + field) +for name, expected in provenance['sha256'].items(): + if hashlib.sha256((folder / name).read_bytes()).hexdigest() != expected: raise SystemExit('Native artifact hash mismatch: ' + name) +print('Verified native artifacts: ' + str(folder)) +PY diff --git a/transport-raknet/src/test/java/org/cloudburstmc/netty/RakThrottleTests.java b/transport-raknet/src/test/java/org/cloudburstmc/netty/RakThrottleTests.java index f24ec141..5d155a10 100644 --- a/transport-raknet/src/test/java/org/cloudburstmc/netty/RakThrottleTests.java +++ b/transport-raknet/src/test/java/org/cloudburstmc/netty/RakThrottleTests.java @@ -74,6 +74,7 @@ private Bootstrap clientBootstrap() { .channelFactory(RakChannelFactory.client(NioDatagramChannel.class)) .group(group) .option(RakChannelOption.RAK_PROTOCOL_VERSION, PROTOCOL_VERSION) + .option(RakChannelOption.RAK_MAX_CONNECTION_ATTEMPTS, 1) .handler(new ChannelInitializer() { @Override protected void initChannel(RakClientChannel ch) { diff --git a/transport-raknet/src/test/java/org/cloudburstmc/netty/SplitPacketHelperTests.java b/transport-raknet/src/test/java/org/cloudburstmc/netty/SplitPacketHelperTests.java index 8f5f2cdd..d4c36634 100644 --- a/transport-raknet/src/test/java/org/cloudburstmc/netty/SplitPacketHelperTests.java +++ b/transport-raknet/src/test/java/org/cloudburstmc/netty/SplitPacketHelperTests.java @@ -44,7 +44,7 @@ private static EncapsulatedPacket part(int partCount, int partIndex, int payload @Test public void reassembledSizeTracksRetainedBytes() { - SplitPacketHelper helper = new SplitPacketHelper(3); + SplitPacketHelper helper = new SplitPacketHelper(0, 3); Assertions.assertEquals(0, helper.getReassembledSize()); EncapsulatedPacket p0 = part(3, 0, 100); @@ -77,7 +77,7 @@ public void reassembledSizeTracksRetainedBytes() { @Test public void expiresAfterTimeout() { - SplitPacketHelper helper = new SplitPacketHelper(2); + SplitPacketHelper helper = new SplitPacketHelper(0, 2); Assertions.assertFalse(helper.expired()); helper.release(); } From eeab76adfab8e01357220194d236f98d39fe867d Mon Sep 17 00:00:00 2001 From: Zulu Ziax Date: Sun, 6 Sep 2026 16:21:08 +0100 Subject: [PATCH 06/15] Explain NXS in plain English and add writing guidance (#11) --- README.md | 17 +- docs/external-signalling/README.md | 589 ++++++++++++++++++----------- docs/technical-writing.md | 41 ++ 3 files changed, 429 insertions(+), 218 deletions(-) create mode 100644 docs/technical-writing.md diff --git a/README.md b/README.md index d9e7741f..35630047 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,18 @@ repositories { ## NetherNet and external signalling -`transport-nethernet` provides the attributed NetherNet transport and local HTTP -signalling integration. `external-signalling` implements the open -[NetherNet External Signalling v1 contract](docs/external-signalling/README.md), -including registration, background lifecycle and stateless admission. -See [contribution provenance and intended submission slices](docs/contribution-provenance.md). +`transport-nethernet` provides NetherNet transport and local HTTP signalling. +`external-signalling` lets a host register with its chosen provider and check +client connection tokens locally. Read the +[NXS v1 specification](docs/external-signalling/README.md) and the +[contribution history and proposed upstream PRs](docs/contribution-provenance.md). Build the pinned native development chain with `bash scripts/bootstrap-native-admission.sh`, then run `./gradlew --max-workers=2 build :external-signalling:nativeAdmissionTest`. An existing local Maven repository can be selected with `-PnativeMavenRepository=/absolute/path/to/maven`. The development native artifacts -currently target Linux x86_64 and system OpenSSL; cross-platform release packaging -remains a separate release gate. +currently target Linux x86_64 and system OpenSSL. Other platforms need their own +release builds and tests. + +When editing documentation or PR descriptions, follow the +[technical-writing checklist](docs/technical-writing.md). diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md index 47cad465..1c7cd25e 100644 --- a/docs/external-signalling/README.md +++ b/docs/external-signalling/README.md @@ -1,17 +1,60 @@ # NetherNet External Signalling v1 -Status: experimental open specification. Identifier: `urn:nethernet:external-signalling:v1`. -Licensed under this repository's Apache-2.0 license. The schema, canonical fixtures, -and this document form one versioned contract. Product account systems, credential -issuance, billing, Microsoft login, DNS management, and host-selection policy are -outside the contract. - -NXS connects a running NetherNet host to an operator-selected signalling provider. -The provider can issue an answer using previously published host information. -The host validates the connecting client's first STUN packet without a per-client -request to the provider. A conforming implementation MUST NOT require any push, -poll, shared lookup, offer fetch, or pre-staged client state to admit that client. -Transport and game outcomes are asynchronous observations, never admission prerequisites. +NetherNet External Signalling (NXS) lets a NetherNet server use a signalling +provider chosen by its operator. The server registers with the provider and +publishes the information clients need to connect. Each client then brings a +short-lived token that the server can check locally. + +For example, a server can publish its address and certificate fingerprint when +it starts. Later, the provider gives a client those details and a token. The +server checks the token in the client's first packet. It does not need to ask +the provider whether to accept that connection. + +This is an experimental open specification, identified by +`urn:nethernet:external-signalling:v1`. This document, the +[schema](nxs-v1.schema.json), and the test fixtures define one versioned protocol. +They use this repository's Apache-2.0 license. + +## How a connection works + +1. The host registers with the provider and proves that it owns its signing key. +2. The host publishes its address, certificate fingerprint, and connection + settings. It sends heartbeats to renew its registration lease. +3. The provider uses that information to give a client a connection answer and + an admission token. +4. The client sends the token in its first STUN packet to the host. +5. The host checks the packet and token, then establishes the connection. The + client's certificate must match the fingerprint in the token. +6. The host reports connection and game outcomes to the provider afterwards. + +Here, **stateless admission** means that the host needs no saved state for that +client before its first packet arrives. The host still keeps its own keys, +registration, and active connections. A conforming implementation MUST NOT +require a push, poll, shared lookup, offer fetch, or pre-staged client state to +admit a client. Reports about the connection or game outcome never determine +whether the host can accept that first packet. + +NXS covers communication between the host and provider. Account systems, +credential issuance, billing, Microsoft login, DNS management, and the policy +for choosing a host are outside this specification. + +### Terms + +| Term | Meaning here | +| --- | --- | +| Host or instance | One running NetherNet server. Its instance ID survives a restart. | +| Provider | The service that registers hosts and gives clients connection information. | +| Service | A provider-assigned registration that can contain one or more instances. | +| Lease | The period for which an instance is eligible to receive new connections. Heartbeats renew it. | +| Generation | A counter advanced on activation. Requests from earlier generations are rejected. | +| Host profile | The address, certificate fingerprint, and other settings clients need to connect. | +| Incarnation | A random ID for one bound UDP endpoint. A newly bound endpoint gets a new ID. | +| Admission | Checking a client's token and first packet before creating its native peer. | +| Key epoch | One version of an admission key, identified by `keyId`. | + +ICE checks network reachability using STUN packets. DTLS authenticates and +encrypts the connection. SCTP carries the data channels over that connection. +A UDP tuple identifies a packet's source address and port at a host endpoint. ## Version and discovery @@ -24,197 +67,296 @@ Transport and game outcomes are asynchronous observations, never admission prere | Stateless capability | `nethernet.stateless-admission.v1` | | Stateless carrier prefix | `NXS1` | -The configured origin MUST use HTTPS; HTTP is permitted only for loopback -development. Origins are normalized by lowercasing scheme/host and omitting default -ports. Credentials, path, query and fragment are not permitted in the configured -origin. Discovery is unauthenticated `GET`. `provider` and `controlOrigin` MUST -equal that origin. Each operation URL MUST have the same origin, no userinfo or -fragment. Clients MUST disable redirects for discovery and credential-bearing calls. -Encoded paths and query strings are signed exactly as transmitted. - -Discovery contains `provider`, `controlOrigin`, arrays `protocols`, `signatures`, -`profiles`, `modes`, an `operations` map, `authorization`, `limits`, and optional -`extensions`. Clients reject an unsupported protocol/profile/signature/mode or -required extension before transmitting any credentials. This profile defines -all operation names in the table below; URL paths are discovered, not hard-coded. -`/v1/nxs/` is a recommended mapping, not a routing requirement. - -`authorization` has `header: "Authorization"` and `schemes` entries containing -`scheme` and supported `modes`. Schemes are `anonymous-proof-of-work` and -`bearer-token`; an implementation need only advertise the schemes it accepts. -Anonymous creation permits `new-service`. Bearer authorization permits -`new-service` and/or `attach-instance`. Token scope, reuse policy and issuance -remain provider decisions. Every flow proves possession of the instance key. - -Limits contain `maxBodyBytes` (at most 65536), `clockSkewMs` (at most 60000), -`heartbeatIntervalMs` (1000–30000), `leaseMs`, and `maxControlPage` (at most 100). -`checkInVersion: 1` negotiates response-driven scheduling. A provider MUST advertise -all limits it enforces, reject oversize bodies, and return errors as -`{"code":"lowercase_machine_code"}` with an appropriate HTTP failure status. -Clients bound response bodies before parsing them. On transient transport failure, -429, 502, 503 or 504, the supplied client retries at most three attempts with -bounded exponential delay and jitter; `Retry-After` seconds over ten cause a -retry-later result. Retries never extend a granted lease or challenge expiry. +### Provider origin and operation URLs + +The configured origin MUST use HTTPS. HTTP is permitted only for loopback +development. Normalize the origin by lowercasing its scheme and host and omitting +default ports. It cannot contain credentials, a path, a query, or a fragment. + +Fetch discovery with an unauthenticated `GET`. Its `provider` and `controlOrigin` +MUST equal the configured origin. Each operation URL MUST have that same origin +and contain no userinfo or fragment. Clients MUST disable redirects for discovery +and for calls that carry credentials. Sign encoded paths and query strings +exactly as transmitted. + +Discovery contains `provider`, `controlOrigin`, the arrays `protocols`, +`signatures`, `profiles`, and `modes`, an `operations` map, `authorization`, +`limits`, and optional `extensions`. Before sending credentials, clients reject +an unsupported protocol, profile, signature, mode, or required extension. + +The [operation table](#operations) defines the operation names. Clients get their +URLs from discovery. `/v1/nxs/` is a recommended path, but providers +can use other paths. + +### Authorization and limits + +`authorization` contains `header: "Authorization"` and a `schemes` array. Each +entry has a `scheme` and its supported `modes`: + +| Scheme | Allowed modes | +| --- | --- | +| `anonymous-proof-of-work` | `new-service` | +| `bearer-token` | `new-service`, `attach-instance`, or both | + +A provider need only advertise the schemes it accepts. It decides how tokens +are issued, what they authorize, and whether they can be reused. Every flow also +requires proof that the instance owns its signing key. + +| Limit | v1 constraint | +| --- | --- | +| `maxBodyBytes` | At most 65536 | +| `clockSkewMs` | At most 60000 | +| `heartbeatIntervalMs` | 1000–30000 | +| `leaseMs` | Advertised lease duration | +| `maxControlPage` | At most 100 | + +`checkInVersion: 1` enables the provider to set the next check-in time in its +response. A provider MUST advertise every limit it enforces, reject oversized +bodies, and return errors as `{"code":"lowercase_machine_code"}` with an +appropriate HTTP failure status. Clients limit response size before parsing. + +On a transient transport failure or HTTP 429, 502, 503, or 504, the supplied +client makes at most three attempts in total. Retries use exponential delays +with jitter and an upper bound. A `Retry-After` value over ten seconds returns +a retry-later result. Retrying never extends a lease or challenge expiry. ## Registration and persistent identity -Generate a fresh P-384 machine signing key for each logical instance. Persist it -before requesting a challenge; no live replicas may share a key/state directory. -An instance restart reuses its own durable state. Images/templates MUST contain -neither machine identity nor DTLS private keys. Clients lock their state directory, -write private state atomically with owner-only permissions and durable file/directory -sync, and stop advertising healthy readiness after persistence failure. - -The challenge request contains `protocol`, `mode`, `profile`, `publicKeyJwk`, -optional `label`, explicit `authorization: {scheme}`, and optional `placement`. -The bearer credential is sent only as `Authorization: Bearer ` to the -challenge operation. It MUST NOT enter JSON, proofs, persistent state, or logs. -`attach-instance` requires bearer authorization and placement. A bearer token -authorizes the service; a client-provided label never grants authority. - -Placement is `{region,pool,tags?}`. Region and pool are immutable routing labels -matching `[A-Za-z0-9_-]{1,32}` and `[A-Za-z0-9_-]{1,64}` respectively. Tags have at -most 16 keys matching `[A-Za-z0-9_.-]{1,32}` and trimmed string values of 1–64 -characters without control characters. Exact placement is bound into the challenge -and revalidated against token authority at atomic completion. No provider selection -algorithm is implied by these fields. - -The public JWK is EC/P-384 with canonical unpadded base64url `x` and `y` encoding -exactly 48 bytes each, and MUST NOT contain `d`. RFC 7638 thumbprint is SHA-256 of -UTF-8 JSON with members exactly `crv,kty,x,y` in that order. ES384 signatures are -96-byte IEEE-P1363 `r || s`, unpadded base64url; DER and noncanonical base64url fail. - -A challenge contains `protocol`, `signature`, `challengeId`, `nonce`, `audience`, -`thumbprint`, `context`, `contextDigest`, `expiresAt`, `serverTime`, and -`pow: {algorithm:"sha256-leading-zero-bits-v0",difficulty}`. Difficulty is 0–24; -bearer-authorized and recovery flows use zero. An authorization reference is opaque, -never the credential itself. Expiry/server times use integer epoch milliseconds. - -Canonical arrays are UTF-8 JSON without whitespace or Unicode normalization. -Missing context strings are empty strings. `contextDigest` is unpadded base64url -SHA-256 of `[mode,profile,label,authorizationId,serviceId,region,pool,registrationId]`. -When tags are nonempty, append `tagsDigest`, the same digest of sorted `[key,value]` -pairs. The completion proof is: +### Save the instance key + +Generate a fresh P-384 machine signing key for each logical instance. Save it +before requesting a challenge. A restart reuses that instance's saved state; +live replicas cannot share a key or state directory. Images and templates MUST +contain neither machine identity nor DTLS private keys. + +Clients lock their state directory and write private state atomically with +owner-only permissions. Sync both files and directories to durable storage. +If saving state fails, stop advertising healthy readiness. + +### Request a challenge + +The request contains `protocol`, `mode`, `profile`, `publicKeyJwk`, explicit +`authorization: {scheme}`, and optional `label` and `placement`. + +Send a bearer credential only to the challenge operation, in +`Authorization: Bearer `. It MUST NOT appear in JSON, proofs, saved state, +or logs. `attach-instance` requires both bearer authorization and placement. +The token authorizes access to the service; a client-provided label grants no +permission. + +Placement is `{region,pool,tags?}`: + +| Field | Constraint | +| --- | --- | +| `region` | Immutable routing label matching `[A-Za-z0-9_-]{1,32}` | +| `pool` | Immutable routing label matching `[A-Za-z0-9_-]{1,64}` | +| `tags` | At most 16 keys matching `[A-Za-z0-9_.-]{1,32}`; values are trimmed strings of 1–64 characters with no control characters | + +The challenge binds the exact placement. At completion, the provider rechecks +that the token authorizes it as part of the same atomic operation that creates +the registration. These fields do not prescribe how a provider selects a host. + +The public JWK is EC/P-384. Its `x` and `y` values use canonical, unpadded +base64url and each encode exactly 48 bytes. It MUST NOT contain `d`. The RFC 7638 +thumbprint is SHA-256 of UTF-8 JSON with members in this exact order: +`crv,kty,x,y`. ES384 signatures use the 96-byte IEEE-P1363 form `r || s`, encoded +as unpadded base64url. Reject DER signatures and noncanonical base64url. + +The challenge response contains `protocol`, `signature`, `challengeId`, `nonce`, +`audience`, `thumbprint`, `context`, `contextDigest`, `expiresAt`, `serverTime`, +and `pow: {algorithm:"sha256-leading-zero-bits-v0",difficulty}`. + +Proof-of-work difficulty is 0–24. Bearer-authorized and recovery flows use zero. +An authorization reference is an opaque identifier, never the credential itself. +Expiry and server times are integer epoch milliseconds. + +### Complete registration + +Canonical arrays use UTF-8 JSON with no whitespace or Unicode normalization. +Use an empty string for a missing context string. `contextDigest` is the +unpadded base64url SHA-256 digest of: + +```text +[mode,profile,label,authorizationId,serviceId,region,pool,registrationId] +``` + +When tags are nonempty, append `tagsDigest` to that array. Compute `tagsDigest` +in the same way from sorted `[key,value]` pairs. The completion proof is: ```text [protocol,"complete",audience,challengeId,nonce,thumbprint,contextDigest, expiresAt,proofNonce,idempotencyKey] ``` -PoW counts leading zero bits of SHA-256 over those bytes. Completion sends -`protocol,challengeId,proofNonce,idempotencyKey,signature`. The provider MUST check -expiry, binding, signature, difficulty, current authority and single-use completion -atomically with resource creation. Retrying completion MUST NOT replay one-time -key secrets. Recover an interrupted completion through proof of the same key. +Proof of work counts the leading zero bits in SHA-256 of those bytes. Send +`protocol,challengeId,proofNonce,idempotencyKey,signature` to complete registration. +The provider MUST check expiry, binding, signature, difficulty, current authority, +and single-use completion atomically with resource creation. + +Retrying completion MUST NOT return one-time key secrets again. If completion +was interrupted, recover the registration by proving ownership of the same key. Completion returns `protocol,provider,registrationId,serviceId,instanceId,keyId, profile,publicAddress,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, -readiness` and optional one-time `ticketKey` and `extensions`. Persist returned IDs -and key material before activation. Strip secret material from application-facing -registration results and diagnostic output. +readiness`, plus optional one-time `ticketKey` and `extensions`. Save the IDs and +key material before activation. Remove secrets from registration results exposed +to applications and from diagnostic output. ## Signed lifecycle and host profile -Every operational request uses the registered machine key, never the enrollment -bearer token. Required headers are `nxs-instance-id`, `nxs-key-id`, `nxs-timestamp`, +### Sign operational requests + +Use the registered machine key for every operational request. The enrollment +bearer token is used only for the challenge request. + +Required headers are `nxs-instance-id`, `nxs-key-id`, `nxs-timestamp`, `nxs-signature-version`, `nxs-generation`, `nxs-sequence`, `nxs-signature`, and -`idempotency-key`. Timestamp is epoch milliseconds; generation and sequence are -nonnegative integers. Reserve sequence durably before sending. Authentication binds: +`idempotency-key`. The timestamp is epoch milliseconds. Generation and sequence +are nonnegative integers. Save each reserved sequence number before sending its +request. The signature covers this array: ```text [protocol,signatureVersion,audience,method,encodedPathAndQuery,timestamp, instanceId,keyId,idempotencyKey,generation,sequence,base64url(sha256(bodyBytes))] ``` -The empty body hashes as zero bytes. Providers reject stale generations, reused -sequence numbers, invalid timestamps and signatures. An idempotent retry with the -same intent and unchanged semantic request can return its recorded non-secret result; -it cannot reapply an operation. Accepted activation increments generation and resets -sequence; old processes are fenced. Signed stateful operations belong to the active -profile. Recovery and a signed activation are the explicit profile migration boundary. +For an empty body, hash a zero-length byte sequence. Providers reject stale +generations, reused sequence numbers, invalid timestamps, and invalid signatures. +An idempotent retry can return the recorded result, with secrets removed, if its +intent and semantic request are unchanged. It cannot apply the operation again. + +Activation increments the generation and resets the sequence. The provider then +rejects requests from the old process. Signed state-changing operations must use +the active profile. To change profiles, recover the registration and send a +signed activation request. -| Operation | Request | Required behavior/result | +### Operations + +| Operation | Request | Required result or behavior | | --- | --- | --- | -| `challenges` | POST challenge request, optional bearer | Bound registration challenge | -| `complete` | POST completion proof | Registration or recovered registration; secrets only once | -| `recover` | POST `{registrationId,protocol,profile}` | Challenge for current/pending machine key; preserves assigned IDs | -| `activate` | Signed POST `{profile}` | Incremented `leaseGeneration,leaseDeadline`; resets stale host readiness | -| `readiness` | Signed GET | Routability and reasons, optional extension metadata | -| `host-profile` | Signed POST profile below | Immutable/monotonic `revision`; reject unusable candidates or keys | -| `heartbeat` | Signed POST health/status below | Received time, renewed lease and optional check-in schedule | -| `control` | Signed GET, optional cursor | Bounded `commands`, optional `cursor`, `serverTime` | -| `control/ack` | Signed POST `{cursor}` | Acknowledge only completed terminal lifecycle commands | -| `ticket-keys` | Signed POST `{}` | One-time `{ticketKey:{keyId,secret,...}}` for a new epoch | -| `ticket-keys/ack` | Signed POST `{keyId}` | Confirm keys installed before routing with their epoch | -| `ticket-events` / `events` | Signed POST `{events:[...]}` | Idempotent bounded asynchronous observations | -| `rotate` | Signed POST `{publicKeyJwk,proof}` | New `keyId` after proof by replacement key | -| `retire` | Signed POST `{keyId}` | Retire previous machine signing key | -| `drain` | Signed POST `{}` | Stop new routing/admissions, preserve existing sessions | -| `deregister` | Signed POST `{}` | Revoke instance from routing; terminate its registration lifecycle | - -Rotation proof bytes are `[protocol,"rotate",audience,instanceId,oldKeyId, -newThumbprint,generation,idempotencyKey]`. Persist replacement private key before -rotation, then result before retiring the old key. Recovery can resolve an interrupted -rotation using the key thumbprint returned by the provider. +| `challenges` | POST challenge request, optional bearer | Challenge bound to the registration request | +| `complete` | POST completion proof | New or recovered registration; return secrets only once | +| `recover` | POST `{registrationId,protocol,profile}` | Challenge for the current or pending machine key; preserve assigned IDs | +| `activate` | Signed POST `{profile}` | Increment `leaseGeneration`, return `leaseDeadline`, and reset stale host readiness | +| `readiness` | Signed GET | Whether the host can receive new connections, with reasons and optional extension metadata | +| `host-profile` | Signed POST profile below | A `revision` cannot change once published; updates use a higher revision. Reject unusable candidates or keys | +| `heartbeat` | Signed POST health/status below | Receipt time, renewed lease, and optional check-in schedule | +| `control` | Signed GET, optional cursor | Limited `commands` page, optional `cursor`, and `serverTime` | +| `control/ack` | Signed POST `{cursor}` | Acknowledge only lifecycle commands that have finished | +| `ticket-keys` | Signed POST `{}` | One-time `{ticketKey:{keyId,secret,...}}` for a new key epoch | +| `ticket-keys/ack` | Signed POST `{keyId}` | Confirm the key is installed before using its epoch for new connections | +| `ticket-events` / `events` | Signed POST `{events:[...]}` | Limited batches of asynchronous observations; retries do not duplicate them | +| `rotate` | Signed POST `{publicKeyJwk,proof}` | New `keyId` after proof of ownership of the replacement key | +| `retire` | Signed POST `{keyId}` | Retire the previous machine signing key | +| `drain` | Signed POST `{}` | Stop directing and accepting new connections; preserve existing sessions | +| `deregister` | Signed POST `{}` | Stop directing connections to the instance and end its registration | + +### Rotate a machine key + +The rotation proof bytes are `[protocol,"rotate",audience,instanceId,oldKeyId, +newThumbprint,generation,idempotencyKey]`. Save the replacement private key before +requesting rotation. Save the result before retiring the old key. After an +interrupted rotation, recovery can use the provider's returned key thumbprint +to identify which key is current. + +### Publish the host profile `host-profile` contains `candidates`, `dtlsFingerprint`, `credentialKeyId`, `sctpPort`, `maxMessageSize`, and `statelessAdmission: {capability,incarnation}`. -Incarnation is fresh random 16-byte lowercase hex for each bound native endpoint. -The fingerprint is `sha-256 ` followed by colon-separated uppercase certificate -digest bytes. Candidates contain `foundation,component,protocol,priority,address, -port,type`; only reachable, explicitly advertised UDP candidates may be published. -Bind addresses and advertised addresses are separate concepts. Never advertise -wildcard `0.0.0.0`/`::`. NAT and relay reachability must be established by the -deployment/provider; passing a registration test does not prove reachability. - -The host provisions its DTLS certificate/key before profile publication and keeps -the private key local. All peers represented by a published profile use that -certificate. Machine signing keys, DTLS identities and admission keys are distinct. -An endpoint can use a newly generated identity on a later incarnation after publishing -the new fingerprint; a shared permanent fleet certificate is neither required nor advised. - -Admission keys have `keyId` (four uppercase alphanumeric characters), secret -(32–256 UTF-8 characters), optional `notBefore` and `retireAfter` epoch milliseconds. -Install at most eight epochs atomically, acknowledge them, then publish a profile -using an active installed epoch. Hosts reject before activation/after retirement -and erase retired material. They do not extend token expiry when rotating keys. - -Heartbeat contains `healthy,capacity,load,protocolVersion,build,hostProfileRevision, -clockUnixMillis`, optional `region,serverStatus,checkInVersion`. Capacity and load -are routing observations, independent of advertised player/max-player counts. -Status contains `name,protocol,version,level,players,maxPlayers,gameType`. -Publishing failures do not refresh old status timestamps. Readiness requires current -identity/generation, a live lease, usable fresh host profile and installed key acknowledgment. -Optional product extensions cannot gate core readiness. - -When check-in v1 is negotiated, the heartbeat response has ISO8601 `receivedAt` and -`checkIn: {version:1,afterMillis,nextCheckInAt,leaseExpiresAt,minUpdateIntervalMillis, -controlPollAfterMillis}`. Absolute times are epoch milliseconds. `nextCheckInAt` -precedes lease expiry. Hosts schedule against monotonic clocks and count network -time against the interval; changed activity/status may prompt an earlier rate-limited -heartbeat. Restarts publish immediately and reset old schedules. Provider outage -expires routing leases but does not itself tear down established sessions. - -Lifecycle controls supported by this profile are `noop,drain,suspend,revoke`. -Unknown controls are not silently acknowledged; process later known lifecycle -commands even while an earlier unknown command prevents advancing the page cursor. -`join-admission` is explicitly not a v1 control: admissions never wait for it. -Event batches have at most 100 entries and retain only redacted correlation, -stage/type, timestamp and bounded reason fields. Never send SDP, private keys, -player identity or game payloads as telemetry. Transport establishment is distinct -from `ticket.game_joined` (game play-ready) and `ticket.game_rejected`. +Generate a fresh random 16-byte `incarnation`, encoded as lowercase hex, for +each bound native endpoint. The fingerprint is `sha-256 ` followed by the +certificate's digest bytes in colon-separated uppercase hex. + +Each candidate contains `foundation,component,protocol,priority,address,port,type`. +Publish only reachable UDP candidates that are explicitly chosen for advertisement. +The bind address and the advertised address serve different purposes. A host can +bind to all interfaces, but it cannot advertise wildcard `0.0.0.0` or `::`. +The deployment or provider must establish reachability through NAT or a relay; +a passing registration test does not prove that clients can reach the address. + +Prepare the host's DTLS certificate and key before publishing its profile. Keep +the private key local. All peers using that profile use that certificate, so +clients see the fingerprint the provider advertised. The host may use a new +certificate for a later endpoint incarnation after publishing its new fingerprint. +A permanent certificate shared across a fleet is neither required nor advised. + +Three types of key have separate jobs: + +| Key | Purpose | +| --- | --- | +| Machine signing key | Authenticate the host's requests to the provider | +| DTLS certificate and private key | Authenticate the host during the client connection | +| Admission key | Protect and validate the client's admission token | + +### Install admission keys + +Each key has a `keyId` of four uppercase alphanumeric characters, a `secret` of +32–256 UTF-8 characters, and optional `notBefore` and `retireAfter` times in epoch +milliseconds. Install at most eight epochs atomically and acknowledge them. Then +publish a profile that uses an active, installed epoch. + +Reject tokens before the key's activation time or after its retirement time. +Erase retired key material. Rotating keys does not extend token expiry. + +### Send heartbeats and report readiness + +A heartbeat contains `healthy,capacity,load,protocolVersion,build,hostProfileRevision, +clockUnixMillis` and optional `region,serverStatus,checkInVersion`. Capacity and +load describe routing capacity; they are independent of the advertised player +and maximum-player counts. Status contains +`name,protocol,version,level,players,maxPlayers,gameType`. A failed publication +does not refresh the timestamp of previously published status. + +A host is ready to receive connections only when it has a current identity and +generation, a live lease, a usable fresh host profile, and acknowledged installed +keys. Optional product extensions cannot affect this core readiness check. + +With check-in v1, the heartbeat response contains ISO8601 `receivedAt` and: + +```text +checkIn: {version:1,afterMillis,nextCheckInAt,leaseExpiresAt,minUpdateIntervalMillis, + controlPollAfterMillis} +``` + +Absolute times in `checkIn` are epoch milliseconds. `nextCheckInAt` is before +lease expiry. Hosts use monotonic clocks for scheduling and include network time +in the interval. Changed activity or status can trigger an earlier heartbeat, +subject to the rate limit. On restart, publish immediately and discard the old +schedule. If the provider is unavailable, routing leases expire; existing sessions +are not closed solely because of that outage. + +### Handle controls and report outcomes + +This profile supports `noop,drain,suspend,revoke`. Do not silently acknowledge an +unknown control. An unknown command can prevent advancing the page cursor, but +later known lifecycle commands still need processing. `join-admission` is not a +v1 control; accepting a client never waits for that command. + +Event batches contain at most 100 entries. Keep only redacted correlation data, +stage or type, timestamp, and reason fields with size limits. Never send SDP, +private keys, player identity, or game payloads as telemetry. A working transport +connection is a separate outcome from `ticket.game_joined` (ready to play) or +`ticket.game_rejected`. ## Stateless admission carrier -The client's first STUN USERNAME is `:`. -`answerUfrag = "NXS1" + keyId + unpaddedBase64(nonce || ciphertext || tag)`. -Use standard base64 alphabet (ICE permits `+` and `/`), not base64url. Total ufrag -length is at most 256 characters. Noncanonical encoding, trailing padding, wrong -prefix, unknown epochs and oversized inputs are rejected before allocation. +### Carry the token in the ICE username + +The client's first STUN USERNAME is `:`, where: -AES-256-GCM uses random 12-byte nonce and 16-byte tag. Its key is +```text +answerUfrag = "NXS1" + keyId + unpaddedBase64(nonce || ciphertext || tag) +``` + +Use the standard base64 alphabet, including `+` and `/`, which ICE permits. +Do not use base64url. The total ufrag length is at most 256 characters. Before +allocating peer state, reject noncanonical encoding, trailing padding, a wrong +prefix, unknown key epochs, and oversized input. + +AES-256-GCM uses a random 12-byte nonce and a 16-byte tag. Its key is `HMAC-SHA256(secret, "nxs-stateless-aead-v1" || NUL || audience)`. -Audience is `nxs-stateless-host-v1/`. AAD is +The audience is `nxs-stateless-host-v1/`. The additional authenticated +data (AAD) is `"nxs-stateless-admission-v1" || NUL || ("NXS1"+keyId) || NUL || audience || NUL || clientUfrag`. | Plaintext offset | Size | Meaning, unsigned big-endian where numeric | @@ -228,50 +370,75 @@ Audience is `nxs-stateless-host-v1/`. AAD is | 66 | 1 | Client ICE password length, 22–91 | | 67 | N | Client ICE password in ICE base64 alphabet | -The host's local ICE password is unpadded standard base64 of the first 24 bytes of +The host's local ICE password is the unpadded standard base64 encoding of the +first 24 bytes of `HMAC-SHA256(secret, "nxs-stateless-ice-v1" || NUL || audience || NUL || answerUfrag)`. -The ticket correlation ID is the first 16 bytes of SHA-256 of the ASCII answer ufrag, -encoded lowercase hex. Maximum admitted token TTL is 120 seconds; the supplied -implementation uses 60 seconds. Hosts validate expiry, bounds, GCM, client binding -and raw STUN MESSAGE-INTEGRITY before tuple promotion or native peer creation. -The resulting DTLS handshake MUST verify the client fingerprint from the token. - -Only identical-token retransmissions from the same UDP tuple may reuse a reservation. -Token replay from another tuple and conflicting admission on an occupied tuple fail -closed. Bound sessions, pending handshakes, replay cache, callbacks and datagram queues. -Peer creation happens outside the mux callback lock. Deliver/replay the authenticated -first datagram after native registration so the first STUN request receives a response. -Do not release admission capacity until native teardown actually completes. +The ticket correlation ID is the first 16 bytes of SHA-256 of the ASCII answer +ufrag, encoded as lowercase hex. + +### Validate the first packet + +A token can be valid for at most 120 seconds. The supplied implementation uses +60 seconds. Before assigning the UDP tuple to a peer or creating a native peer, +the host checks expiry, field bounds, GCM authentication, client binding, and the +raw STUN MESSAGE-INTEGRITY. The DTLS handshake MUST then verify the client +fingerprint from the token. + +Only a retransmission of the identical token from the same UDP tuple can reuse +a reservation. Reject the same token from another tuple. Also reject a conflicting +admission on an occupied tuple. + +Limit the number of sessions, pending handshakes, replay-cache entries, callbacks, +and queued datagrams. Create peers outside the UDP mux callback lock. After +registering the native peer, deliver or replay the authenticated first datagram +so that its STUN request receives a response. Release admission capacity only +after native teardown has actually finished. ## Optional extensions and compatibility -`extensions` is an object with at most 16 reverse-DNS namespace keys and 16384 bytes -of encoded UTF-8 JSON. Each value is `{version:positiveInteger,critical:boolean,data:object}`. -Namespace keys are lowercase domain-style labels, at most 128 characters. Unknown -optional extensions are passed through/ignored, never automatically executed. -Unsupported critical extensions fail before credentials or activation. Core semantics -cannot be redefined by an optional extension. Bodies and operation paths remain -authenticated by the surrounding TLS/signature boundary. - -An extension may advertise `data.operations` URLs. An application may explicitly -request an operation only after validating its namespace/version and meaning. -The generic transport still enforces same-origin URLs and signs their exact path. -Account claim actions are a product extension; NXS assigns them no core meaning. - -Previously persisted IDs/keys may be recovered into this profile through explicit -`recover {registrationId,protocol,profile}` and signed `activate {profile}`. Verify -the same key and origin, preserve IDs and DTLS files, then atomically record the new -profile/generation. Legacy protocol bytes MUST NOT be relabelled as v1. Providers -may retain separately negotiated legacy adapters; the neutral Java module implements -only NXS. Rollback requires explicit signed profile activation and recovery with the -previous client; never bypass machine authentication or copy a live state directory. +### Extensions + +Providers can add optional application metadata without making it part of NXS. +For example, a product could supply an account-claim link. NXS does not define +what claiming an account means or require other providers to implement it. + +`extensions` is an object with at most 16 reverse-DNS namespace keys, such as +`com.example.feature`, and at most 16384 bytes of encoded UTF-8 JSON. Keys use +lowercase domain-style labels and have at most 128 characters. Each value is +`{version:positiveInteger,critical:boolean,data:object}`. + +Pass through or ignore unknown optional extensions; never execute them +automatically. Reject unsupported critical extensions before sending credentials +or activating. An optional extension cannot change the core protocol rules. +TLS and request signatures still authenticate bodies and operation paths. + +An extension can advertise URLs in `data.operations`. An application can request +one of these operations only after validating the namespace, version, and meaning. +The generic transport still requires the same provider origin and signs the +exact path. + +### Upgrade and rollback + +Recover saved IDs and keys into this profile with +`recover {registrationId,protocol,profile}`, then signed `activate {profile}`. +Verify the same key and origin, preserve IDs and DTLS files, and record the new +profile and generation atomically. Legacy protocol bytes MUST NOT be relabelled +as v1. Providers may keep separately negotiated legacy adapters; the neutral +Java module implements only NXS. + +Rollback uses the previous client with explicit recovery and signed profile +activation. Never bypass machine authentication or copy a live state directory. ## Conformance -`node docs/external-signalling/fixtures.mjs` verifies independent JavaScript signing, -encryption and fixture hashes. `--write` regenerates public test signatures. -The JVM suites consume these exact files via Gradle resources. The independent -provider implements registration, signed lifecycle, status, keys, outcomes, drain -and recovery without a product account system. Native tests separately exercise -raw STUN admission and DTLS transport. Stock-client admission, gameplay and two-host -routing must be reported separately from fixture/native conformance. +Run `node docs/external-signalling/fixtures.mjs` to verify the independent +JavaScript signing, encryption, and fixture hashes. `--write` regenerates public +test signatures. The JVM suites load these same files through Gradle resources. + +The independent test provider covers registration, signed operations, status, +keys, outcomes, drain, and recovery without a product account system. Native +tests separately check raw STUN admission and DTLS transport. + +Report stock-client admission, gameplay, and routing across two hosts separately +from fixture and native tests. Passing those tests does not prove that a stock +client can join and play. diff --git a/docs/technical-writing.md b/docs/technical-writing.md new file mode 100644 index 00000000..f45f710d --- /dev/null +++ b/docs/technical-writing.md @@ -0,0 +1,41 @@ +# Writing for reviewers and implementers + +Explain what changes, why it matters, and how the reader can check it. Assume the +reader understands software but has not followed this project's discussions. + +- Start with the problem and the resulting behavior. Put audit details later. +- Name the actor and action: “the host checks the token,” for example. +- Give each sentence one main idea. Split sentences that ask the reader to hold + several conditions in mind. +- Use familiar words. Keep a technical term when it adds precision, and explain + it on first use. +- Show a small example before a complex rule. Use a table for comparisons or a + sequence for a flow. +- Keep exact API names, field names, limits, and signing formats. Simpler prose + must not weaken a protocol requirement. +- State what the tests establish and what remains untested. Support performance + or reliability claims with measurements. +- Link to detailed history and build records. Keep attribution and dependency + tables where reviewers need them, but avoid repeating full commit hashes in + prose. + +For a PR, lead with the behavior change, then explain the approach and relevant +validation. For an aggregate proposal, also show who contributed each part and +which future PRs depend on others. Describe the final change rather than the +sequence of attempts used to build it. + +For a specification, start with its purpose, a short example, and the terms a +reader needs. Follow with the exact rules. Keep optional product behavior clearly +identified so an independent implementation knows what it needs to support. + +| Before | After | +| --- | --- | +| “Old processes are fenced.” | “After activation, the provider rejects requests from the previous generation.” | +| “Bounded namespaced extensions.” | “Optional metadata uses named extensions with limits on their number and size.” | +| “Persistent endpoint identity.” | “The host reuses its DTLS certificate, so clients see the advertised fingerprint.” | + +Before publishing, read the opening paragraph aloud. A reviewer should be able +to explain the benefit without first reading the implementation. + +This checklist follows [Google's advice on short sentences](https://developers.google.com/tech-writing/one/short-sentences) +and [Microsoft's style and voice guidance](https://learn.microsoft.com/en-us/style-guide/top-10-tips-style-voice). From c41512bbc635bbb0ebb3e0d668b15350d5542211 Mon Sep 17 00:00:00 2001 From: Zulu Date: Sun, 6 Sep 2026 20:45:38 +0100 Subject: [PATCH 07/15] Keep admission packets native during asynchronous validation --- docs/contribution-provenance.md | 8 +- docs/external-signalling/README.md | 17 +- external-signalling/README.md | 6 +- .../StatelessAdmissionValidator.java | 15 +- .../admission/AdmissionGateTest.java | 147 +++++----- .../admission/AdmissionPrimitiveProbe.java | 262 +++++------------- .../NativeAdmissionIntegrationTest.java | 70 ++++- .../StatelessAdmissionValidatorTest.java | 50 ++-- .../nethernet/admission/AdmissionGate.java | 101 +++---- .../nethernet/admission/AdmissionRequest.java | 23 ++ .../admission/AdmissionValidator.java | 6 +- .../NativeAdmissionServerChannel.java | 178 ++++++------ .../nethernet/admission/StunBinding.java | 78 ------ .../admission/VerifiedAdmission.java | 4 +- .../util/nethernet/NetherNetLogging.java | 39 +-- 15 files changed, 443 insertions(+), 561 deletions(-) create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionRequest.java delete mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/StunBinding.java diff --git a/docs/contribution-provenance.md b/docs/contribution-provenance.md index 1154b892..f3f975c8 100644 --- a/docs/contribution-provenance.md +++ b/docs/contribution-provenance.md @@ -44,8 +44,12 @@ transient, bounded opaque data. An optional application adapter owns its own account actions. The independent provider exercises the full host lifecycle and four authorization/placement journeys without such an adapter. -N4 authenticates raw STUN before promotion/allocation, bounds replay and capacity, -keeps creation outside mux locks, and retains capacity until native teardown. +N4 validates admission tokens in Java and STUN integrity in native code before +creating a peer. It limits pending attempts, used tokens and active sessions. +The native listener retains the first request during asynchronous validation, +coalesces duplicates and continues after acceptance without a client retry. +Peer creation runs outside the receive lock, and capacity remains reserved until +native teardown finishes. The admission token binds the client fingerprint, ICE credentials, endpoint incarnation, expiry and opaque caller context. Native integration tests cover both data channels, first-datagram response, invalid ingress, replay, key retirement, diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md index 1c7cd25e..1791fe48 100644 --- a/docs/external-signalling/README.md +++ b/docs/external-signalling/README.md @@ -388,11 +388,18 @@ Only a retransmission of the identical token from the same UDP tuple can reuse a reservation. Reject the same token from another tuple. Also reject a conflicting admission on an occupied tuple. -Limit the number of sessions, pending handshakes, replay-cache entries, callbacks, -and queued datagrams. Create peers outside the UDP mux callback lock. After -registering the native peer, deliver or replay the authenticated first datagram -so that its STUN request receives a response. Release admission capacity only -after native teardown has actually finished. +Limit the number of sessions, pending handshakes, used-token records, callbacks, +and retained requests. Native code retains the first STUN request while the +application validates its token asynchronously. Duplicate requests for the same +pending attempt share that decision. The application receives parsed request +metadata, not packet bytes. + +Create peers outside the UDP receive lock and only after native STUN integrity +verification succeeds. Then process the retained request immediately: completing +admission MUST NOT depend on the client retransmitting. Established transport +packets stay native. Release admission capacity only after native teardown has +actually finished. A failed integrity check MUST NOT consume the token, since a +copied token alone does not prove that the sender has its ICE password. ## Optional extensions and compatibility diff --git a/external-signalling/README.md b/external-signalling/README.md index 558e037e..9ec8fd25 100644 --- a/external-signalling/README.md +++ b/external-signalling/README.md @@ -21,8 +21,10 @@ known namespaces and invoke only their advertised same-origin operations. The co performs product account/claim actions or stages individual joins from provider control. `NativeProviderTransport` publishes the actual bound UDP endpoint and certificate -fingerprint before accepting clients. Its admission validator verifies an NXS1 token and -raw STUN integrity before creating a native peer. The optional native test task is +fingerprint before accepting clients. Java validates the NXS1 token from incoming +ICE metadata; native code verifies STUN integrity before creating a peer. The +first request stays native during asynchronous validation, and acceptance does +not depend on a client retry. Established transport packets stay native. The optional native test task is `:external-signalling:nativeAdmissionTest`; native packaging must match the immutable JNI revision in `native-dependencies.properties`. Never combine new headers with older native binaries. Native tests prove transport conformance, not stock-client gameplay. diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java index b89728f3..0ce41a31 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java @@ -1,7 +1,7 @@ package org.cloudburstmc.netty.signalling.admission; import dev.kastle.netty.channel.nethernet.admission.AdmissionValidator; -import dev.kastle.netty.channel.nethernet.admission.StunBinding; +import dev.kastle.netty.channel.nethernet.admission.AdmissionRequest; import dev.kastle.netty.channel.nethernet.admission.VerifiedAdmission; import javax.crypto.Cipher; import javax.crypto.Mac; @@ -12,7 +12,7 @@ import java.security.MessageDigest; import java.util.*; -/** NXS1 validation using only a background key snapshot and raw client STUN. */ +/** NXS1 token validation using locally installed keys and the incoming ICE username. */ 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); } @@ -57,11 +57,11 @@ public synchronized void retireKeys(long nowMillis) { 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; + @Override public synchronized VerifiedAdmission validate(AdmissionRequest request, long nowMillis) { + if (request == null) return null; byte[] plaintext = null; try { - String token = binding.localUfrag(); + String token = request.localUfrag(); if (token.length() < 8 || !token.startsWith("NXS1")) return null; String keyId = token.substring(4, 8); Material key = keys.get(keyId); @@ -71,7 +71,7 @@ public synchronized void retireKeys(long nowMillis) { 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("nxs-stateless-admission-v1\0" + token.substring(0, 8) + "\0" + audience + "\0" + binding.remoteUfrag())); + cipher.updateAAD(utf8("nxs-stateless-admission-v1\0" + token.substring(0, 8) + "\0" + audience + "\0" + request.remoteUfrag())); plaintext = cipher.doFinal(Arrays.copyOfRange(envelope, 12, envelope.length)); if (plaintext.length < 89) return null; ByteBuffer body = ByteBuffer.wrap(plaintext); @@ -86,8 +86,7 @@ public synchronized void retireKeys(long nowMillis) { String remotePassword = new String(plaintext, 67, length, StandardCharsets.US_ASCII); if (sctp < 1 || max < 1 || max > 262144 || !remotePassword.matches("[A-Za-z0-9+/]{22,91}")) return null; String localPassword = BASE64.encodeToString(Arrays.copyOf(hmac("HmacSHA256", key.secret(), utf8("nxs-stateless-ice-v1\0" + audience + "\0" + token)), 24)); - if (!binding.verify(packet, localPassword)) return null; - return new VerifiedAdmission(tokenId(token), token, localPassword, binding.remoteUfrag(), remotePassword, + return new VerifiedAdmission(tokenId(token), token, localPassword, request.remoteUfrag(), remotePassword, "sha-256 " + HexFormat.ofDelimiter(":").withUpperCase().formatHex(fingerprint), sctp, max, expiresAt, networkId, HexFormat.of().formatHex(identity), keyId); } catch (Exception invalid) { return null; } diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java index 077f128e..9c74b57b 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java @@ -8,91 +8,86 @@ 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); + final InetSocketAddress other = new InetSocketAddress("127.0.0.1", 23451); AdmissionGate gate() { return new AdmissionGate(new AdmissionGate.Limits(2, 2, 1, 1000), validator()); } - @Test void pendingLimitWarningsAreAuthenticatedAggregatedAndRateLimited() throws Exception { + AdmissionRequest elsewhere() { return new AdmissionRequest(token, remote, other); } + + @Test void invalidTokensNeverReserveCapacity() { + var gate = gate(); + for (int i = 0; i < 1000; i++) assertNull(gate.reserve(request("invalidToken", remote), now, 0)); + assertEquals(0, gate.stats().sessions()); assertEquals(0, gate.stats().claims()); + assertEquals(1000, gate.stats().invalid()); + } + @Test void concurrentAttemptsShareOneTokenReservation() throws Exception { + var gate = gate(); + try (var executor = Executors.newFixedThreadPool(8)) { + List> calls = new ArrayList<>(); + for (int i = 0; i < 64; i++) calls.add(() -> gate.reserve(request(), now, 0)); + int reserved = 0; + for (var result : executor.invokeAll(calls)) if (result.get() != null) reserved++; + assertEquals(1, reserved); + } + assertEquals(1, gate.stats().sessions()); assertEquals(1, gate.stats().pending()); + assertNull(gate.reserve(elsewhere(), now, 0)); + } + @Test void nativeVerificationFailureDoesNotConsumeToken() { + var gate = gate(); + var forged = gate.reserve(request(), now, 0); + assertNotNull(forged); assertEquals(0, gate.stats().accepted()); + gate.invalidNativeRequest(); assertTrue(gate.finish(forged)); + assertEquals(0, gate.stats().claims()); assertEquals(0, gate.stats().sessions()); + var legitimate = gate.reserve(elsewhere(), now, 1); + assertNotNull(legitimate); assertTrue(gate.ready(legitimate)); + assertFalse(gate.ready(legitimate)); assertEquals(1, gate.stats().accepted()); + } + @Test void acceptedTokensCannotAllocateAgainAndActiveSessionsOutliveTokenExpiry() { + var gate = gate(); var r = gate.reserve(request(), now, 0); + assertTrue(gate.ready(r)); gate.connected(r); + assertNull(gate.reserve(elsewhere(), now, 0)); + assertEquals(1, gate.stats().replayRejected()); + assertTrue(gate.sweep(now + 120_000, 120_000_000_000L).isEmpty()); + assertNotNull(gate.admission(r)); + assertTrue(gate.finish(r)); assertNull(gate.admission(r)); + assertNull(gate.reserve(request(), now, 0)); + assertEquals(1, gate.stats().claims()); + gate.sweep(now + 120_000, 120_000_000_000L); assertEquals(0, gate.stats().claims()); + } + @Test void timeoutAndShutdownKeepCapacityUntilNativeTeardownCompletes() { + var gate = gate(); var r = gate.reserve(request(), now, 0); assertTrue(gate.ready(r)); + assertEquals(List.of(r), gate.sweep(now + 1000, 1_000_000_000L)); + assertTrue(gate.sweep(now + 2000, 2_000_000_000L).isEmpty()); + assertNull(gate.admission(r)); assertEquals(1, gate.stats().sessions()); + assertTrue(gate.finish(r)); assertEquals(0, gate.stats().sessions()); + gate = gate(); r = gate.reserve(request(), now, 0); + assertEquals(List.of(r), gate.close()); + assertFalse(gate.ready(r)); assertNull(gate.admission(r)); + assertEquals(1, gate.stats().pending()); assertNull(gate.reserve(request(), now, 0)); + assertTrue(gate.finish(r)); assertEquals(0, gate.stats().pending()); assertEquals(0, gate.stats().claims()); + } + @Test void drainingRejectsNewReservations() { + var gate = gate(); gate.drain(); + assertNull(gate.reserve(request(), now, 0)); + assertEquals(0, gate.stats().claims()); assertEquals(1, gate.stats().capacityRejected()); + } + @Test void pendingWarningsAreAggregatedAndRateLimited() throws Exception { assertEquals(1024, AdmissionGate.Limits.defaults().pending()); - var trusted = validator().validate(valid, StunBinding.parse(valid), now); + var trusted = validator().validate(request(), now); var v = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); var gate = new AdmissionGate(new AdmissionGate.Limits(2, 4, 1, 1000), v); - var work = new ArrayBlockingQueue(1); - var answer1 = TestSignallingProvider.answer(trusted.remoteDescription(), trusted.remoteFingerprint(), 49199, now + 30_000, TestSignallingProvider.AUDIENCE, false); - var answer2 = TestSignallingProvider.answer(trusted.remoteDescription(), trusted.remoteFingerprint(), 49199, now + 30_000, TestSignallingProvider.AUDIENCE, false); - byte[] packet1 = binding(answer1.token() + ":" + remote, answer1.password()); - byte[] packet2 = binding(answer2.token() + ":" + remote, answer2.password()); - gate.ingress(packet1, first, now, 0, work::add); var r = work.remove(); - byte[] invalid = binding(answer2.token() + ":" + remote, "wrong-password-000000000000"); - assertFalse(gate.ingress(invalid, other, now, 0, work::add)); + var first = TestSignallingProvider.answer(trusted.remoteDescription(), trusted.remoteFingerprint(), 49199, now + 30_000, TestSignallingProvider.AUDIENCE, false); + var next = TestSignallingProvider.answer(trusted.remoteDescription(), trusted.remoteFingerprint(), 49199, now + 30_000, TestSignallingProvider.AUDIENCE, false); + var r = gate.reserve(request(first.token(), remote), now, 0); + var nextRequest = new AdmissionRequest(next.token(), remote, other); + assertNull(gate.reserve(request("invalidToken", remote), now, 0)); assertNull(gate.pollPendingLimitWarning(0)); - for (int i = 0; i < 3; i++) assertFalse(gate.ingress(packet2, other, now, 0, work::add)); + for (int i = 0; i < 3; i++) assertNull(gate.reserve(nextRequest, now, 0)); assertEquals(new AdmissionGate.PendingLimitWarning(1, 1, 3), gate.pollPendingLimitWarning(0)); - assertTrue(work.isEmpty()); assertEquals(1, gate.stats().claims()); - for (int i = 0; i < 2; i++) assertFalse(gate.ingress(packet2, other, now, 0, work::add)); + for (int i = 0; i < 2; i++) assertNull(gate.reserve(nextRequest, now, 0)); assertNull(gate.pollPendingLimitWarning(4_999_999_999L)); - assertArrayEquals(packet1, gate.ready(r)); - // A short burst must still be reported even after the queue has drained. + assertTrue(gate.ready(r)); assertEquals(new AdmissionGate.PendingLimitWarning(1, 1, 2), gate.pollPendingLimitWarning(5_000_000_000L)); - assertNull(gate.pollPendingLimitWarning(10_000_000_000L)); - assertFalse(gate.ingress(packet2, other, now, 0, work::add)); - assertEquals(1, work.size()); assertEquals(1, gate.stats().pending()); + assertNotNull(gate.reserve(nextRequest, now, 0)); assertEquals(1, gate.stats().pending()); assertEquals(5, gate.stats().capacityRejected()); } - @Test void firstAuthenticatedPacketIsOwnedDeferredAndTransferredOnlyOnce() { - var gate = gate(); var work = new ArrayBlockingQueue(1); - byte[] input = valid.clone(); - assertFalse(gate.ingress(input, first, now, 0, work::add)); - var r = work.remove(); Arrays.fill(input, (byte)0); - for (int i = 0; i < 10; i++) assertFalse(gate.ingress(valid, first, now, 0, work::add)); - assertTrue(work.isEmpty()); assertEquals(1, gate.stats().pending()); - assertArrayEquals(valid, gate.ready(r)); assertEquals(0, gate.stats().pending()); - assertNull(gate.ready(r)); // duplicate readiness cannot replay again - } - @Test void closedAndTimedOutReservationsNeverReleaseDeferredPackets() { - var gate = gate(); var work = new ArrayBlockingQueue(1); - gate.ingress(valid, first, now, 0, work::add); var r = work.remove(); - gate.close(); - assertNull(gate.ready(r)); assertNull(gate.admission(r)); - gate = gate(); var timed = gate; - timed.ingress(valid, first, now, 0, work::add); r = work.remove(); - timed.sweep(now + 1000, 1_000_000_000L); - assertNull(timed.ready(r)); - } - @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()); assertArrayEquals(valid, 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)); - assertNull(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/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java index 9a04ae69..fcff52b0 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java @@ -3,206 +3,86 @@ // The original file license is preserved; see LICENSES/MPL-2.0.txt. package org.cloudburstmc.netty.signalling.admission; +import dev.kastle.netty.channel.nethernet.admission.*; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; import tel.schich.libdatachannel.*; - -import javax.crypto.Cipher; -import javax.crypto.Mac; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; import java.net.*; -import java.nio.*; -import java.nio.charset.StandardCharsets; -import java.nio.file.*; -import java.security.*; -import java.security.cert.CertificateFactory; -import java.util.*; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; import java.util.concurrent.*; import java.util.concurrent.atomic.*; -/** Bounded feasibility probe, not the production NXS policy implementation. */ +/** Standalone wire probe using the same admission adapter as the running host. */ public final class AdmissionPrimitiveProbe { - static final String AUDIENCE="nxs-stateless-host-v1/0123456789abcdef0123456789abcdef"; - static final String SECRET="stateless-fixture-secret-32-bytes-minimum"; - static final String HEADER="NXS1K001"; - static final Base64.Encoder B64=Base64.getEncoder().withoutPadding(); - static final InetAddress LOOPBACK=InetAddress.getLoopbackAddress(); - static final int PORT=49184; - static byte[] bytes(String s) { return s.getBytes(StandardCharsets.UTF_8); } - static String field(String sdp,String name) { - return sdp.lines().filter(x->x.startsWith("a="+name+":")).findFirst().orElseThrow().substring(name.length()+3).trim(); - } - static byte[] hmac(String algorithm,byte[] key,byte[] input) throws Exception { - Mac mac=Mac.getInstance(algorithm); mac.init(new SecretKeySpec(key,algorithm)); return mac.doFinal(input); - } - static byte[] encryptionKey() throws Exception { - return hmac("HmacSHA256",bytes(SECRET),bytes("nxs-stateless-aead-v1\0"+AUDIENCE)); - } - static String password(String token) throws Exception { - return B64.encodeToString(Arrays.copyOf(hmac("HmacSHA256",bytes(SECRET),bytes("nxs-stateless-ice-v1\0"+AUDIENCE+"\0"+token)),24)); - } - static byte[] aad(String clientUfrag) { return bytes("nxs-stateless-admission-v1\0"+HEADER+"\0"+AUDIENCE+"\0"+clientUfrag); } - // Signalling side only. Host receives NONE of these arguments out of band. - static String mint(String offer,int passwordLength,boolean wrongFingerprint) throws Exception { - String pwd=field(offer,"ice-pwd"); - check(pwd.length()==passwordLength,"client password length"); - byte[] fingerprint=HexFormat.of().parseHex(field(offer,"fingerprint").substring(8).replace(":","")); - if(wrongFingerprint) fingerprint[0]^=1; - ByteBuffer plain=ByteBuffer.allocate(67+pwd.length()); - plain.putInt((int)(System.currentTimeMillis()/1000+30)).put(fingerprint).putShort((short)5000).putInt(262144); - plain.put(new byte[16]).putLong(42).put((byte)pwd.length()).put(bytes(pwd)); - byte[] nonce=new byte[12]; new SecureRandom().nextBytes(nonce); - Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(encryptionKey(),"AES"),new GCMParameterSpec(128,nonce)); - cipher.updateAAD(aad(field(offer,"ice-ufrag"))); - byte[] encrypted=cipher.doFinal(plain.array()); Arrays.fill(plain.array(),(byte)0); - return HEADER+B64.encodeToString(ByteBuffer.allocate(12+encrypted.length).put(nonce).put(encrypted).array()); - } - record Admission(String token,String clientUfrag,String clientPassword,String fingerprint,int sctp,int max,String tuple,long firstValidNanos) { - @Override public String toString() { return "Admission[redacted]"; } - String offer() { - 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:"+clientUfrag+"\r\na=ice-pwd:"+clientPassword+"\r\na=fingerprint:sha-256 "+fingerprint+ - "\r\na=sctp-port:"+sctp+"\r\na=max-message-size:"+max+"\r\n"; - } - } - // Host uses only the packet plus background key/profile. No offer or client cache input. - static Admission validate(byte[] packet,String tuple) throws Exception { - 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) return null; - String username=null; int integrity=-1; - for(int i=20;ipacket.length) return null; - int type=Short.toUnsignedInt(b.getShort(i)), len=Short.toUnsignedInt(b.getShort(i+2)); - if(i+4+len>packet.length) return null; - if(type==6) { if(username!=null || integrity!=-1) return null; username=new String(packet,i+4,len,StandardCharsets.US_ASCII); } - if(type==8) { if(integrity!=-1 || len!=20 || username==null) return null; integrity=i; } - i+=4+((len+3)&~3); if(i>packet.length) return null; - } - if(username==null || integrity<0) return null; - String[] names=username.split(":",-1); - if(names.length!=2 || names[0].length()>256 || !names[0].startsWith(HEADER) || !names[1].matches("[A-Za-z0-9+/]{4,256}")) return null; - byte[] envelope=Base64.getDecoder().decode(names[0].substring(8)); - if(envelope.length<117 || !B64.encodeToString(envelope).equals(names[0].substring(8))) return null; - Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.DECRYPT_MODE,new SecretKeySpec(encryptionKey(),"AES"),new GCMParameterSpec(128,Arrays.copyOf(envelope,12))); - cipher.updateAAD(aad(names[1])); - byte[] raw=cipher.doFinal(Arrays.copyOfRange(envelope,12,envelope.length)); - try { - ByteBuffer plain=ByteBuffer.wrap(raw); - long expiry=Integer.toUnsignedLong(plain.getInt())*1000; - if(expiry<=System.currentTimeMillis() || expiry>System.currentTimeMillis()+60000) return null; - byte[] fp=new byte[32]; plain.get(fp); - int sctp=Short.toUnsignedInt(plain.getShort()), max=plain.getInt(); - plain.position(66); int len=Byte.toUnsignedInt(plain.get()); - if(len<22 || len>91 || plain.remaining()!=len || sctp==0 || max<1 || max>262144) return null; - byte[] pwd=new byte[len];plain.get(pwd); - String remotePassword=new String(pwd,StandardCharsets.US_ASCII);Arrays.fill(pwd,(byte)0); - if(!remotePassword.matches("[A-Za-z0-9+/]{22,91}")) return null; - byte[] signed=Arrays.copyOf(packet,integrity); - ByteBuffer.wrap(signed).putShort(2,(short)(integrity+24-20)); - byte[] expected=hmac("HmacSHA1",bytes(password(names[0])),signed); - if(!MessageDigest.isEqual(expected,Arrays.copyOfRange(packet,integrity+4,integrity+24))) return null; - return new Admission(names[0],names[1],remotePassword,HexFormat.ofDelimiter(":").withUpperCase().formatHex(fp),sctp,max,tuple,System.nanoTime()); - } finally {Arrays.fill(raw,(byte)0);} - } - static void check(boolean ok,String message) { if(!ok) throw new AssertionError(message); } + static final InetAddress LOOPBACK = InetAddress.getLoopbackAddress(); + static final int PORT = 49184; + static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); } + public static void main(String[] args) throws Exception { - Path certificate=Path.of(args[0]), key=Path.of(args[1]); - byte[] der; - try(var input=Files.newInputStream(certificate)) { der=CertificateFactory.getInstance("X.509").generateCertificate(input).getEncoded(); } - String hostFingerprint=HexFormat.ofDelimiter(":").withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(der)); - for(int passwordLength:new int[]{24,32,91}) run(certificate,key,hostFingerprint,passwordLength,false); - run(certificate,key,hostFingerprint,24,true); + var identity = NativeHostIdentity.load(Path.of(args[0]), Path.of(args[1])); + for (int passwordLength : new int[]{24, 32, 91}) run(identity, passwordLength, false); + run(identity, 24, true); } - static void run(Path certificate,Path key,String hostFingerprint,int passwordLength,boolean wrongFingerprint) throws Exception { - ArrayBlockingQueue work=new ArrayBlockingQueue<>(4); - Set approved=ConcurrentHashMap.newKeySet(), claimed=ConcurrentHashMap.newKeySet(); - AtomicInteger rejected=new AtomicInteger(),created=new AtomicInteger(),rawPackets=new AtomicInteger(); - AtomicReference failure=new AtomicReference<>(); - AtomicReference initialPacket=new AtomicReference<>(); - AtomicInteger initialPort=new AtomicInteger(); - List hosts=new ArrayList<>(); - CountDownLatch messages=new CountDownLatch(2), opened=new CountDownLatch(2); - AtomicInteger channelMask=new AtomicInteger(), callbackCloseGuards=new AtomicInteger(); - CountDownLatch hostFailed=new CountDownLatch(1); - try(RawUdpMuxListener mux=new RawUdpMuxListener(LOOPBACK,PORT,(packet,address,port)->{ - rawPackets.incrementAndGet(); String tuple=address+":"+port; - if(approved.contains(tuple)) return true; - try { - Admission admission=validate(packet,tuple); - if(admission==null) {rejected.incrementAndGet();return false;} - if(claimed.add(admission.token())) { - initialPacket.set(packet); initialPort.set(port); - check(work.offer(admission),"bounded creation queue"); - } - } catch(Exception error) {rejected.incrementAndGet();} - return false; - });PeerConnection client=PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(LOOPBACK))) { - long baselineNativeAttempts=PeerConnection.nativeCreationAttempts(); - check(mux.stats()[2]==0,"host has zero agents before any client packet"); - try(DatagramSocket invalid=new DatagramSocket()) { - byte[] noise=new byte[40];invalid.send(new DatagramPacket(noise,noise.length,LOOPBACK,PORT)); - for(int i=0;i<100 && rejected.get()==0;i++) Thread.sleep(5); - check(rejected.get()>0 && mux.stats()[2]==0 && mux.stats()[3]==0,"invalid datagram created no native state"); - } - List clientChannels=new ArrayList<>(); - for(int channel=0;channel<2;channel++) { - String label=channel==0?"ReliableDataChannel":"UnreliableDataChannel"; - var init=DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(channel==1,channel==1,0,0)); - var dc=client.createDataChannel(label,init);clientChannels.add(dc); - dc.onOpen.register(d->{ - try { client.closeAndAwait(java.time.Duration.ofMillis(1)); failure.set(new AssertionError("teardown wait must reject callback context")); } - catch(IllegalStateException expected) { callbackCloseGuards.incrementAndGet(); } - opened.countDown();ByteBuffer message=ByteBuffer.allocateDirect(2);message.put((byte)0).put((byte)(label.startsWith("Reliable")?1:2)).flip();d.sendMessage(message);}); + + static void run(NativeHostIdentity identity, int passwordLength, boolean wrongFingerprint) throws Exception { + var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); + var endpoint = new NativeAdmissionServerChannel(identity, validator, new AdmissionGate.Limits(4, 8, 2, 10_000)); + var group = new DefaultEventLoopGroup(1); + var messages = new CountDownLatch(2); + var failure = new AtomicReference(); + var received = new AtomicInteger(); + try (var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(LOOPBACK), Runnable::run)) { + new ServerBootstrap().group(group).channelFactory(() -> endpoint) + .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 message) { + int bit = reliable ? 1 : 2; + check(message.readableBytes() == 1 && message.readByte() == bit, "channel identity and payload"); + received.getAndUpdate(mask -> mask | bit); messages.countDown(); + } + @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable error) { failure.set(error); ctx.close(); } + }); + } + }).bind(LOOPBACK, PORT).sync(); + for (boolean reliable : new boolean[]{true, false}) { + var channel = client.createDataChannel(reliable ? "ReliableDataChannel" : "UnreliableDataChannel", + DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(!reliable, !reliable, 0, 0))); + channel.onOpen.register(dc -> dc.sendMessage(ByteBuffer.allocateDirect(2).put((byte)0).put((byte)(reliable ? 1 : 2)).flip())); } - client.setLocalDescription("offer","clientFixtureUf","p".repeat(passwordLength)); - String token=mint(client.localDescription(),passwordLength,wrongFingerprint); - check(token.length()==8+(int)Math.ceil((95+passwordLength)*4.0/3),"token byte budget"); - // NXS-generated answer: never obtained from a native server peer. - String answer="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(token)+"\r\na=fingerprint:sha-256 "+hostFingerprint+ - "\r\na=sctp-port:5000\r\na=max-message-size:262144\r\na=candidate:1 1 UDP 2130706431 127.0.0.1 "+PORT+" typ host\r\na=end-of-candidates\r\n"; - client.setRemoteDescription(answer,SessionDescriptionType.ANSWER); - Admission admitted=work.poll(10,TimeUnit.SECONDS);check(admitted!=null,"valid raw STUN reaches endpoint without control delivery"); - check(mux.stats()[2]==0 && PeerConnection.nativeCreationAttempts()==baselineNativeAttempts,"token validation precedes all native creation attempts"); - PeerConnection host=PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(LOOPBACK) - .withEnableIceUdpMux(true).withPortRangeBegin((short)PORT).withPortRangeEnd((short)PORT),Runnable::run,certificate,key); - hosts.add(host);created.incrementAndGet(); - check(PeerConnection.nativeCreationAttempts()==baselineNativeAttempts+1,"exactly one native creation attempt"); - check(System.nanoTime()>admitted.firstValidNanos(),"monotonic validation before creation"); - host.onStateChange.register((p,state)->{if(state==PeerState.RTC_FAILED) hostFailed.countDown();}); - host.onDataChannel.register((p,dc)->{ - String label=dc.label();int bit=label.equals("ReliableDataChannel")?1:label.equals("UnreliableDataChannel")?2:0; - if(bit==0){failure.set(new AssertionError("unexpected label"));return;} - channelMask.getAndUpdate(mask->mask|bit); - dc.onMessage.register(DataChannelCallback.Message.handleBinary((d,buffer)->{ - try {check(buffer.remaining()==2 && buffer.get()==0 && buffer.get()==bit,"channel identity and payload");messages.countDown();} - catch(Throwable error){failure.set(error);} - })); - }); - host.setRemoteDescription(admitted.offer(),SessionDescriptionType.OFFER); - host.setLocalDescription("answer",admitted.token(),password(admitted.token())); - check(field(host.localDescription(),"fingerprint").equals("sha-256 "+hostFingerprint),"published native certificate identity"); - check(field(host.localDescription(),"ice-ufrag").equals(token),"native did not truncate token"); - approved.add(admitted.tuple()); - mux.replay(initialPacket.getAndSet(null),LOOPBACK,initialPort.get()); - if(wrongFingerprint) { - check(hostFailed.await(15,TimeUnit.SECONDS),"DTLS rejects authenticated token with wrong client fingerprint"); - check(channelMask.get()==0 && opened.getCount()==2,"wrong certificate opens no channels"); - System.out.println("native-spike PASS wrongClientFingerprint=dtls-rejected channels=0 perJoinControl=0"); - for(PeerConnection peer:hosts) check(peer.closeAndAwait(java.time.Duration.ofSeconds(5)),"native teardown completes before releasing capacity");hosts.clear(); - return; + client.setLocalDescription("offer", "clientFixtureUf", "p".repeat(passwordLength)); + var answer = TestSignallingProvider.answer(client.localDescription(), identity.fingerprint(), PORT, + System.currentTimeMillis() + 30_000, TestSignallingProvider.AUDIENCE, wrongFingerprint); + check(answer.token().length() == 8 + (int)Math.ceil((95 + passwordLength) * 4.0 / 3), "token byte budget"); + check(endpoint.nativeStats()[2] == 0, "no host peer before an incoming request"); + client.setRemoteDescription(answer.sdp(), SessionDescriptionType.ANSWER); + if (wrongFingerprint) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); + boolean rejected = false; + while (!rejected && System.nanoTime() < deadline) { + rejected = endpoint.pollEvents().stream().anyMatch(event -> event.stage().equals("ticket.failed")); + if (!rejected) Thread.sleep(10); + } + check(rejected && received.get() == 0, "DTLS rejects a client certificate that does not match its token"); + } else { + check(messages.await(10, TimeUnit.SECONDS), "both channels deliver messages"); + check(failure.get() == null && received.get() == 3, "both channel payloads match"); } - check(opened.await(10,TimeUnit.SECONDS),"both client channels open"); - check(messages.await(10,TimeUnit.SECONDS),"both channels deliver distinct binary messages"); - check(failure.get()==null && callbackCloseGuards.get()==2,"native callbacks completed without failure and cannot wait on themselves"); - check(created.get()==1 && work.isEmpty() && channelMask.get()==3,"one lazy peer and both channels"); - long[] stats=mux.stats();check(stats[2]==1 && stats[3]==1,"one fixed-port agent and tuple"); - System.out.println("native-spike PASS ufragChars="+token.length()+" passwordBytes="+passwordLength+" hostPeers="+created.get()+" rawPackets="+rawPackets.get()+" channels=3 perJoinControl=0"); - for(PeerConnection peer:hosts) check(peer.closeAndAwait(java.time.Duration.ofSeconds(5)),"native teardown completes before releasing capacity");hosts.clear(); - } finally {for(PeerConnection peer:hosts) check(peer.closeAndAwait(java.time.Duration.ofSeconds(5)),"native teardown completes before releasing capacity");} + check(endpoint.creationAttempts() == 1 && endpoint.nativeStats()[5] == 1, "one peer and one admission notification"); + System.out.println("native-admission PASS ufragChars=" + answer.token().length() + " wrongClientFingerprint=" + wrongFingerprint + " channels=" + received.get() + " admissionNotifications=1"); + } finally { + endpoint.close().awaitUninterruptibly(); + endpoint.termination().toCompletableFuture().get(6, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } } } diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java index 75eb9a5d..ba45220a 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java @@ -65,7 +65,8 @@ static void rakPing(int port) throws Exception { try (var socket = new DatagramSocket()) { byte[] packet = new byte[40]; socket.send(new DatagramPacket(packet, packet.length, advertised)); } - var endpoint = host.channel(); await(() -> endpoint.admissionStats().invalid() > 0); + var endpoint = host.channel(); await(() -> endpoint.nativeStats()[0] > 0); + assertEquals(0, endpoint.nativeStats()[5], "Malformed UDP stays native"); assertEquals(0, endpoint.creationAttempts()); } finally { if (host != null) host.close().toCompletableFuture().get(10, TimeUnit.SECONDS); @@ -78,7 +79,13 @@ static void rakPing(int port) throws Exception { var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); var group = new DefaultEventLoopGroup(1); - var endpoint = new NativeAdmissionServerChannel(id, validator, new AdmissionGate.Limits(2, 4, 1, 10_000)); + AtomicInteger validations = new AtomicInteger(); + AdmissionValidator delayed = (metadata, now) -> { + validations.incrementAndGet(); + try { Thread.sleep(200); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return null; } + return validator.validate(metadata, now); + }; + var endpoint = new NativeAdmissionServerChannel(id, delayed, new AdmissionGate.Limits(2, 4, 1, 10_000)); try (var socket = new DatagramSocket(new InetSocketAddress(loopback, 0)); var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(loopback), Runnable::run)) { new ServerBootstrap().group(group).channelFactory(() -> endpoint) @@ -99,12 +106,55 @@ static void rakPing(int port) throws Exception { assertEquals(0x0101, Short.toUnsignedInt(ByteBuffer.wrap(bytes).getShort())); assertArrayEquals(Arrays.copyOfRange(request, 8, 20), Arrays.copyOfRange(bytes, 8, 20)); assertEquals(1, endpoint.creationAttempts()); assertEquals(1, endpoint.nativeStats()[3]); + assertEquals(1, validations.get()); assertEquals(1, endpoint.nativeStats()[5]); + // Authenticated retransmissions on the established tuple never return to admission. + for (int i = 0; i < 40; i++) socket.send(new DatagramPacket(request, request.length, loopback, port)); + await(() -> endpoint.nativeStats()[0] >= 41); + assertEquals(1, validations.get()); assertEquals(1, endpoint.nativeStats()[5]); System.out.printf(Locale.ROOT, "first-stun PASS requestsSent=1 matchingSuccess=true responseMs=%.3f%n", elapsedMs); } finally { - endpoint.close().awaitUninterruptibly(); endpoint.termination().toCompletableFuture().get(6, TimeUnit.SECONDS); + if (endpoint.isRegistered()) endpoint.close().awaitUninterruptibly(); else endpoint.unsafe().closeForcibly(); + endpoint.termination().toCompletableFuture().get(6, TimeUnit.SECONDS); group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); } } + @Test @Timeout(20) void expiredDecisionCreatesNoPeerAndReleasesItsReservation() throws Exception { + var id = identity(); var loopback = InetAddress.getByName("127.0.0.1"); int port = 49200; + var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); + AdmissionValidator delayed = (metadata, now) -> { + var admission = validator.validate(metadata, now); + try { Thread.sleep(2500); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return null; } + return admission; + }; + var group = new DefaultEventLoopGroup(1); + var endpoint = new NativeAdmissionServerChannel(id, delayed, new AdmissionGate.Limits(2, 4, 1, 10_000)); + try (var socket = new DatagramSocket(new InetSocketAddress(loopback, 0)); + var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(loopback), Runnable::run)) { + new ServerBootstrap().group(group).channelFactory(() -> endpoint) + .childHandler(new ChannelInboundHandlerAdapter()).bind(loopback, port).sync(); + client.createDataChannel("ReliableDataChannel"); + client.setLocalDescription("offer", "expiredDecisionClient", "p".repeat(32)); + var answer = TestSignallingProvider.answer(client.localDescription(), id.fingerprint(), port, + System.currentTimeMillis() + 2000, TestSignallingProvider.AUDIENCE, false); + byte[] request = nominatedBinding(answer.token() + ":expiredDecisionClient", answer.password()); + long before = PeerConnection.nativeCreationAttempts(); + socket.send(new DatagramPacket(request, request.length, loopback, port)); + await(() -> endpoint.admissionStats().invalid() > 0); + assertEquals(before, PeerConnection.nativeCreationAttempts()); assertEquals(0, endpoint.creationAttempts()); + assertEquals(0, endpoint.nativeStats()[2]); assertEquals(0, endpoint.nativeStats()[3]); + assertEquals(0, endpoint.admissionStats().claims()); assertEquals(0, endpoint.admissionStats().sessions()); + } finally { + if (endpoint.isRegistered()) endpoint.close().awaitUninterruptibly(); else endpoint.unsafe().closeForcibly(); + endpoint.termination().toCompletableFuture().get(6, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } + @Test @Timeout(60) void tokenLengthBoundsAndClientFingerprintAreEnforcedByRealTransport() throws Exception { + var id = identity(); + for (int passwordLength : new int[]{24, 32, 91}) AdmissionPrimitiveProbe.run(id, passwordLength, false); + AdmissionPrimitiveProbe.run(id, 24, true); + } private static byte[] nominatedBinding(String username, String password) throws Exception { byte[] minimal = AdmissionFixture.binding(username, password); int integrityOffset = minimal.length - 24; @@ -155,9 +205,10 @@ private static byte[] nominatedBinding(String username, String password) throws 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); + await(()->endpoint.nativeStats()[0]>0); + assertEquals(0, endpoint.nativeStats()[5], "Malformed UDP stays native"); assertEquals(beforeInvalid,PeerConnection.nativeCreationAttempts());assertEquals(0,endpoint.nativeStats()[3]); - assertThrows(IllegalStateException.class,()->new RawUdpMuxListener(loopback,port,(p,a,n)->false)); + assertThrows(IllegalStateException.class,()->new IceUdpMuxListener(loopback,port,Runnable::run,request -> CompletableFuture.completedFuture(null))); 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++) { @@ -185,11 +236,14 @@ private static byte[] nominatedBinding(String username, String password) throws 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(); + long beforeNegatives=PeerConnection.nativeCreationAttempts(); try(var invalid=new DatagramSocket()) { - for(byte[] packet:rejectedPackets) invalid.send(new DatagramPacket(packet,packet.length,loopback,port)); + for(byte[] packet:rejectedPackets) { + long rejectedBefore = endpoint.admissionStats().invalid(); + invalid.send(new DatagramPacket(packet,packet.length,loopback,port)); + await(() -> endpoint.admissionStats().invalid() > rejectedBefore); + } } - 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]); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java index 75c4a94d..0ced7742 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java @@ -7,6 +7,7 @@ import javax.crypto.spec.SecretKeySpec; import java.io.InputStreamReader; import java.nio.ByteBuffer; +import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -28,6 +29,8 @@ StatelessAdmissionValidator validator(String audience) { return v; } StatelessAdmissionValidator validator() { return validator(f.getAsJsonObject("context").get("audience").getAsString()); } + AdmissionRequest request() { return request(token, remote); } + static AdmissionRequest request(String local, String remote) { return new AdmissionRequest(local, remote, new InetSocketAddress("127.0.0.1", 23450)); } static byte[] binding(String username, String password) { try { byte[] u = username.getBytes(StandardCharsets.US_ASCII); @@ -43,9 +46,8 @@ static byte[] binding(String username, String password) { } class StatelessAdmissionValidatorTest extends AdmissionFixture { - @Test void canonicalJavaScriptTokenAndPacketIntegrityAgree() { - byte[] packet = binding(token + ":" + remote, password); - var a = validator().validate(packet, StunBinding.parse(packet), now); + @Test void canonicalJavaScriptTokenMatchesJavaClaims() { + var a = validator().validate(request(), now); assertNotNull(a); var c = f.getAsJsonObject("claims"); assertEquals(c.get("clientIcePwd").getAsString(), a.remotePassword()); @@ -58,30 +60,24 @@ class StatelessAdmissionValidatorTest extends AdmissionFixture { 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)); + assertNull(v.validate(request(), now + 60_000)); + assertNull(v.validate(request(), 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)); + assertNull(validator(audience).validate(request(), now)); + String altered = token.substring(0, 90) + (token.charAt(90)=='A'?'B':'A') + token.substring(91); + assertNull(v.validate(request(altered, remote), now)); + assertNull(v.validate(request(token, "clientOtherUfrag"), now)); + assertThrows(IllegalArgumentException.class, () -> request(token + "=", remote)); 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)); + assertNull(v.validate(request(), now)); + v.clear(); assertFalse(v.ready()); + assertNull(v.validate(request(), 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 callbackMetadataAndClaimsDoNotPrintCredentials() { + assertFalse(request().toString().contains(token)); + assertFalse(request().toString().contains(remote)); + assertNotNull(validator().validate(request(), now)); // Native code owns STUN integrity verification. } @Test void keyUpdatesAreBoundedAtomicAndRedacted() { var v = validator(); @@ -108,12 +104,12 @@ class StatelessAdmissionValidatorTest extends AdmissionFixture { } } @Test void backgroundKeyValidityBoundsDoNotExtendTokens() { - var v = validator(); byte[] packet = binding(token + ":" + remote, password); + var v = validator(); 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)); + assertNull(v.validate(request(), now)); + assertNotNull(v.validate(request(), now + 1)); + assertNull(v.validate(request(), now + 20_000)); } } 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 index 3fca0fc2..fbeffba9 100644 --- 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 @@ -2,9 +2,8 @@ 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. */ +/** Bounded admission reservations and records of used tokens. Packet processing stays native. */ public final class AdmissionGate { public record Limits(int sessions, int claims, int pending, long handshakeMillis) { public Limits { @@ -15,22 +14,20 @@ public record Limits(int sessions, int claims, int pending, long handshakeMillis } public static final class Reservation { private VerifiedAdmission admission; - private byte[] initialPacket; 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, byte[] packet) { + private boolean ready, connected, closing, 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; - this.initialPacket = packet.clone(); } 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) {} + public record Stats(int sessions, int pending, int claims, long invalid, long replayRejected, long capacityRejected, long accepted) {} public record PendingLimitWarning(int pending, int limit, long rejected) {} private static final long WARNING_INTERVAL_NANOS = 5_000_000_000L; private final Limits limits; @@ -39,73 +36,60 @@ public record PendingLimitWarning(int pending, int limit, long rejected) {} private final Map tuples = new HashMap<>(); private int pending; private boolean draining, closed; - private long invalid, replayRejected, capacityRejected, accepted, retransmissions; + private long invalid, replayRejected, capacityRejected, accepted; private long pendingLimitRejected, lastPendingWarningNanos; private int pendingAtRejection; private boolean pendingWarningEmitted; - public AdmissionGate(Limits limits, AdmissionValidator validator) { this.limits = Objects.requireNonNull(limits); this.validator = Objects.requireNonNull(validator); } + 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) { capacityRejected++; return false; } + /** Reserve capacity after token validation. Native STUN verification must still succeed. */ + public synchronized Reservation reserve(AdmissionRequest request, long nowMillis, long nowNanos) { + if (closed) return null; + VerifiedAdmission a = validator.validate(request, nowMillis); + if (a == null) { invalid++; return null; } + if (claims.containsKey(a.tokenId()) || tuples.containsKey(request.address())) { replayRejected++; return null; } + if (draining) { capacityRejected++; return null; } if (pending >= limits.pending()) { capacityRejected++; pendingLimitRejected++; pendingAtRejection = pending; - return false; + return null; } - if (tuples.size() >= limits.sessions() || claims.size() >= limits.claims()) { capacityRejected++; return false; } - // Only authenticated, capacity-admitted requests are retained. The parser - // caps each at 2048 bytes and pending reservations bound the number held. - Reservation r = new Reservation(a, tuple, nowNanos, packet); - claims.put(r.tokenId, r); tuples.put(tuple, r); pending++; accepted++; - try { enqueue.accept(r); } - catch (RuntimeException rejected) { finish(r); capacityRejected++; } - return false; // defer this packet until creation; never wait for a client retry + if (tuples.size() >= limits.sessions() || claims.size() >= limits.claims()) { capacityRejected++; return null; } + Reservation r = new Reservation(a, request.address(), nowNanos); + claims.put(r.tokenId, r); tuples.put(r.tuple, r); pending++; + return r; } - public synchronized VerifiedAdmission admission(Reservation r) { return current(r) ? r.admission : null; } - /** Activate and transfer the first packet once, atomically releasing its pending slot. */ - public synchronized byte[] ready(Reservation r) { - if (!current(r) || r.ready) return null; - byte[] packet = r.initialPacket; - r.initialPacket = null; - r.ready = true; - pending--; - return packet; + public synchronized VerifiedAdmission admission(Reservation r) { return current(r) && !r.closing ? r.admission : null; } + /** Native STUN verification and peer creation succeeded. A used token cannot allocate another peer. */ + public synchronized boolean ready(Reservation r) { + if (!current(r) || r.closing || r.ready) return false; + r.ready = true; pending--; accepted++; + return true; } - public synchronized void connected(Reservation r) { if (current(r)) r.connected = true; } + public synchronized void connected(Reservation r) { if (current(r) && !r.closing) r.connected = true; } + public synchronized void invalidNativeRequest() { invalid++; } + /** Call only once native teardown is complete, or when native creation never started. */ 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; r.initialPacket = null; // retain only a bounded replay tombstone + tuples.remove(r.tuple); r.closed = true; r.admission = null; + // A copied token with forged STUN integrity must not consume the real client's token. + if (!r.ready || closed) claims.remove(r.tokenId); 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. */ + /** Mark timed-out handshakes for closure. Capacity stays reserved until finish is called. */ 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); + for (Reservation r : claims.values()) { + if (!r.closed && !r.closing && !r.connected && nowNanos - r.acceptedNanos >= limits.handshakeMillis() * 1_000_000L) { + r.closing = true; timedOut.add(r); + } + } claims.values().removeIf(r -> r.closed && r.expiresAt <= nowMillis); return timedOut; } @@ -113,11 +97,12 @@ public synchronized List sweep(long nowMillis, long nowNanos) { public synchronized List close() { closed = true; List active = new ArrayList<>(tuples.values()); - for (Reservation r : active) finish(r); - claims.clear(); return active; + for (Reservation r : active) r.closing = true; + claims.values().removeIf(r -> r.closed); + return active; } - public synchronized Stats stats() { return new Stats(tuples.size(), pending, claims.size(), invalid, replayRejected, capacityRejected, accepted, retransmissions); } - /** Drain an aggregate on the owner thread; never invoke a logger in raw ingress. */ + public synchronized Stats stats() { return new Stats(tuples.size(), pending, claims.size(), invalid, replayRejected, capacityRejected, accepted); } + /** Read one aggregate warning on the owner thread. */ public synchronized PendingLimitWarning pollPendingLimitWarning(long nowNanos) { if (pendingLimitRejected == 0 || (pendingWarningEmitted && nowNanos - lastPendingWarningNanos < WARNING_INTERVAL_NANOS)) return null; var warning = new PendingLimitWarning(pendingAtRejection, limits.pending(), pendingLimitRejected); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionRequest.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionRequest.java new file mode 100644 index 00000000..ff6714f2 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionRequest.java @@ -0,0 +1,23 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import java.net.InetSocketAddress; +import java.util.Objects; + +/** Untrusted fields from one incoming ICE attempt. Packet bytes remain native. */ +public record AdmissionRequest(String localUfrag, String remoteUfrag, InetSocketAddress address) { + public AdmissionRequest { + if (!iceString(localUfrag, 4, 256) || !iceString(remoteUfrag, 4, 256)) + throw new IllegalArgumentException("ICE username fragments"); + Objects.requireNonNull(address); + if (address.isUnresolved()) throw new IllegalArgumentException("Resolved source address required"); + } + 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 "AdmissionRequest[redacted]"; } +} 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 index d51aa9a4..9169165e 100644 --- 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 @@ -1,8 +1,8 @@ package dev.kastle.netty.channel.nethernet.admission; -/** Local-only validation against a bounded background key/profile snapshot. No network calls. */ +/** Validates admission metadata using locally installed keys. 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); + /** Return connection settings, or null to reject. Native code separately verifies STUN integrity. */ + VerifiedAdmission validate(AdmissionRequest request, long nowMillis); } 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 9f5c1eac..702266b7 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 @@ -8,76 +8,134 @@ import io.netty.util.internal.logging.InternalLoggerFactory; import tel.schich.libdatachannel.*; import java.net.*; +import java.time.Duration; +import java.time.Instant; 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. */ +/** Fixed-UDP host. Java decides admission once; native code owns transport packets. */ public final class NativeAdmissionServerChannel extends AbstractServerChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NativeAdmissionServerChannel.class); 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 long creationNanos = System.nanoTime(); 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(); } + boolean reported, closing; + Session(AdmissionGate.Reservation reservation, AdmittedNetherNetChildChannel child) { this.reservation = reservation; this.child = child; } } private final DefaultNetherServerChannelConfig config = new DefaultNetherServerChannelConfig(this); private final NativeHostIdentity identity; private final boolean allowWildcardBind; private final AdmissionGate gate; - private final int maxNativePeers; + private final AdmissionGate.Limits limits; private final AtomicReference nativeCloseFailure = new AtomicReference<>(); private final AtomicInteger liveNativePeers = new AtomicInteger(); private final Set> nativeClosures = ConcurrentHashMap.newKeySet(); - private final ArrayBlockingQueue pending; + private final Set> admissions = ConcurrentHashMap.newKeySet(); 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; + private volatile IceUdpMuxListener mux; + private ScheduledFuture maintenance; public NativeAdmissionServerChannel(NativeHostIdentity identity, AdmissionValidator validator, AdmissionGate.Limits limits) { this(identity, validator, limits, false); } - /** Wildcard binding is safe only when the caller publishes a separately validated concrete candidate. */ + /** Wildcard binding requires a separately validated concrete advertised candidate. */ public NativeAdmissionServerChannel(NativeHostIdentity identity, AdmissionValidator validator, AdmissionGate.Limits limits, boolean allowWildcardBind) { - this.identity = Objects.requireNonNull(identity); gate = new AdmissionGate(limits, validator); pending = new ArrayBlockingQueue<>(limits.pending()); maxNativePeers = limits.sessions(); - this.allowWildcardBind = allowWildcardBind; + this.identity = Objects.requireNonNull(identity); this.limits = Objects.requireNonNull(limits); + gate = new AdmissionGate(limits, validator); this.allowWildcardBind = allowWildcardBind; } @Override protected void doBind(SocketAddress socketAddress) throws Exception { if (!(socketAddress instanceof InetSocketAddress a) || a.isUnresolved() || a.getPort() == 0 || (!allowWildcardBind && 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; + address = a; + mux = new IceUdpMuxListener(a.getAddress(), a.getPort(), Math.min(limits.pending(), 4096), + Duration.ofMillis(Math.min(limits.handshakeMillis(), 30_000)), eventLoop(), this::admit); + maintenance = eventLoop().scheduleWithFixedDelay(this::maintain, 100, 100, TimeUnit.MILLISECONDS); + } + private CompletionStage admit(IceUdpMuxListener.Request request) throws Exception { + if (!isOpen() || nativeCloseFailure.get() != null) return CompletableFuture.completedFuture(null); + byte[] ip = NetUtil.createByteArrayFromIpAddressString(request.remoteAddress()); + if (ip == null) return CompletableFuture.completedFuture(null); + AdmissionRequest metadata = new AdmissionRequest(request.localUfrag(), request.remoteUfrag(), + new InetSocketAddress(InetAddress.getByAddress(ip), request.remotePort())); + AdmissionGate.Reservation reservation = gate.reserve(metadata, System.currentTimeMillis(), System.nanoTime()); + if (reservation == null) return CompletableFuture.completedFuture(null); + VerifiedAdmission a = gate.admission(reservation); + CompletableFuture settled = new CompletableFuture<>(); admissions.add(settled); + request.completion().whenComplete((peer, failure) -> { 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"); + eventLoop().execute(() -> { + if (failure != null) { + gate.invalidNativeRequest(); + Session session = sessions.get(reservation); + if (session == null) gate.finish(reservation); + else finish(reservation, "native_acceptance_failed"); + } + settled.complete(null); admissions.remove(settled); }); - } catch (UnknownHostException invalid) { return false; } + } catch (RejectedExecutionException stopped) { + nativeCloseFailure.compareAndSet(null, stopped); gate.drain(); settled.completeExceptionally(stopped); + } + }); + return CompletableFuture.completedFuture(new IceUdpMuxListener.Acceptance( + PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true) + .withMaxMessageSize(NetherNetFrameDecoder.MESSAGE_LIMIT), + a.remoteDescription(), a.localPassword(), identity.certificate(), identity.privateKey(), null, + Runnable::run, peer -> initialize(reservation, a, peer), Instant.ofEpochMilli(a.expiresAt()))); + } + /** Called by the listener on this channel's event loop, before the first request resumes. */ + private void initialize(AdmissionGate.Reservation reservation, VerifiedAdmission a, PeerConnection peer) { + var child = new AdmittedNetherNetChildChannel(this, peer, reservation.tuple(), address); + var session = new Session(reservation, child); + creations.incrementAndGet(); liveNativePeers.incrementAndGet(); + sessions.put(reservation, session); nativeClosures.add(session.closed); + session.closed.whenComplete((ignored, failure) -> { + if (failure != null) { nativeCloseFailure.compareAndSet(null, failure); gate.drain(); } + else { liveNativePeers.decrementAndGet(); gate.finish(reservation); } + nativeClosures.remove(session.closed); + }); + child.nativeTermination().whenComplete((ignored, failure) -> { + if (failure == null) session.closed.complete(null); else session.closed.completeExceptionally(failure); + }); + // Keep ownership before checks that may fail, so partial setup is included in teardown. + if (!isOpen() || gate.admission(reservation) == null || a.expiresAt() <= System.currentTimeMillis()) + throw new IllegalStateException("Admission expired or cancelled"); + 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"); + child.attr(AdmissionPrincipal.KEY).set(new AdmissionPrincipal(a.tokenId(), a.networkId(), a.callerContextHash(), a.keyId())); + 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 { child.acceptDataChannel(dc); } + catch (Exception invalidChannel) { session.failed = true; } }); - address = a; mux = listener; - tick = eventLoop().scheduleWithFixedDelay(this::pump, 0, 5, TimeUnit.MILLISECONDS); + if (!gate.ready(reservation)) throw new IllegalStateException("Admission cancelled"); + pipeline().fireChannelRead(child); pipeline().fireChannelReadComplete(); + emit(reservation, "ticket.ice_seen", "token_and_stun_validated", session.creationNanos); } - private void pump() { + /** Periodic expiry and session reporting; connection creation is driven by admission completion. */ + private void maintain() { if (!isOpen()) return; try { - if (mux.failure() != null || nativeCloseFailure.get() != null) { close(); return; } + IceUdpMuxListener listener = mux; + if (listener != null && listener.failure() != null) nativeCloseFailure.compareAndSet(null, listener.failure()); + if (nativeCloseFailure.get() != null) { close(); return; } var warning = gate.pollPendingLimitWarning(System.nanoTime()); if (warning != null) log.warn("Pending admission limit reached: pending={}, limit={}, rejectedSinceLastWarning={}", warning.pending(), warning.limit(), warning.rejected()); 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 && 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()) { @@ -87,64 +145,19 @@ private void pump() { } } 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.callerContextHash(), 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); gate.drain(); } - else liveNativePeers.decrementAndGet(); - nativeClosures.remove(session.closed); - }); - // 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; - 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(); - byte[] initialPacket = gate.ready(reservation); - if (initialPacket == null) { finish(reservation, "cancelled"); return; } - mux.replay(initialPacket, reservation.tuple().getAddress(), reservation.tuple().getPort()); - 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) { - 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()); - } - } 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); } + Session session = sessions.remove(r); + // An outstanding native prepare owns its reservation until request.completion settles. + if (session != null) { + closeChild(session); + if (!session.reported) emit(r, "ticket.failed", reason, session.creationNanos); + } } private static void closeChild(Session session) { + if (session.closing) return; + session.closing = true; 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); } } @@ -157,18 +170,19 @@ private void emit(AdmissionGate.Reservation r, String stage, String reason, long 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 long[] nativeStats() { IceUdpMuxListener 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); + open = false; gate.close(); + if (maintenance != null) maintenance.cancel(false); + IceUdpMuxListener listener = mux; mux = null; + if (listener != null) listener.close(); 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) -> { + List> outstanding = new ArrayList<>(nativeClosures); outstanding.addAll(admissions); + CompletableFuture.allOf(outstanding.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); 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 deleted file mode 100644 index 120f88a5..00000000 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/StunBinding.java +++ /dev/null @@ -1,78 +0,0 @@ -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 index bf104660..1311de04 100644 --- 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 @@ -7,8 +7,8 @@ public record VerifiedAdmission(String tokenId, String localUfrag, String localP String networkId, String callerContextHash, 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 (!AdmissionRequest.iceString(localUfrag, 4, 256) || !AdmissionRequest.iceString(remoteUfrag, 4, 256) || + !AdmissionRequest.iceString(localPassword, 22, 256) || !AdmissionRequest.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"); } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java index 7902e08d..d398c277 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java @@ -2,14 +2,10 @@ import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; +import tel.schich.libdatachannel.LibDataChannel; +import java.util.Locale; -/** - * Controls how much of libdatachannel's own logging reaches your logs. - *

- * The native logger runs at its most verbose level and the binding maps that straight onto SLF4J, so a - * connection emits a couple of dozen lines at INFO about ICE, DTLS and SCTP internals. Neither is - * configurable, leaving the level of {@value #NATIVE_LOGGER} as the only place to filter. - */ +/** Controls native logging before messages cross into Java, and configures supported Java backends. */ public final class NetherNetLogging { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetLogging.class); @@ -20,26 +16,31 @@ private NetherNetLogging() { } /** - * Sets the level of the libdatachannel logger. SLF4J has no level API, so this is applied through - * Log4j2 or Logback; with any other backend it does nothing and you should configure it yourself. + * Sets the native threshold and, when available, the Log4j2 or Logback logger level. * * @param level One of OFF, ERROR, WARN, INFO, DEBUG, TRACE or ALL. WARN is a good default. - * @return true if the level was applied, false if the backend was not recognised. + * @return true if the native threshold was set, false if the level was invalid. */ public static boolean setNativeLogLevel(String level) { if (level == null || level.isBlank()) { return false; } - String normalised = level.trim().toUpperCase(); - - if (applyLog4j2(normalised) || applyLogback(normalised)) { - log.debug("Set {} to {}", NATIVE_LOGGER, normalised); - return true; - } - - log.debug("Could not set {} to {}, no supported logging backend found", NATIVE_LOGGER, normalised); - return false; + String normalised = level.trim().toUpperCase(Locale.ROOT); + LibDataChannel.LogLevel nativeLevel = switch (normalised) { + case "OFF" -> LibDataChannel.LogLevel.NONE; + case "ERROR" -> LibDataChannel.LogLevel.ERROR; + case "WARN" -> LibDataChannel.LogLevel.WARNING; + case "INFO" -> LibDataChannel.LogLevel.INFO; + case "DEBUG" -> LibDataChannel.LogLevel.DEBUG; + case "TRACE", "ALL" -> LibDataChannel.LogLevel.VERBOSE; + default -> null; + }; + if (nativeLevel == null) return false; + LibDataChannel.setLogLevel(nativeLevel); + if (!applyLog4j2(normalised)) applyLogback(normalised); + log.debug("Set native transport log level to {}", normalised); + return true; } private static boolean applyLog4j2(String level) { From 3e0e5389fd28481a4db18671e9461b7412c72b57 Mon Sep 17 00:00:00 2001 From: Zulu Date: Sun, 6 Sep 2026 21:03:59 +0100 Subject: [PATCH 08/15] Pin asynchronous native admission libraries --- native-dependencies.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/native-dependencies.properties b/native-dependencies.properties index 912b7178..e4b8a5d9 100644 --- a/native-dependencies.properties +++ b/native-dependencies.properties @@ -1,10 +1,10 @@ # Maintained fork integration pins; branch names never select build inputs. java.repository=teamziax/libdatachannel-java -java.commit=c94d932c03ec6f12eb9d9b629dd2689ffa3f424e +java.commit=ffa77a5a043982dc5760c452108fada89c054322 nativeJavaGroup=io.github.teamziax -nativeJavaVersion=0.24.5.0-dev.c94d932c03ec6f12eb9d9b629dd2689ffa3f424e +nativeJavaVersion=0.24.5.0-dev.ffa77a5a043982dc5760c452108fada89c054322 datachannel.repository=teamziax/libdatachannel -datachannel.commit=bd9090f775f2354cc35716ec04b24110562e6ab3 +datachannel.commit=ffc7dbf43ac378b3a1a1fa3f9f5ff916fcc0184b juice.repository=teamziax/libjuice -juice.commit=4ffdcc321fee1c6d743bc98882cb2a6544e7b5f2 +juice.commit=5498f67aff2092aa2bb868f74971ae7c076e5735 platform=linux-x86_64 From 62f36de102c9e2e7e828836e433bae235a86743e Mon Sep 17 00:00:00 2001 From: Zulu Date: Sun, 6 Sep 2026 21:08:55 +0100 Subject: [PATCH 09/15] Separate admission requirements from the native reference implementation --- docs/external-signalling/README.md | 40 +++++++++++++++++------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md index 1791fe48..3a94a223 100644 --- a/docs/external-signalling/README.md +++ b/docs/external-signalling/README.md @@ -378,28 +378,34 @@ ufrag, encoded as lowercase hex. ### Validate the first packet -A token can be valid for at most 120 seconds. The supplied implementation uses -60 seconds. Before assigning the UDP tuple to a peer or creating a native peer, -the host checks expiry, field bounds, GCM authentication, client binding, and the -raw STUN MESSAGE-INTEGRITY. The DTLS handshake MUST then verify the client -fingerprint from the token. +A token can be valid for at most 120 seconds. Before assigning the UDP tuple to a +peer or allocating a peer connection, the host checks expiry, field bounds, GCM +authentication, client binding, and STUN MESSAGE-INTEGRITY. The DTLS handshake +MUST then verify the client fingerprint from the token. Only a retransmission of the identical token from the same UDP tuple can reuse a reservation. Reject the same token from another tuple. Also reject a conflicting admission on an occupied tuple. -Limit the number of sessions, pending handshakes, used-token records, callbacks, -and retained requests. Native code retains the first STUN request while the -application validates its token asynchronously. Duplicate requests for the same -pending attempt share that decision. The application receives parsed request -metadata, not packet bytes. - -Create peers outside the UDP receive lock and only after native STUN integrity -verification succeeds. Then process the retained request immediately: completing -admission MUST NOT depend on the client retransmitting. Established transport -packets stay native. Release admission capacity only after native teardown has -actually finished. A failed integrity check MUST NOT consume the token, since a -copied token alone does not prove that the sender has its ICE password. +Limit the number of sessions, pending handshakes, used-token records, queued +validation tasks, and retained requests. Duplicate requests for the same pending +attempt share one decision. Preserve enough of the first request to respond after +acceptance: completing admission MUST NOT depend on the client retransmitting. +Release admission capacity only after the connection's resources have been +released. A failed integrity check MUST NOT consume the token, since a copied +token alone does not prove that the sender has its ICE password. + +#### Reference implementation + +The supplied Network implementation uses a 60-second token limit. libjuice +retains the first STUN packet and sends parsed metadata to Java for asynchronous +token validation. libdatachannel verifies STUN integrity before creating the +peer, outside the UDP receive lock. It then processes the retained request after +the application has installed its callbacks. Established transport packets stay +native, and capacity remains reserved until native teardown finishes. + +Other implementations may meet the requirements above using different languages, +threading models, and transport libraries. ## Optional extensions and compatibility From b531b33971b71bb78e4560229c8ae828aed787a2 Mon Sep 17 00:00:00 2001 From: Zulu Date: Sun, 6 Sep 2026 21:10:51 +0100 Subject: [PATCH 10/15] Pin the verified native CI configuration --- native-dependencies.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/native-dependencies.properties b/native-dependencies.properties index e4b8a5d9..39e54cb2 100644 --- a/native-dependencies.properties +++ b/native-dependencies.properties @@ -1,10 +1,10 @@ # Maintained fork integration pins; branch names never select build inputs. java.repository=teamziax/libdatachannel-java -java.commit=ffa77a5a043982dc5760c452108fada89c054322 +java.commit=d855d4f3e9995b7ad926e6c0d23d0095666b2570 nativeJavaGroup=io.github.teamziax -nativeJavaVersion=0.24.5.0-dev.ffa77a5a043982dc5760c452108fada89c054322 +nativeJavaVersion=0.24.5.0-dev.d855d4f3e9995b7ad926e6c0d23d0095666b2570 datachannel.repository=teamziax/libdatachannel -datachannel.commit=ffc7dbf43ac378b3a1a1fa3f9f5ff916fcc0184b +datachannel.commit=070e9ba5327dfac1d59ca4fab9bb991daed8faee juice.repository=teamziax/libjuice juice.commit=5498f67aff2092aa2bb868f74971ae7c076e5735 platform=linux-x86_64 From a6e73dfa9061fa7fcd35d61d221441371b5b6ca2 Mon Sep 17 00:00:00 2001 From: Zulu Date: Mon, 7 Sep 2026 09:44:38 +0100 Subject: [PATCH 11/15] Publish multiple native provider IP endpoints --- external-signalling/README.md | 16 +++- .../signalling/admission/EndpointAddress.java | 56 +++++++++++ .../admission/NativeProviderTransport.java | 40 ++++++-- .../admission/EndpointAddressTest.java | 30 ++++++ .../NativeAdmissionIntegrationTest.java | 93 +++++++++++++++++++ 5 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/EndpointAddress.java create mode 100644 external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/EndpointAddressTest.java diff --git a/external-signalling/README.md b/external-signalling/README.md index 9ec8fd25..4e5ac6f5 100644 --- a/external-signalling/README.md +++ b/external-signalling/README.md @@ -20,8 +20,20 @@ One instance owns one private state directory; restarts preserve that directory. known namespaces and invoke only their advertised same-origin operations. The core never performs product account/claim actions or stages individual joins from provider control. -`NativeProviderTransport` publishes the actual bound UDP endpoint and certificate -fingerprint before accepting clients. Java validates the NXS1 token from incoming +`NativeProviderTransport` publishes its UDP endpoints and certificate fingerprint +before accepting clients. Its supplier overload of `open` refreshes a deduplicated +snapshot of 1–32 numeric IP/port pairs at each background profile publication. Adapters +can provide all suitable addresses of a wildcard listener plus operator-configured +forwarding endpoints; the original single-endpoint overload remains available. +`EndpointAddress` provides numeric parsing and public/private/special-purpose +classification for adapters. Publication does not test network reachability or +configure port forwarding. An IPv6 wildcard listener accepts IPv4 and IPv6 with the +pinned native stack; a concrete IPv6 bind does not imply IPv4 coverage. + +All endpoints share one admission incarnation. The first authenticated source tuple +owns its ticket, including when several address families are advertised; subsequent +tuples cannot use that ticket to create another peer. This does not introduce path +migration after admission. Java validates the NXS1 token from incoming ICE metadata; native code verifies STUN integrity before creating a peer. The first request stays native during asynchronous validation, and acceptance does not depend on a client retry. Established transport packets stay native. The optional native test task is diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/EndpointAddress.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/EndpointAddress.java new file mode 100644 index 00000000..553dde3d --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/EndpointAddress.java @@ -0,0 +1,56 @@ +package org.cloudburstmc.netty.signalling.admission; + +import io.netty.util.NetUtil; +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** Numeric endpoint classification, shared by provider adapters. No DNS or reachability claims. */ +public final class EndpointAddress { + public enum Scope { PUBLIC, PRIVATE, LOOPBACK, DOCUMENTATION, UNUSABLE } + private EndpointAddress() {} + + public static InetAddress parse(String value) throws UnknownHostException { + if (value == null || value.isEmpty() || value.length() > 45 || !value.matches("[0-9a-fA-F:.]+")) throw new UnknownHostException("Expected a numeric IP address"); + String dotted = value.substring(value.lastIndexOf(':') + 1); + if ((!value.contains(":") || value.contains(".")) && !dotted.matches("(?:0|[1-9][0-9]{0,2})(?:\\.(?:0|[1-9][0-9]{0,2})){3}")) + throw new UnknownHostException("Expected dotted-decimal IPv4"); + byte[] bytes = NetUtil.createByteArrayFromIpAddressString(value); + if (bytes == null) throw new UnknownHostException("Invalid IP address"); + return InetAddress.getByAddress(bytes); // Also normalizes IPv4-mapped IPv6. + } + + /** IANA special-purpose registries, reviewed 2026-09-07. */ + public static Scope scope(InetAddress address) { + byte[] raw = address.getAddress(); + if (raw.length == 4) { + int a = raw[0] & 255, b = raw[1] & 255, c = raw[2] & 255, d = raw[3] & 255; + if (a == 10 || (a == 172 && b >= 16 && b <= 31) || (a == 192 && b == 168) || (a == 100 && b >= 64 && b <= 127)) return Scope.PRIVATE; + if (a == 127) return Scope.LOOPBACK; + if ((a == 192 && b == 0 && c == 2) || (a == 198 && b == 51 && c == 100) || (a == 203 && b == 0 && c == 113)) return Scope.DOCUMENTATION; + if (a == 0 || a >= 224 || (a == 169 && b == 254) || (a == 198 && (b == 18 || b == 19)) || + (a == 192 && b == 88 && c == 99) || (a == 192 && b == 0 && c == 0 && d != 9 && d != 10)) return Scope.UNUSABLE; + return Scope.PUBLIC; + } + int[] words = new int[8]; + for (int i = 0; i < words.length; i++) words[i] = ((raw[i * 2] & 255) << 8) | (raw[i * 2 + 1] & 255); + int a = words[0], b = words[1]; + if ((a & 0xfe00) == 0xfc00) return Scope.PRIVATE; + if (address.isLoopbackAddress()) return Scope.LOOPBACK; + if ((a == 0x2001 && b == 0xdb8) || (a == 0x3fff && b < 0x1000)) return Scope.DOCUMENTATION; + if ((a & 0xe000) != 0x2000 || a == 0x2002) return Scope.UNUSABLE; + if (a == 0x2001 && b < 0x200) { + boolean zeroMiddle = true; + for (int i = 2; i < 7; i++) zeroMiddle &= words[i] == 0; + boolean globallyReachable = (b == 1 && zeroMiddle && words[7] >= 1 && words[7] <= 3) || + b == 3 || (b == 4 && words[2] == 0x112) || (b >= 0x20 && b <= 0x3f); + if (!globallyReachable) return Scope.UNUSABLE; + } + return Scope.PUBLIC; + } + + public static boolean advertisable(InetAddress address, boolean localDevelopment) { + Scope scope = scope(address); + return scope == Scope.PUBLIC || scope == Scope.PRIVATE || + (localDevelopment && (scope == Scope.LOOPBACK || scope == Scope.DOCUMENTATION)); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java index 1ad5c258..6ff1eae1 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.util.*; import java.util.concurrent.*; +import java.util.function.Supplier; /** Profile adapter: background key/profile lifecycle only; no per-join metadata input is used. */ public final class NativeProviderTransport implements ProviderTransport { @@ -19,14 +20,14 @@ private record Epoch(String id, long notBefore, long retireAfter) {} private final NativeAdmissionServerChannel channel; private final StatelessAdmissionValidator validator; private final String incarnation; - private final InetSocketAddress advertisedAddress; + private final Supplier> advertisedAddresses; private final ScheduledFuture retireTask; private List epochs = List.of(); private boolean draining, closed; - private NativeProviderTransport(NativeAdmissionServerChannel channel, StatelessAdmissionValidator validator, String incarnation, InetSocketAddress advertisedAddress) { + private NativeProviderTransport(NativeAdmissionServerChannel channel, StatelessAdmissionValidator validator, String incarnation, Supplier> advertisedAddresses) { this.channel = channel; this.validator = validator; this.incarnation = incarnation; - this.advertisedAddress = advertisedAddress; + this.advertisedAddresses = advertisedAddresses; 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. */ @@ -35,10 +36,13 @@ public static CompletionStage open(ServerBootstrap boot } /** Explicit advertised candidate supports wildcard/local binds and operator-provisioned NAT mappings. */ public static CompletionStage open(ServerBootstrap bootstrap, InetSocketAddress bind, InetSocketAddress advertised, Path certificate, Path privateKey, AdmissionGate.Limits limits) { + return open(bootstrap, bind, () -> List.of(advertised), certificate, privateKey, limits); + } + /** Refreshes the endpoint snapshot on background profile publication; packet handling stays native. */ + public static CompletionStage open(ServerBootstrap bootstrap, InetSocketAddress bind, Supplier> advertised, Path certificate, Path privateKey, AdmissionGate.Limits limits) { CompletableFuture result = new CompletableFuture<>(); try { - if (advertised == null || advertised.isUnresolved() || advertised.getPort() == 0 || advertised.getAddress().isAnyLocalAddress()) - throw new IllegalArgumentException("Concrete advertised UDP address and fixed port required"); + checkedEndpoints(advertised.get()); NativeHostIdentity identity = NativeHostIdentity.load(certificate, privateKey); byte[] nonce = new byte[16]; new SecureRandom().nextBytes(nonce); String incarnation = HexFormat.of().formatHex(nonce); @@ -64,17 +68,33 @@ public static String audience(String incarnation) { // The provider 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 = advertisedAddress; - 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); + List endpoints; + try { endpoints = checkedEndpoints(advertisedAddresses.get()); } + catch (RuntimeException unavailable) { return CompletableFuture.failedFuture(unavailable); } + JsonArray candidates = new JsonArray(); + int index = 0; + for (InetSocketAddress endpoint : endpoints) { + JsonObject candidate = new JsonObject(); candidate.addProperty("address", endpoint.getAddress().getHostAddress()); + candidate.addProperty("port", endpoint.getPort()); candidate.addProperty("component", 1); candidate.addProperty("foundation", Integer.toString(++index)); + candidate.addProperty("priority", 2130706431 - (index - 1) * 256); candidate.addProperty("protocol", "udp"); candidate.addProperty("type", "host"); + 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); } + private static List checkedEndpoints(List endpoints) { + List unique = endpoints.stream().distinct().toList(); + if (unique.isEmpty() || unique.size() > 32) throw new IllegalArgumentException("Publish 1-32 UDP endpoints"); + for (InetSocketAddress endpoint : unique) { + if (endpoint == null || endpoint.isUnresolved() || endpoint.getPort() == 0 || endpoint.getAddress().isAnyLocalAddress() + || endpoint.getAddress().isMulticastAddress() || endpoint.getAddress().isLinkLocalAddress()) + throw new IllegalArgumentException("Concrete advertised UDP address and fixed port required"); + } + return List.copyOf(unique); + } @Override public synchronized CompletionStage installTicketKeys(List keys) { if (closed) return CompletableFuture.failedFuture(new IllegalStateException("Native endpoint closed")); try { diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/EndpointAddressTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/EndpointAddressTest.java new file mode 100644 index 00000000..6fccce04 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/EndpointAddressTest.java @@ -0,0 +1,30 @@ +package org.cloudburstmc.netty.signalling.admission; + +import org.junit.jupiter.api.Test; +import java.net.Inet4Address; +import java.net.UnknownHostException; +import static org.junit.jupiter.api.Assertions.*; + +class EndpointAddressTest { + @Test void classifiesPrivateSharedAndPublicBoundaries() throws Exception { + for (String ip : new String[]{"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1", "100.64.0.1", "100.127.255.255", "fc00::1", "fd7a:115c:a1e0::1"}) + assertEquals(EndpointAddress.Scope.PRIVATE, EndpointAddress.scope(EndpointAddress.parse(ip)), ip); + for (String ip : new String[]{"8.8.8.8", "172.15.255.255", "172.32.0.0", "100.63.255.255", "100.128.0.0", "192.0.0.9", "2606:4700:4700::1111", "2001:4860::1"}) + assertEquals(EndpointAddress.Scope.PUBLIC, EndpointAddress.scope(EndpointAddress.parse(ip)), ip); + } + @Test void excludesUnusableAddressesAndOnlyAllowsFixturesExplicitly() throws Exception { + for (String ip : new String[]{"0.0.0.0", "169.254.1.1", "224.0.0.1", "255.255.255.255", "198.18.0.1", "::", "fe80::1", "fec0::1", "ff02::1", "64:ff9b::808:808", "2001:2::1", "2002:808:808::1"}) + assertFalse(EndpointAddress.advertisable(EndpointAddress.parse(ip), true), ip); + for (String ip : new String[]{"127.0.0.1", "::1", "192.0.2.1", "2001:db8::1", "3fff::1"}) { + assertFalse(EndpointAddress.advertisable(EndpointAddress.parse(ip), false), ip); + assertTrue(EndpointAddress.advertisable(EndpointAddress.parse(ip), true), ip); + } + } + @Test void normalizesMappedIpv4AndRejectsDnsAndAmbiguousLiterals() throws Exception { + var mapped = EndpointAddress.parse("::ffff:192.168.1.2"); + assertInstanceOf(Inet4Address.class, mapped); + assertEquals(EndpointAddress.Scope.PRIVATE, EndpointAddress.scope(mapped)); + for (String ip : new String[]{"localhost", "game.example", "127.1", "010.0.0.1", "256.0.0.1", "fe80::1%eth0", "[::1]", "2001:::1", "::ffff:192.168.001.1", "8.8.8.8\n"}) + assertThrows(UnknownHostException.class, () -> EndpointAddress.parse(ip), ip); + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java index ba45220a..7830e718 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java @@ -23,6 +23,99 @@ @Tag("native") class NativeAdmissionIntegrationTest { + @Test @Timeout(30) void dualStackWildcardAcceptsBothFamiliesAndRetainsSingleTicketOwnership() throws Exception { + var id = identity(); var group = new DefaultEventLoopGroup(1); + int port = 49188; + var v4 = new InetSocketAddress("127.0.0.1", port); + var v6 = new InetSocketAddress("::1", port); + var advertised = new AtomicReference<>(List.of(v6, v4, v4)); + NativeProviderTransport host = null; + try { + var bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInitializer() { + @Override protected void initChannel(Channel channel) {} + }); + host = NativeProviderTransport.open(bootstrap, new InetSocketAddress("::", port), advertised::get, + id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get(10, TimeUnit.SECONDS); + host.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey("K001", TestSignallingProvider.SECRET))).toCompletableFuture().get(); + var profile = host.hostProfile().toCompletableFuture().get(); + assertEquals(2, profile.getAsJsonArray("candidates").size()); + String incarnation = profile.getAsJsonObject("statelessAdmission").get("incarnation").getAsString(); + String audience = NativeProviderTransport.audience(incarnation); + var endpoint = host.channel(); + for (var destination : List.of(v4, v6)) { + var other = destination.equals(v4) ? v6 : v4; + try (var socket = new DatagramSocket(new InetSocketAddress(destination.getAddress(), 0)); + var duplicate = new DatagramSocket(new InetSocketAddress(other.getAddress(), 0)); + var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(destination.getAddress()), Runnable::run)) { + client.createDataChannel("ReliableDataChannel"); + String ufrag = destination.equals(v4) ? "dualStackClient4" : "dualStackClient6"; + client.setLocalDescription("offer", ufrag, "p".repeat(32)); + var answer = TestSignallingProvider.answer(client.localDescription(), id.fingerprint(), port, + System.currentTimeMillis() + 30_000, audience, false); + byte[] request = nominatedBinding(answer.token() + ":" + ufrag, answer.password()); + socket.setSoTimeout(2000); + socket.send(new DatagramPacket(request, request.length, destination)); + byte[] bytes = new byte[2048]; var response = new DatagramPacket(bytes, bytes.length); + socket.receive(response); + assertEquals(destination.getAddress(), response.getAddress()); assertEquals(port, response.getPort()); + assertEquals(0x0101, Short.toUnsignedInt(ByteBuffer.wrap(bytes).getShort())); + assertArrayEquals(Arrays.copyOfRange(request, 8, 20), Arrays.copyOfRange(bytes, 8, 20)); + long creations = endpoint.creationAttempts(); + duplicate.setSoTimeout(250); + duplicate.send(new DatagramPacket(request, request.length, other)); + assertThrows(SocketTimeoutException.class, () -> duplicate.receive(new DatagramPacket(new byte[2048], 2048))); + assertEquals(creations, endpoint.creationAttempts(), "An alternate family cannot allocate a second peer with the same ticket"); + } + } + assertEquals(2, endpoint.creationAttempts()); + advertised.set(List.of(v4)); + var changed = host.hostProfile().toCompletableFuture().get(); + assertEquals(1, changed.getAsJsonArray("candidates").size()); + assertEquals(incarnation, changed.getAsJsonObject("statelessAdmission").get("incarnation").getAsString()); + advertised.set(List.of()); + assertTrue(host.hostProfile().toCompletableFuture().isCompletedExceptionally()); + } finally { + if (host != null) host.close().toCompletableFuture().get(10, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + try (var socket = new DatagramSocket(new InetSocketAddress("::", port))) { assertEquals(port, socket.getLocalPort()); } + } + + @Test @Timeout(40) void clientsOfEitherFamilyOpenBothDataChannelsFromTheSameCandidateList() throws Exception { + var id = identity(); var group = new DefaultEventLoopGroup(1); int port = 49187; + NativeProviderTransport host = null; + try { + var bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInitializer() { + @Override protected void initChannel(Channel channel) {} + }); + host = NativeProviderTransport.open(bootstrap, new InetSocketAddress("::", port), + () -> List.of(new InetSocketAddress("::1", port), new InetSocketAddress("127.0.0.1", port)), + id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get(10, TimeUnit.SECONDS); + host.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey("K001", TestSignallingProvider.SECRET))).toCompletableFuture().get(); + String incarnation = host.hostProfile().toCompletableFuture().get().getAsJsonObject("statelessAdmission").get("incarnation").getAsString(); + for (String ip : List.of("127.0.0.1", "::1")) { + try (var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(InetAddress.getByName(ip)), Runnable::run)) { + AtomicInteger opened = new AtomicInteger(); + client.createDataChannel("ReliableDataChannel").onOpen.register(dc -> opened.incrementAndGet()); + client.createDataChannel("UnreliableDataChannel", DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(true, true, 0, 0))) + .onOpen.register(dc -> opened.incrementAndGet()); + client.setLocalDescription("offer", ip.equals("::1") ? "clientWithIpv6" : "clientWithIpv4", "p".repeat(32)); + var answer = TestSignallingProvider.answer(client.localDescription(), id.fingerprint(), port, + System.currentTimeMillis() + 30_000, NativeProviderTransport.audience(incarnation), false); + String sdp = answer.sdp().replace("a=candidate:1 1 UDP 2130706431 127.0.0.1", "a=candidate:2 1 UDP 2130706175 127.0.0.1") + .replace("a=end-of-candidates", "a=candidate:1 1 UDP 2130706431 ::1 " + port + " typ host\r\na=end-of-candidates"); + client.setRemoteDescription(sdp, SessionDescriptionType.ANSWER); + await(() -> opened.get() == 2); + assertTrue(client.closeAndAwait(Duration.ofSeconds(5))); + } + } + assertEquals(2, host.channel().creationAttempts()); + } finally { + if (host != null) host.close().toCompletableFuture().get(10, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } + @TempDir Path directory; NativeHostIdentity identity() throws Exception { Path cert = directory.resolve("host.crt"), key = directory.resolve("host.key"); From 91adcd9760aebea4ebab7268b8bf1339131671f5 Mon Sep 17 00:00:00 2001 From: Zulu Date: Mon, 7 Sep 2026 20:17:02 +0100 Subject: [PATCH 12/15] Simplify experimental NXS to registration, heartbeat and outcomes --- docs/external-signalling/README.md | 515 +++--------------- docs/external-signalling/nxs-v1.schema.json | 472 ++++++++++++++-- docs/external-signalling/wire-reference.md | 419 ++++++++++++++ .../netty/signalling/CheckInSchedule.java | 8 +- .../netty/signalling/ProviderClient.java | 243 +++++---- .../netty/signalling/ProviderTransport.java | 8 +- .../admission/NativeProviderTransport.java | 14 +- .../netty/signalling/CheckInScheduleTest.java | 4 +- .../signalling/IndependentProviderStub.java | 63 ++- .../netty/signalling/ProviderBench.java | 2 +- .../netty/signalling/ProviderClientTest.java | 58 +- .../signalling/ProviderJourneysTest.java | 4 +- .../NativeAdmissionIntegrationTest.java | 2 +- 13 files changed, 1181 insertions(+), 631 deletions(-) create mode 100644 docs/external-signalling/wire-reference.md diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md index 3a94a223..3d3ab699 100644 --- a/docs/external-signalling/README.md +++ b/docs/external-signalling/README.md @@ -1,457 +1,112 @@ -# NetherNet External Signalling v1 +# NetherNet External Signalling -NetherNet External Signalling (NXS) lets a NetherNet server use a signalling -provider chosen by its operator. The server registers with the provider and -publishes the information clients need to connect. Each client then brings a -short-lived token that the server can check locally. +NXS lets a NetherNet host use a signalling provider chosen by its operator. +The integration has three flows: register, heartbeat, and accept joins with +asynchronous feedback. This is an experimental contract, updated in place. +The [wire reference](wire-reference.md), [schema](nxs-v1.schema.json) and +[fixtures](nxs-v1.fixtures.json) specify the exact formats under Apache-2.0. -For example, a server can publish its address and certificate fingerprint when -it starts. Later, the provider gives a client those details and a token. The -server checks the token in the client's first packet. It does not need to ask -the provider whether to accept that connection. +## 1. Register with a provider -This is an experimental open specification, identified by -`urn:nethernet:external-signalling:v1`. This document, the -[schema](nxs-v1.schema.json), and the test fixtures define one versioned protocol. -They use this repository's Apache-2.0 license. +Configure the provider's HTTPS origin and fetch +`/.well-known/nethernet-external-signalling`. Check its supported authentication +and use the operation URLs it returns. All URLs must remain on that origin; +never follow redirects when discovering or sending credentials. -## How a connection works +Create and save a P-384 machine key for this instance. Call `register` with the +public key and one of these enrollment choices: -1. The host registers with the provider and proves that it owns its signing key. -2. The host publishes its address, certificate fingerprint, and connection - settings. It sends heartbeats to renew its registration lease. -3. The provider uses that information to give a client a connection answer and - an admission token. -4. The client sends the token in its first STUN packet to the host. -5. The host checks the packet and token, then establishes the connection. The - client's certificate must match the fingerprint in the token. -6. The host reports connection and game outcomes to the provider afterwards. +- `new-service`: create a service and its first instance, using advertised + anonymous proof of work or a bearer token. +- `attach-instance`: add an instance to an existing service, using a bearer + token and authorized placement metadata. -Here, **stateless admission** means that the host needs no saved state for that -client before its first packet arrives. The host still keeps its own keys, -registration, and active connections. A conforming implementation MUST NOT -require a push, poll, shared lookup, offer fetch, or pre-staged client state to -admit a client. Reports about the connection or game outcome never determine -whether the host can accept that first packet. +The provider returns a challenge. Sign its bound proof and call `complete`. +Completion returns the assigned IDs, a fresh process generation, lease deadline +and initial admission key. Save them before publishing readiness. Completion +starts the generation; there is no separate activation call. -NXS covers communication between the host and provider. Account systems, -credential issuance, billing, Microsoft login, DNS management, and the policy -for choosing a host are outside this specification. +On restart, reuse the saved machine key and call `register` with +`{registrationId,protocol,profile}`. Prove the returned challenge through +`complete` to preserve IDs and fence the previous process. If a completion reply +was lost, recover using the saved challenge ID. Repeating a consumed completion +cannot start another generation or reveal its secrets again. -### Terms +Every live replica needs its own key and private state directory. Account/token +issuance, ownership claims and fleet administration belong to the provider. -| Term | Meaning here | -| --- | --- | -| Host or instance | One running NetherNet server. Its instance ID survives a restart. | -| Provider | The service that registers hosts and gives clients connection information. | -| Service | A provider-assigned registration that can contain one or more instances. | -| Lease | The period for which an instance is eligible to receive new connections. Heartbeats renew it. | -| Generation | A counter advanced on activation. Requests from earlier generations are rejected. | -| Host profile | The address, certificate fingerprint, and other settings clients need to connect. | -| Incarnation | A random ID for one bound UDP endpoint. A newly bound endpoint gets a new ID. | -| Admission | Checking a client's token and first packet before creating its native peer. | -| Key epoch | One version of an admission key, identified by `keyId`. | - -ICE checks network reachability using STUN packets. DTLS authenticates and -encrypts the connection. SCTP carries the data channels over that connection. -A UDP tuple identifies a packet's source address and port at a host endpoint. - -## Version and discovery +## 2. Heartbeat to the provider -| Field | v1 value | -| --- | --- | -| Registration/request protocol | `nethernet-external-signalling-v1` | -| Machine request signature | `nxs-es384-v1` | -| Operational profile | `nxs-admission-v1` | -| Discovery path | `/.well-known/nethernet-external-signalling` | -| Stateless capability | `nethernet.stateless-admission.v1` | -| Stateless carrier prefix | `NXS1` | +Send a signed `heartbeat` immediately after startup and whenever its returned +schedule says to check in. The request carries: -### Provider origin and operation URLs +- Health, capacity, load and optional public server status. +- `hostProfile` when endpoint details change; otherwise `hostProfileRevision`. +- `installedKeyIds`, listing installed admission epochs with the active one last. +- Local `state` (`serving`, `draining` or `closed`), the applied provider-state + revision, and whether the integration can report game outcomes. -The configured origin MUST use HTTPS. HTTP is permitted only for loopback -development. Normalize the origin by lowercasing its scheme and host and omitting -default ports. It cannot contain credentials, a path, a query, or a fragment. +The reply returns the accepted profile revision, readiness, lease/schedule, +provider state and any admission-key updates. A host becomes routable only with +a live lease, usable profile and acknowledged installed key. -Fetch discovery with an unauthenticated `GET`. Its `provider` and `controlOrigin` -MUST equal the configured origin. Each operation URL MUST have that same origin -and contain no userinfo or fragment. Clients MUST disable redirects for discovery -and for calls that carry credentials. Sign encoded paths and query strings -exactly as transmitted. +Apply provider state before acknowledging its revision. `draining` stops new +joins and preserves existing sessions; `closed` closes the transport. Provider +routing and credential decisions take effect independently of host check-in. -Discovery contains `provider`, `controlOrigin`, the arrays `protocols`, -`signatures`, `profiles`, and `modes`, an `operations` map, `authorization`, -`limits`, and optional `extensions`. Before sending credentials, clients reject -an unsupported protocol, profile, signature, mode, or required extension. +For a replacement admission key, include a fresh `keyRequestId`. Save and install +the returned key, then immediately heartbeat with the updated profile and +installed IDs. The provider cannot issue new tokens under that epoch before the +acknowledgement. Retain older keys until their reported retirement deadlines. -The [operation table](#operations) defines the operation names. Clients get their -URLs from discovery. `/v1/nxs/` is a recommended path, but providers -can use other paths. +On orderly shutdown, stop accepting new joins and immediately heartbeat with +`state: "draining"`. Do not wait for the periodic timer. A provider outage lets +routing leases expire; it does not by itself close established sessions. -### Authorization and limits +## 3. Accept a stateless join and report the outcome -`authorization` contains `header: "Authorization"` and a `schemes` array. Each -entry has a `scheme` and its supported `modes`: +The provider gives the client the host's connection details and a short-lived +admission token. The client carries that token in its first STUN packet. The +host validates token authentication, expiry, endpoint/client binding and STUN +integrity locally before creating a peer. DTLS must then verify the client +certificate fingerprint from the token. -| Scheme | Allowed modes | -| --- | --- | -| `anonymous-proof-of-work` | `new-service` | -| `bearer-token` | `new-service`, `attach-instance`, or both | +No provider push, poll, lookup or pre-staged client state may gate admission. +The host retains its own background keys and active connection state. -A provider need only advertise the schemes it accepts. It decides how tokens -are issued, what they authorize, and whether they can be reused. Every flow also -requires proof that the instance owns its signing key. +Send signed `outcomes` batches asynchronously, independently of heartbeat timing: -| Limit | v1 constraint | +| Observation | Required feedback | | --- | --- | -| `maxBodyBytes` | At most 65536 | -| `clockSkewMs` | At most 60000 | -| `heartbeatIntervalMs` | 1000–30000 | -| `leaseMs` | Advertised lease duration | -| `maxControlPage` | At most 100 | - -`checkInVersion: 1` enables the provider to set the next check-in time in its -response. A provider MUST advertise every limit it enforces, reject oversized -bodies, and return errors as `{"code":"lowercase_machine_code"}` with an -appropriate HTTP failure status. Clients limit response size before parsing. - -On a transient transport failure or HTTP 429, 502, 503, or 504, the supplied -client makes at most three attempts in total. Retries use exponential delays -with jitter and an upper bound. A `Retry-After` value over ten seconds returns -a retry-later result. Retrying never extends a lease or challenge expiry. - -## Registration and persistent identity - -### Save the instance key - -Generate a fresh P-384 machine signing key for each logical instance. Save it -before requesting a challenge. A restart reuses that instance's saved state; -live replicas cannot share a key or state directory. Images and templates MUST -contain neither machine identity nor DTLS private keys. - -Clients lock their state directory and write private state atomically with -owner-only permissions. Sync both files and directories to durable storage. -If saving state fails, stop advertising healthy readiness. - -### Request a challenge - -The request contains `protocol`, `mode`, `profile`, `publicKeyJwk`, explicit -`authorization: {scheme}`, and optional `label` and `placement`. - -Send a bearer credential only to the challenge operation, in -`Authorization: Bearer `. It MUST NOT appear in JSON, proofs, saved state, -or logs. `attach-instance` requires both bearer authorization and placement. -The token authorizes access to the service; a client-provided label grants no -permission. - -Placement is `{region,pool,tags?}`: - -| Field | Constraint | -| --- | --- | -| `region` | Immutable routing label matching `[A-Za-z0-9_-]{1,32}` | -| `pool` | Immutable routing label matching `[A-Za-z0-9_-]{1,64}` | -| `tags` | At most 16 keys matching `[A-Za-z0-9_.-]{1,32}`; values are trimmed strings of 1–64 characters with no control characters | - -The challenge binds the exact placement. At completion, the provider rechecks -that the token authorizes it as part of the same atomic operation that creates -the registration. These fields do not prescribe how a provider selects a host. - -The public JWK is EC/P-384. Its `x` and `y` values use canonical, unpadded -base64url and each encode exactly 48 bytes. It MUST NOT contain `d`. The RFC 7638 -thumbprint is SHA-256 of UTF-8 JSON with members in this exact order: -`crv,kty,x,y`. ES384 signatures use the 96-byte IEEE-P1363 form `r || s`, encoded -as unpadded base64url. Reject DER signatures and noncanonical base64url. - -The challenge response contains `protocol`, `signature`, `challengeId`, `nonce`, -`audience`, `thumbprint`, `context`, `contextDigest`, `expiresAt`, `serverTime`, -and `pow: {algorithm:"sha256-leading-zero-bits-v0",difficulty}`. - -Proof-of-work difficulty is 0–24. Bearer-authorized and recovery flows use zero. -An authorization reference is an opaque identifier, never the credential itself. -Expiry and server times are integer epoch milliseconds. - -### Complete registration - -Canonical arrays use UTF-8 JSON with no whitespace or Unicode normalization. -Use an empty string for a missing context string. `contextDigest` is the -unpadded base64url SHA-256 digest of: - -```text -[mode,profile,label,authorizationId,serviceId,region,pool,registrationId] -``` - -When tags are nonempty, append `tagsDigest` to that array. Compute `tagsDigest` -in the same way from sorted `[key,value]` pairs. The completion proof is: - -```text -[protocol,"complete",audience,challengeId,nonce,thumbprint,contextDigest, - expiresAt,proofNonce,idempotencyKey] -``` - -Proof of work counts the leading zero bits in SHA-256 of those bytes. Send -`protocol,challengeId,proofNonce,idempotencyKey,signature` to complete registration. -The provider MUST check expiry, binding, signature, difficulty, current authority, -and single-use completion atomically with resource creation. - -Retrying completion MUST NOT return one-time key secrets again. If completion -was interrupted, recover the registration by proving ownership of the same key. - -Completion returns `protocol,provider,registrationId,serviceId,instanceId,keyId, -profile,publicAddress,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, -readiness`, plus optional one-time `ticketKey` and `extensions`. Save the IDs and -key material before activation. Remove secrets from registration results exposed -to applications and from diagnostic output. - -## Signed lifecycle and host profile - -### Sign operational requests - -Use the registered machine key for every operational request. The enrollment -bearer token is used only for the challenge request. - -Required headers are `nxs-instance-id`, `nxs-key-id`, `nxs-timestamp`, -`nxs-signature-version`, `nxs-generation`, `nxs-sequence`, `nxs-signature`, and -`idempotency-key`. The timestamp is epoch milliseconds. Generation and sequence -are nonnegative integers. Save each reserved sequence number before sending its -request. The signature covers this array: - -```text -[protocol,signatureVersion,audience,method,encodedPathAndQuery,timestamp, - instanceId,keyId,idempotencyKey,generation,sequence,base64url(sha256(bodyBytes))] -``` - -For an empty body, hash a zero-length byte sequence. Providers reject stale -generations, reused sequence numbers, invalid timestamps, and invalid signatures. -An idempotent retry can return the recorded result, with secrets removed, if its -intent and semantic request are unchanged. It cannot apply the operation again. - -Activation increments the generation and resets the sequence. The provider then -rejects requests from the old process. Signed state-changing operations must use -the active profile. To change profiles, recover the registration and send a -signed activation request. - -### Operations - -| Operation | Request | Required result or behavior | -| --- | --- | --- | -| `challenges` | POST challenge request, optional bearer | Challenge bound to the registration request | -| `complete` | POST completion proof | New or recovered registration; return secrets only once | -| `recover` | POST `{registrationId,protocol,profile}` | Challenge for the current or pending machine key; preserve assigned IDs | -| `activate` | Signed POST `{profile}` | Increment `leaseGeneration`, return `leaseDeadline`, and reset stale host readiness | -| `readiness` | Signed GET | Whether the host can receive new connections, with reasons and optional extension metadata | -| `host-profile` | Signed POST profile below | A `revision` cannot change once published; updates use a higher revision. Reject unusable candidates or keys | -| `heartbeat` | Signed POST health/status below | Receipt time, renewed lease, and optional check-in schedule | -| `control` | Signed GET, optional cursor | Limited `commands` page, optional `cursor`, and `serverTime` | -| `control/ack` | Signed POST `{cursor}` | Acknowledge only lifecycle commands that have finished | -| `ticket-keys` | Signed POST `{}` | One-time `{ticketKey:{keyId,secret,...}}` for a new key epoch | -| `ticket-keys/ack` | Signed POST `{keyId}` | Confirm the key is installed before using its epoch for new connections | -| `ticket-events` / `events` | Signed POST `{events:[...]}` | Limited batches of asynchronous observations; retries do not duplicate them | -| `rotate` | Signed POST `{publicKeyJwk,proof}` | New `keyId` after proof of ownership of the replacement key | -| `retire` | Signed POST `{keyId}` | Retire the previous machine signing key | -| `drain` | Signed POST `{}` | Stop directing and accepting new connections; preserve existing sessions | -| `deregister` | Signed POST `{}` | Stop directing connections to the instance and end its registration | - -### Rotate a machine key - -The rotation proof bytes are `[protocol,"rotate",audience,instanceId,oldKeyId, -newThumbprint,generation,idempotencyKey]`. Save the replacement private key before -requesting rotation. Save the result before retiring the old key. After an -interrupted rotation, recovery can use the provider's returned key thumbprint -to identify which key is current. - -### Publish the host profile +| Both data channels become usable | `ticket.data_channels_open` | +| An authenticated observed attempt fails before transport becomes usable | `ticket.failed`, with a bounded reason | +| The game admits or rejects the player | `ticket.game_joined` or `ticket.game_rejected`, when the integration observes this boundary | -`host-profile` contains `candidates`, `dtlsFingerprint`, `credentialKeyId`, -`sctpPort`, `maxMessageSize`, and `statelessAdmission: {capability,incarnation}`. -Generate a fresh random 16-byte `incarnation`, encoded as lowercase hex, for -each bound native endpoint. The fingerprint is `sha-256 ` followed by the -certificate's digest bytes in colon-separated uppercase hex. +Declare game-outcome support as `available` or `unavailable` in heartbeat. A +transport connection never proves successful gameplay. Intermediate ICE, DTLS +and SCTP stages are optional diagnostics in the same stream. -Each candidate contains `foundation,component,protocol,priority,address,port,type`. -Publish only reachable UDP candidates that are explicitly chosen for advertisement. -The bind address and the advertised address serve different purposes. A host can -bind to all interfaces, but it cannot advertise wildcard `0.0.0.0` or `::`. -The deployment or provider must establish reachability through NAT or a relay; -a passing registration test does not prove that clients can reach the address. +Each event contains `ticketId`, `stage`, `occurredAt` and optional `reason`. +Retry bounded batches without duplicating observations. Do not send player +identity, SDP, credentials or game payloads. Reporting failure never delays +admission or renews a lease. Missing feedback means an unknown outcome: a client +may never reach the host, or the host may crash before reporting. -Prepare the host's DTLS certificate and key before publishing its profile. Keep -the private key local. All peers using that profile use that certificate, so -clients see the fingerprint the provider advertised. The host may use a new -certificate for a later endpoint incarnation after publishing its new fingerprint. -A permanent certificate shared across a fleet is neither required nor advised. +## Operation reference -Three types of key have separate jobs: +All operations use POST; all except `register` and `complete` use the machine +request signature. URLs come from discovery. -| Key | Purpose | +| Operation | Purpose | | --- | --- | -| Machine signing key | Authenticate the host's requests to the provider | -| DTLS certificate and private key | Authenticate the host during the client connection | -| Admission key | Protect and validate the client's admission token | - -### Install admission keys - -Each key has a `keyId` of four uppercase alphanumeric characters, a `secret` of -32–256 UTF-8 characters, and optional `notBefore` and `retireAfter` times in epoch -milliseconds. Install at most eight epochs atomically and acknowledge them. Then -publish a profile that uses an active, installed epoch. - -Reject tokens before the key's activation time or after its retirement time. -Erase retired key material. Rotating keys does not extend token expiry. - -### Send heartbeats and report readiness - -A heartbeat contains `healthy,capacity,load,protocolVersion,build,hostProfileRevision, -clockUnixMillis` and optional `region,serverStatus,checkInVersion`. Capacity and -load describe routing capacity; they are independent of the advertised player -and maximum-player counts. Status contains -`name,protocol,version,level,players,maxPlayers,gameType`. A failed publication -does not refresh the timestamp of previously published status. - -A host is ready to receive connections only when it has a current identity and -generation, a live lease, a usable fresh host profile, and acknowledged installed -keys. Optional product extensions cannot affect this core readiness check. - -With check-in v1, the heartbeat response contains ISO8601 `receivedAt` and: - -```text -checkIn: {version:1,afterMillis,nextCheckInAt,leaseExpiresAt,minUpdateIntervalMillis, - controlPollAfterMillis} -``` - -Absolute times in `checkIn` are epoch milliseconds. `nextCheckInAt` is before -lease expiry. Hosts use monotonic clocks for scheduling and include network time -in the interval. Changed activity or status can trigger an earlier heartbeat, -subject to the rate limit. On restart, publish immediately and discard the old -schedule. If the provider is unavailable, routing leases expire; existing sessions -are not closed solely because of that outage. - -### Handle controls and report outcomes - -This profile supports `noop,drain,suspend,revoke`. Do not silently acknowledge an -unknown control. An unknown command can prevent advancing the page cursor, but -later known lifecycle commands still need processing. `join-admission` is not a -v1 control; accepting a client never waits for that command. - -Event batches contain at most 100 entries. Keep only redacted correlation data, -stage or type, timestamp, and reason fields with size limits. Never send SDP, -private keys, player identity, or game payloads as telemetry. A working transport -connection is a separate outcome from `ticket.game_joined` (ready to play) or -`ticket.game_rejected`. - -## Stateless admission carrier - -### Carry the token in the ICE username - -The client's first STUN USERNAME is `:`, where: - -```text -answerUfrag = "NXS1" + keyId + unpaddedBase64(nonce || ciphertext || tag) -``` - -Use the standard base64 alphabet, including `+` and `/`, which ICE permits. -Do not use base64url. The total ufrag length is at most 256 characters. Before -allocating peer state, reject noncanonical encoding, trailing padding, a wrong -prefix, unknown key epochs, and oversized input. - -AES-256-GCM uses a random 12-byte nonce and a 16-byte tag. Its key is -`HMAC-SHA256(secret, "nxs-stateless-aead-v1" || NUL || audience)`. -The audience is `nxs-stateless-host-v1/`. The additional authenticated -data (AAD) is -`"nxs-stateless-admission-v1" || NUL || ("NXS1"+keyId) || NUL || audience || NUL || clientUfrag`. - -| Plaintext offset | Size | Meaning, unsigned big-endian where numeric | -| --- | --- | --- | -| 0 | 4 | Expiry in epoch seconds, exactly representable in milliseconds | -| 4 | 32 | SHA-256 client certificate fingerprint | -| 36 | 2 | Client SCTP port, 1–65535 | -| 38 | 4 | Client maximum message size, 1–262144 | -| 42 | 16 | Opaque caller-context hash, no account-specific interpretation | -| 58 | 8 | NetherNet network ID, unsigned 64-bit | -| 66 | 1 | Client ICE password length, 22–91 | -| 67 | N | Client ICE password in ICE base64 alphabet | - -The host's local ICE password is the unpadded standard base64 encoding of the -first 24 bytes of -`HMAC-SHA256(secret, "nxs-stateless-ice-v1" || NUL || audience || NUL || answerUfrag)`. -The ticket correlation ID is the first 16 bytes of SHA-256 of the ASCII answer -ufrag, encoded as lowercase hex. - -### Validate the first packet - -A token can be valid for at most 120 seconds. Before assigning the UDP tuple to a -peer or allocating a peer connection, the host checks expiry, field bounds, GCM -authentication, client binding, and STUN MESSAGE-INTEGRITY. The DTLS handshake -MUST then verify the client fingerprint from the token. - -Only a retransmission of the identical token from the same UDP tuple can reuse -a reservation. Reject the same token from another tuple. Also reject a conflicting -admission on an occupied tuple. - -Limit the number of sessions, pending handshakes, used-token records, queued -validation tasks, and retained requests. Duplicate requests for the same pending -attempt share one decision. Preserve enough of the first request to respond after -acceptance: completing admission MUST NOT depend on the client retransmitting. -Release admission capacity only after the connection's resources have been -released. A failed integrity check MUST NOT consume the token, since a copied -token alone does not prove that the sender has its ICE password. - -#### Reference implementation - -The supplied Network implementation uses a 60-second token limit. libjuice -retains the first STUN packet and sends parsed metadata to Java for asynchronous -token validation. libdatachannel verifies STUN integrity before creating the -peer, outside the UDP receive lock. It then processes the retained request after -the application has installed its callbacks. Established transport packets stay -native, and capacity remains reserved until native teardown finishes. - -Other implementations may meet the requirements above using different languages, -threading models, and transport libraries. - -## Optional extensions and compatibility - -### Extensions - -Providers can add optional application metadata without making it part of NXS. -For example, a product could supply an account-claim link. NXS does not define -what claiming an account means or require other providers to implement it. - -`extensions` is an object with at most 16 reverse-DNS namespace keys, such as -`com.example.feature`, and at most 16384 bytes of encoded UTF-8 JSON. Keys use -lowercase domain-style labels and have at most 128 characters. Each value is -`{version:positiveInteger,critical:boolean,data:object}`. - -Pass through or ignore unknown optional extensions; never execute them -automatically. Reject unsupported critical extensions before sending credentials -or activating. An optional extension cannot change the core protocol rules. -TLS and request signatures still authenticate bodies and operation paths. - -An extension can advertise URLs in `data.operations`. An application can request -one of these operations only after validating the namespace, version, and meaning. -The generic transport still requires the same provider origin and signs the -exact path. - -### Upgrade and rollback - -Recover saved IDs and keys into this profile with -`recover {registrationId,protocol,profile}`, then signed `activate {profile}`. -Verify the same key and origin, preserve IDs and DTLS files, and record the new -profile and generation atomically. Legacy protocol bytes MUST NOT be relabelled -as v1. Providers may keep separately negotiated legacy adapters; the neutral -Java module implements only NXS. - -Rollback uses the previous client with explicit recovery and signed profile -activation. Never bypass machine authentication or copy a live state directory. - -## Conformance - -Run `node docs/external-signalling/fixtures.mjs` to verify the independent -JavaScript signing, encryption, and fixture hashes. `--write` regenerates public -test signatures. The JVM suites load these same files through Gradle resources. - -The independent test provider covers registration, signed operations, status, -keys, outcomes, drain, and recovery without a product account system. Native -tests separately check raw STUN admission and DTLS transport. - -Report stock-client admission, gameplay, and routing across two hosts separately -from fixture and native tests. Passing those tests does not prove that a stock -client can join and play. +| `register` | Request an enrollment or recovery challenge | +| `complete` | Prove the challenge and start the process generation | +| `heartbeat` | Exchange host health, profile, keys, lifecycle state and readiness | +| `outcomes` | Report transport and game observations | +| `rotate` | Prove and install a replacement machine signing key | +| `retire` | Retire the previous machine signing key | +| `deregister` | Permanently end this instance's registration | + +Machine-key maintenance is separate from admission-key updates. Exact signing, +request fields, key handling, token layout, bounds and retries are in the +[wire reference](wire-reference.md). diff --git a/docs/external-signalling/nxs-v1.schema.json b/docs/external-signalling/nxs-v1.schema.json index b003b4e0..36ce7314 100644 --- a/docs/external-signalling/nxs-v1.schema.json +++ b/docs/external-signalling/nxs-v1.schema.json @@ -184,6 +184,49 @@ }, "extensions": { "$ref": "#/$defs/extensions" + }, + "operations": { + "type": "object", + "required": [ + "register", + "complete", + "heartbeat", + "outcomes", + "rotate", + "retire", + "deregister" + ], + "properties": { + "register": { + "type": "string", + "format": "uri" + }, + "complete": { + "type": "string", + "format": "uri" + }, + "heartbeat": { + "type": "string", + "format": "uri" + }, + "outcomes": { + "type": "string", + "format": "uri" + }, + "rotate": { + "type": "string", + "format": "uri" + }, + "retire": { + "type": "string", + "format": "uri" + }, + "deregister": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false } } }, @@ -290,17 +333,6 @@ "placement" ] }, - "activation": { - "type": "object", - "required": [ - "profile" - ], - "properties": { - "profile": { - "const": "nxs-admission-v1" - } - } - }, "rotation": { "type": "object", "required": [ @@ -315,14 +347,6 @@ "secret" ] }, - "pendingAction": { - "type": "object", - "required": [ - "url", - "expiresAt", - "text" - ] - }, "extensions": { "type": "object", "maxProperties": 16, @@ -373,6 +397,396 @@ "const": "nxs-admission-v1" } } + }, + "register": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/$defs/recovery" + } + ] + }, + "hostState": { + "enum": [ + "serving", + "draining", + "closed" + ] + }, + "hostProfile": { + "type": "object", + "required": [ + "candidates", + "dtlsFingerprint", + "credentialKeyId", + "sctpPort", + "maxMessageSize", + "statelessAdmission" + ], + "properties": { + "candidates": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "required": [ + "foundation", + "component", + "protocol", + "priority", + "address", + "port", + "type" + ], + "properties": { + "foundation": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,32}$" + }, + "component": { + "const": 1 + }, + "protocol": { + "const": "udp" + }, + "priority": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "address": { + "type": "string", + "maxLength": 253 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "type": { + "enum": [ + "host", + "srflx", + "relay" + ] + } + } + } + }, + "dtlsFingerprint": { + "type": "string", + "pattern": "^sha-256 [0-9A-F]{2}(?::[0-9A-F]{2}){31}$" + }, + "credentialKeyId": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + }, + "sctpPort": { + "const": 5000 + }, + "maxMessageSize": { + "const": 262144 + }, + "statelessAdmission": { + "type": "object", + "required": [ + "capability", + "incarnation" + ], + "properties": { + "capability": { + "const": "nethernet.stateless-admission.v1" + }, + "incarnation": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + } + } + } + }, + "heartbeat": { + "type": "object", + "required": [ + "healthy", + "capacity", + "load", + "protocolVersion", + "clockUnixMillis", + "checkInVersion", + "state", + "appliedStateRevision", + "gameOutcomes" + ], + "properties": { + "healthy": { + "type": "boolean" + }, + "capacity": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "load": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "protocolVersion": { + "type": "string", + "maxLength": 128 + }, + "clockUnixMillis": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "checkInVersion": { + "const": 1 + }, + "state": { + "$ref": "#/$defs/hostState" + }, + "appliedStateRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "gameOutcomes": { + "enum": [ + "available", + "unavailable" + ] + }, + "build": { + "type": "string", + "maxLength": 128 + }, + "region": { + "type": "string", + "maxLength": 32 + }, + "serverStatus": { + "type": "object" + }, + "hostProfile": { + "$ref": "#/$defs/hostProfile" + }, + "hostProfileRevision": { + "type": "string", + "maxLength": 184 + }, + "installedKeyIds": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + } + }, + "keyRequestId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{16,128}$" + }, + "extensions": { + "$ref": "#/$defs/extensions" + } + } + }, + "checkIn": { + "type": "object", + "required": [ + "version", + "afterMillis", + "nextCheckInAt", + "leaseExpiresAt", + "minUpdateIntervalMillis" + ], + "properties": { + "version": { + "const": 1 + }, + "afterMillis": { + "type": "integer", + "minimum": 1000, + "maximum": 86400000 + }, + "nextCheckInAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "leaseExpiresAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "minUpdateIntervalMillis": { + "type": "integer", + "minimum": 1000, + "maximum": 86400000 + } + } + }, + "heartbeatResponse": { + "type": "object", + "required": [ + "accepted", + "receivedAt", + "staleAfter", + "hostProfileRevision", + "activeKeyId", + "leaseGeneration", + "readiness", + "desiredState", + "retirements" + ], + "properties": { + "accepted": { + "const": true + }, + "receivedAt": { + "type": "string", + "format": "date-time" + }, + "staleAfter": { + "type": "string", + "format": "date-time" + }, + "hostProfileRevision": { + "type": [ + "string", + "null" + ] + }, + "activeKeyId": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + }, + "leaseGeneration": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "readiness": { + "$ref": "#/$defs/readiness" + }, + "desiredState": { + "type": "object", + "required": [ + "revision", + "state" + ], + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "state": { + "$ref": "#/$defs/hostState" + } + } + }, + "checkIn": { + "$ref": "#/$defs/checkIn" + }, + "keyRequest": { + "type": "object", + "required": [ + "id", + "keyId" + ], + "properties": { + "id": { + "type": "string", + "maxLength": 128 + }, + "keyId": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + } + } + }, + "ticketKey": { + "$ref": "#/$defs/ticketKey" + }, + "retirements": { + "type": "array", + "items": { + "type": "object", + "required": [ + "keyId", + "retireAfter" + ], + "properties": { + "keyId": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + }, + "retireAfter": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + } + } + } + }, + "outcomes": { + "type": "object", + "required": [ + "events" + ], + "properties": { + "events": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "required": [ + "ticketId", + "stage", + "occurredAt" + ], + "properties": { + "ticketId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "stage": { + "enum": [ + "ticket.ice_seen", + "ticket.ice_connected", + "ticket.dtls_connected", + "ticket.sctp_connected", + "ticket.data_channels_open", + "ticket.game_joined", + "ticket.game_rejected", + "ticket.failed" + ] + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string", + "maxLength": 128 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false } }, "x-context-order": [ @@ -390,7 +804,7 @@ ], "x-signature-format": "ES384 P1363 r||s 96 bytes unpadded base64url", "x-canonicalization": "UTF-8 JSON arrays, no whitespace, no Unicode normalization, epoch milliseconds as integers, absent context strings are empty", - "description": "Canonical registration and lifecycle metadata for the nxs-admission-v1 profile.", + "description": "Registration, heartbeat and stateless join feedback for the experimental NXS contract.", "x-profile": "nxs-admission-v1", "x-discovery-path": "/.well-known/nethernet-external-signalling", "x-headers": [ @@ -404,22 +818,12 @@ "idempotency-key" ], "x-operations": [ - "challenges", + "register", "complete", - "recover", - "activate", - "drain", - "deregister", + "heartbeat", + "outcomes", "rotate", "retire", - "ticket-keys", - "ticket-keys/ack", - "readiness", - "heartbeat", - "host-profile", - "control", - "control/ack", - "ticket-events", - "events" + "deregister" ] } diff --git a/docs/external-signalling/wire-reference.md b/docs/external-signalling/wire-reference.md new file mode 100644 index 00000000..06811c71 --- /dev/null +++ b/docs/external-signalling/wire-reference.md @@ -0,0 +1,419 @@ +# NXS wire reference + +This is the normative format reference for the [three integration flows](README.md). +Protocol identifiers describe the current experimental format; this revision +replaces its earlier operation surface in place. + +## Version and discovery + +| Field | v1 value | +| --- | --- | +| Registration/request protocol | `nethernet-external-signalling-v1` | +| Machine request signature | `nxs-es384-v1` | +| Operational profile | `nxs-admission-v1` | +| Discovery path | `/.well-known/nethernet-external-signalling` | +| Stateless capability | `nethernet.stateless-admission.v1` | +| Stateless carrier prefix | `NXS1` | + +### Provider origin and operation URLs + +The configured origin MUST use HTTPS. HTTP is permitted only for loopback +development. Normalize the origin by lowercasing its scheme and host and omitting +default ports. It cannot contain credentials, a path, a query, or a fragment. + +Fetch discovery with an unauthenticated `GET`. Its `provider` and `controlOrigin` +MUST equal the configured origin. Each operation URL MUST have that same origin +and contain no userinfo or fragment. Clients MUST disable redirects for discovery +and for calls that carry credentials. Sign encoded paths and query strings +exactly as transmitted. + +Discovery contains `provider`, `controlOrigin`, the arrays `protocols`, +`signatures`, `profiles`, and `modes`, an `operations` map, `authorization`, +`limits`, and optional `extensions`. Before sending credentials, clients reject +an unsupported protocol, profile, signature, mode, or required extension. + +The [operation table](README.md#operation-reference) defines the operation names. Clients get their +URLs from discovery. `/v1/nxs/` is a recommended path, but providers +can use other paths. + +### Authorization and limits + +`authorization` contains `header: "Authorization"` and a `schemes` array. Each +entry has a `scheme` and its supported `modes`: + +| Scheme | Allowed modes | +| --- | --- | +| `anonymous-proof-of-work` | `new-service` | +| `bearer-token` | `new-service`, `attach-instance`, or both | + +A provider need only advertise the schemes it accepts. It decides how tokens +are issued, what they authorize, and whether they can be reused. Every flow also +requires proof that the instance owns its signing key. + +| Limit | v1 constraint | +| --- | --- | +| `maxBodyBytes` | At most 65536 | +| `clockSkewMs` | At most 60000 | +| `heartbeatIntervalMs` | 1000–30000 | +| `leaseMs` | Advertised lease duration | + +`checkInVersion: 1` enables the provider to set the next check-in time in its +response. A provider MUST advertise every limit it enforces, reject oversized +bodies, and return errors as `{"code":"lowercase_machine_code"}` with an +appropriate HTTP failure status. Clients limit response size before parsing. + +On a transient transport failure or HTTP 429, 502, 503, or 504, the supplied +client makes at most three attempts in total. Retries use exponential delays +with jitter and an upper bound. A `Retry-After` value over ten seconds returns +a retry-later result. Outcome uploads use one attempt with a three-second timeout +and a ten-second failure backoff so they cannot starve heartbeat renewal. +Retrying never extends a lease or challenge expiry. + +## Registration and persistent identity + +### Save the instance key + +Generate a fresh P-384 machine signing key for each logical instance. Save it +before requesting a challenge. A restart reuses that instance's saved state; +live replicas cannot share a key or state directory. Images and templates MUST +contain neither machine identity nor DTLS private keys. + +Clients lock their state directory and write private state atomically with +owner-only permissions. Sync both files and directories to durable storage. +If saving state fails, stop advertising healthy readiness. + +### `register` request + +The request contains `protocol`, `mode`, `profile`, `publicKeyJwk`, explicit +`authorization: {scheme}`, and optional `label` and `placement`. + +Send a bearer credential only to the enrollment `register` operation, in +`Authorization: Bearer `. It MUST NOT appear in JSON, proofs, saved state, +or logs. `attach-instance` requires both bearer authorization and placement. +The token authorizes access to the service; a client-provided label grants no +permission. + +Placement is `{region,pool,tags?}`: + +| Field | Constraint | +| --- | --- | +| `region` | Immutable routing label matching `[A-Za-z0-9_-]{1,32}` | +| `pool` | Immutable routing label matching `[A-Za-z0-9_-]{1,64}` | +| `tags` | At most 16 keys matching `[A-Za-z0-9_.-]{1,32}`; values are trimmed strings of 1–64 characters with no control characters | + +The challenge binds the exact placement. At completion, the provider rechecks +that the token authorizes it as part of the same atomic operation that creates +the registration. These fields do not prescribe how a provider selects a host. + +The public JWK is EC/P-384. Its `x` and `y` values use canonical, unpadded +base64url and each encode exactly 48 bytes. It MUST NOT contain `d`. The RFC 7638 +thumbprint is SHA-256 of UTF-8 JSON with members in this exact order: +`crv,kty,x,y`. ES384 signatures use the 96-byte IEEE-P1363 form `r || s`, encoded +as unpadded base64url. Reject DER signatures and noncanonical base64url. + +The challenge response contains `protocol`, `signature`, `challengeId`, `nonce`, +`audience`, `thumbprint`, `context`, `contextDigest`, `expiresAt`, `serverTime`, +and `pow: {algorithm:"sha256-leading-zero-bits-v0",difficulty}`. + +Proof-of-work difficulty is 0–24. Bearer-authorized and recovery flows use zero. +An authorization reference is an opaque identifier, never the credential itself. +Expiry and server times are integer epoch milliseconds. + +### `complete` request + +Canonical arrays use UTF-8 JSON with no whitespace or Unicode normalization. +Use an empty string for a missing context string. `contextDigest` is the +unpadded base64url SHA-256 digest of: + +```text +[mode,profile,label,authorizationId,serviceId,region,pool,registrationId] +``` + +When tags are nonempty, append `tagsDigest` to that array. Compute `tagsDigest` +in the same way from sorted `[key,value]` pairs. The completion proof is: + +```text +[protocol,"complete",audience,challengeId,nonce,thumbprint,contextDigest, + expiresAt,proofNonce,idempotencyKey] +``` + +Proof of work counts the leading zero bits in SHA-256 of those bytes. Send +`protocol,challengeId,proofNonce,idempotencyKey,signature` to complete registration. +The provider MUST check expiry, binding, signature, difficulty, current authority, +and single-use completion atomically with resource creation. + +Retrying completion MUST NOT return one-time key secrets again. If completion +was interrupted, recover the registration by proving ownership of the same key. + +Completion returns `protocol,provider,registrationId,serviceId,instanceId,keyId, +profile,publicAddress,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, +readiness`, plus optional one-time `ticketKey` and `extensions`. Completion atomically starts a new generation, clears previous readiness and +resets the operational sequence to zero. Save the IDs and key material before +heartbeat. Recovery uses `register {registrationId,protocol,profile}` and the +same completion proof. A deregistered instance cannot recover. Remove secrets from registration results exposed +to applications and from diagnostic output. + +## Signed requests and machine-key maintenance + +### Sign operational requests + +Use the registered machine key for every operational request. The enrollment +bearer token is used only for the challenge request. + +Required headers are `nxs-instance-id`, `nxs-key-id`, `nxs-timestamp`, +`nxs-signature-version`, `nxs-generation`, `nxs-sequence`, `nxs-signature`, and +`idempotency-key`. The timestamp is epoch milliseconds. Generation and sequence +are nonnegative integers. Save each reserved sequence number before sending its +request. The signature covers this array: + +```text +[protocol,signatureVersion,audience,method,encodedPathAndQuery,timestamp, + instanceId,keyId,idempotencyKey,generation,sequence,base64url(sha256(bodyBytes))] +``` + +For an empty body, hash a zero-length byte sequence. Providers reject stale +generations, reused sequence numbers, invalid timestamps, and invalid signatures. +An idempotent retry can return the recorded result, with secrets removed, if its +intent and semantic request are unchanged. It cannot apply the operation again. + +Each completed registration/recovery starts a generation exactly once. The +provider rejects writes from previous generations. A replay cannot advance the +generation or extend the original lease. Replaying a consumed completion returns +a recovery-required error; recover with a fresh challenge. + +### Rotate a machine key + +The rotation proof bytes are `[protocol,"rotate",audience,instanceId,oldKeyId, +newThumbprint,generation,idempotencyKey]`. Save the replacement private key before +requesting rotation. Save the result before retiring the old key. After an +interrupted rotation, recovery can use the provider's returned key thumbprint +to identify which key is current. + +`retire` carries `{keyId}` and must be signed by a different, current machine +key. `deregister` carries `{}` and permanently ends registration. Neither is an +admission-key rotation or an ordinary graceful drain. + +## `heartbeat` + +Required fields: `healthy,capacity,load,protocolVersion,clockUnixMillis, +checkInVersion,state,appliedStateRevision,gameOutcomes`. +Optional fields: `build,region,serverStatus,hostProfile,hostProfileRevision, +installedKeyIds,keyRequestId,extensions`. + +- `capacity` is an integer from 0 to 1000000; `load` is a finite number from 0 to 1. +- `state` is `serving`, `draining` or `closed`. A draining endpoint cannot resume + serving in the same generation; a fresh endpoint requires recovery/completion. +- `gameOutcomes` is `available` when the integration observes game acceptance and + rejection, otherwise `unavailable`. +- `appliedStateRevision` is a nonnegative integer. A response carries + `desiredState: {revision,state}`. Reject unknown states or regressing revisions; + acknowledge only state that finished applying. Pending application triggers + a bounded earlier heartbeat. Receipt alone is not acknowledgement. +- `clockUnixMillis` is an increasing snapshot clock within the generation and + must be within 30000 milliseconds of provider time. +- `region` cannot change authorized placement. `serverStatus` contains + `name,protocol,version,level,players,maxPlayers,gameType`; it is independent of + routing capacity/load. Omitted or failed status publication does not refresh + a previous status snapshot. + +### Publish the host profile + +`heartbeat.hostProfile` contains `candidates`, `dtlsFingerprint`, `credentialKeyId`, +`sctpPort`, `maxMessageSize`, and `statelessAdmission: {capability,incarnation}`. +Generate a fresh random 16-byte `incarnation`, encoded as lowercase hex, for +each bound native endpoint. The fingerprint is `sha-256 ` followed by the +certificate's digest bytes in colon-separated uppercase hex. + +Each candidate contains `foundation,component,protocol,priority,address,port,type`. +Publish 1–32 candidates. Foundations match `[A-Za-z0-9._:-]{1,32}`; component is +1, protocol is `udp`, priority is 1–2147483647, port is 1–65535, and type is +`host`, `srflx` or `relay`. Addresses are IP literals. +Publish only reachable UDP candidates that are explicitly chosen for advertisement. +The bind address and the advertised address serve different purposes. A host can +bind to all interfaces, but it cannot advertise wildcard `0.0.0.0` or `::`. +The deployment or provider must establish reachability through NAT or a relay; +a passing registration test does not prove that clients can reach the address. + +Prepare the host's DTLS certificate and key before publishing its profile. Keep +the private key local. All peers using that profile use that certificate, so +clients see the fingerprint the provider advertised. The host may use a new +certificate for a later endpoint incarnation after publishing its new fingerprint. +A permanent certificate shared across a fleet is neither required nor advised. + +Three types of key have separate jobs: + +| Key | Purpose | +| --- | --- | +| Machine signing key | Authenticate the host's requests to the provider | +| DTLS certificate and private key | Authenticate the host during the client connection | +| Admission key | Protect and validate the client's admission token | + +The provider assigns `hostProfileRevision` in its reply. Send that revision +on later heartbeats until the profile changes. A request retry returns the same +revision. Profile publication, acknowledgement of its installed key, and the +lease update must commit consistently. A profile using an uninstalled epoch +cannot become routable. + +### Admission-key exchange + +`installedKeyIds` contains at most eight distinct four-character uppercase +alphanumeric IDs, ordered with the active epoch last. Save and install every +listed key before sending it. The last ID must be the provider's current or +pending epoch. Publish a matching profile when changing the active epoch. + +To provision a replacement, include a random `keyRequestId` of 16–128 URL-safe +characters, saved before sending. The response includes +`keyRequest: {id,keyId}` and, on first delivery only, `ticketKey: {keyId,secret}`. +A key secret has 32–256 UTF-8 characters; optional `notBefore` and `retireAfter` +are epoch milliseconds. An idempotent retry cannot mint another key or return +the secret again. If its delivery was lost, use a fresh request ID to provision +a replacement. A provider may retire an unacknowledged, superseded pending key. + +Install the key atomically, then immediately publish its profile and acknowledge +it in heartbeat. Replies include `retirements: [{keyId,retireAfter}]` for older +reported epochs. Repeated replies preserve the original deadlines; they cannot +extend key life. Providers must allow outstanding tokens their defined overlap +window. Reject tokens before activation or after retirement and erase retired +material. Key rotation never extends token expiry. + +### Readiness, lease and schedule + +The reply includes `receivedAt` (ISO8601), `hostProfileRevision`, `activeKeyId`, +`leaseGeneration`, `readiness: {routable,reasons}`, and: + +```text +checkIn: {version:1,afterMillis,nextCheckInAt,leaseExpiresAt,minUpdateIntervalMillis} +``` + +Schedule timestamps are epoch milliseconds. `nextCheckInAt` precedes lease +expiry. `checkInVersion: 1` requests scheduling; while an initial usable profile +is unavailable the provider can omit `checkIn` and use its discovery cadence +and `staleAfter` ISO8601 deadline. A draining/closed host is never routable. + +Readiness is a current provider observation; only the recorded `checkIn` or +`staleAfter` grants a lease. Request replay returns that original grant. Hosts +use monotonic timers, count network time against the interval, and publish +changed activity/status earlier subject to the returned rate limit. Restart +immediately publishes fresh state and discards the prior schedule. Existing +sessions survive a control-plane outage. + +## `outcomes` + +Request: `{events:[{ticketId,stage,occurredAt,reason?}]}` with at most 100 events. +`occurredAt` is ISO8601; `reason` is a bounded code of at most 128 characters. +The ticket ID derives from the authenticated admission carrier, never from an +unauthenticated packet. The provider scopes correlation to the signed instance. +No provider-specific routing decision ID is required. + +Required stages are `ticket.data_channels_open` and `ticket.failed` for observed +transport attempts, plus `ticket.game_joined`/`ticket.game_rejected` when +`gameOutcomes` is `available`. Optional diagnostic stages are `ticket.ice_seen`, +`ticket.ice_connected`, `ticket.dtls_connected` and `ticket.sctp_connected`. +Success and failure describe the observed boundary, not an inferred later stage. + +A successful response acknowledges the whole batch. Repeated observations must +be deduplicated by instance, ticket, stage, occurrence time and reason, including +when a retry uses a new request ID. Queue and persist redacted reports with a +finite bound; the reference client retains at most 1000 pending entries and +flushes at most 100 per tick independently of idle heartbeat timing. Backpressure +must not block native admission or lease renewal. Neither absent reports nor an +unreachable host proves a particular client's outcome. Never send SDP, private +keys, player identity or game payloads. + +## Stateless admission carrier + +### Carry the token in the ICE username + +The client's first STUN USERNAME is `:`, where: + +```text +answerUfrag = "NXS1" + keyId + unpaddedBase64(nonce || ciphertext || tag) +``` + +Use the standard base64 alphabet, including `+` and `/`, which ICE permits. +Do not use base64url. The total ufrag length is at most 256 characters. Before +allocating peer state, reject noncanonical encoding, trailing padding, a wrong +prefix, unknown key epochs, and oversized input. + +AES-256-GCM uses a random 12-byte nonce and a 16-byte tag. Its key is +`HMAC-SHA256(secret, "nxs-stateless-aead-v1" || NUL || audience)`. +The audience is `nxs-stateless-host-v1/`. The additional authenticated +data (AAD) is +`"nxs-stateless-admission-v1" || NUL || ("NXS1"+keyId) || NUL || audience || NUL || clientUfrag`. + +| Plaintext offset | Size | Meaning, unsigned big-endian where numeric | +| --- | --- | --- | +| 0 | 4 | Expiry in epoch seconds, exactly representable in milliseconds | +| 4 | 32 | SHA-256 client certificate fingerprint | +| 36 | 2 | Client SCTP port, 1–65535 | +| 38 | 4 | Client maximum message size, 1–262144 | +| 42 | 16 | Opaque caller-context hash, no account-specific interpretation | +| 58 | 8 | NetherNet network ID, unsigned 64-bit | +| 66 | 1 | Client ICE password length, 22–91 | +| 67 | N | Client ICE password in ICE base64 alphabet | + +The host's local ICE password is the unpadded standard base64 encoding of the +first 24 bytes of +`HMAC-SHA256(secret, "nxs-stateless-ice-v1" || NUL || audience || NUL || answerUfrag)`. +The ticket correlation ID is the first 16 bytes of SHA-256 of the ASCII answer +ufrag, encoded as lowercase hex. + +### Validate the first packet + +A token can be valid for at most 120 seconds. Before assigning the UDP tuple to a +peer or allocating a peer connection, the host checks expiry, field bounds, GCM +authentication, client binding, and STUN MESSAGE-INTEGRITY. The DTLS handshake +MUST then verify the client fingerprint from the token. + +Only a retransmission of the identical token from the same UDP tuple can reuse +a reservation. Reject the same token from another tuple. Also reject a conflicting +admission on an occupied tuple. + +Limit the number of sessions, pending handshakes, used-token records, queued +validation tasks, and retained requests. Duplicate requests for the same pending +attempt share one decision. Preserve enough of the first request to respond after +acceptance: completing admission MUST NOT depend on the client retransmitting. +Release admission capacity only after the connection's resources have been +released. A failed integrity check MUST NOT consume the token, since a copied +token alone does not prove that the sender has its ICE password. + +#### Reference implementation + +The supplied Network implementation uses a 60-second token limit. libjuice +retains the first STUN packet and sends parsed metadata to Java for asynchronous +token validation. libdatachannel verifies STUN integrity before creating the +peer, outside the UDP receive lock. It then processes the retained request after +the application has installed its callbacks. Established transport packets stay +native, and capacity remains reserved until native teardown finishes. + +Other implementations may meet the requirements above using different languages, +threading models, and transport libraries. + +## Optional extensions + +Providers can add optional application metadata without making it part of NXS. +For example, a product could supply an account-claim link. NXS does not define +what claiming an account means or require other providers to implement it. + +`extensions` is an object with at most 16 reverse-DNS namespace keys, such as +`com.example.feature`, and at most 16384 bytes of encoded UTF-8 JSON. Keys use +lowercase domain-style labels and have at most 128 characters. Each value is +`{version:positiveInteger,critical:boolean,data:object}`. + +Pass through or ignore unknown optional extensions; never execute them +automatically. Reject unsupported critical extensions before sending credentials +or publishing readiness. An optional extension cannot change the core protocol rules. +TLS and request signatures still authenticate bodies and operation paths. + +An extension can advertise URLs in `data.operations`. An application can request +one of these operations only after validating the namespace, version, and meaning. +The generic transport still requires the same provider origin and signs the +exact path. + +## Conformance + +Run `node docs/external-signalling/fixtures.mjs` for independent signature and +admission fixtures. The Java tests consume the same schema/fixtures and exercise +an independent provider with no product accounts. Native tests separately cover +local admission and real UDP/ICE/DTLS/SCTP. Report stock-client gameplay separately +from these checks. diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java index 560f2b56..4fbbff4b 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java @@ -4,17 +4,17 @@ import java.io.IOException; /** Validates the scheduling contract; policy and idle thresholds belong to the provider. */ -record CheckInSchedule(long afterMillis, long controlPollAfterMillis, long minUpdateIntervalMillis) { +record CheckInSchedule(long afterMillis, long minUpdateIntervalMillis) { static CheckInSchedule parse(JsonObject response) throws IOException { try { JsonObject s = response.getAsJsonObject("checkIn"); if (number(s, "version") != 1) throw new IllegalArgumentException(); - long after = number(s, "afterMillis"), control = number(s, "controlPollAfterMillis"), minimum = number(s, "minUpdateIntervalMillis"); + long after = number(s, "afterMillis"), minimum = number(s, "minUpdateIntervalMillis"); long next = number(s, "nextCheckInAt"), expires = number(s, "leaseExpiresAt"); long received = java.time.Instant.parse(response.get("receivedAt").getAsString()).toEpochMilli(); - if (after < 1000 || after > 86400000 || control < 1000 || control > after || minimum < 1000 || minimum > after + if (after < 1000 || after > 86400000 || minimum < 1000 || minimum > after || next - received != after || expires <= next || expires - next > 300000) throw new IllegalArgumentException(); - return new CheckInSchedule(after, control, minimum); + return new CheckInSchedule(after, minimum); } catch (RuntimeException invalid) { throw new IOException("Invalid provider check-in schedule", invalid); } } private static long number(JsonObject object, String field) { diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java index 48c27dae..56764e8f 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java @@ -63,7 +63,9 @@ public static final class ProviderException extends IOException { private JsonObject lastProfile; private long intervalMs = 10000, nextHeartbeat, snapshotClock; private boolean started, closed, scheduledCheckIns; - private long nextControl, nextStatusUpdate, controlIntervalMs = 1000, minUpdateIntervalMs = 1000; + private long nextOutcomes, nextStatusUpdate, minUpdateIntervalMs = 1000, appliedStateRevision; + private String hostState = "serving", installedKeyId; + private JsonObject lastHeartbeat = new JsonObject(); private ServerStatus lastReportedStatus; private Health lastReportedHealth; private final AtomicBoolean closing = new AtomicBoolean(); @@ -86,13 +88,10 @@ public CompletableFuture start() { return submit(() -> { else recoverExisting(); JsonObject registration = state.getAsJsonObject("registration"); if (!registration.get("provider").getAsString().equals(origin)) throw new IOException("Registration audience changed"); - JsonObject activationRequest = new JsonObject(); activationRequest.addProperty("profile", config.profile()); - JsonObject activation = signed("activate", "POST", activationRequest); state.addProperty("protocol", ProviderCrypto.PROTOCOL); state.addProperty("profile", config.profile()); - state.addProperty("generation", activation.get("leaseGeneration").getAsLong()); state.addProperty("sequence", 0); state.remove("cursor"); save(); + state.addProperty("generation", registration.get("leaseGeneration").getAsLong()); state.addProperty("sequence", 0); + state.remove("cursor"); state.remove("pendingAdmissions"); save(); installKeys(); - // Volatile admissions from an earlier profile cannot be restored by a stateless endpoint. - state.remove("pendingAdmissions"); save(); started = true; heartbeat(); timer = executor.scheduleWithFixedDelay(() -> { if (closed) return; @@ -103,11 +102,10 @@ public CompletableFuture start() { return submit(() -> { nextStatusUpdate = nextHeartbeat; diagnostics.accept("provider_status_unavailable: " + safeFailure(e)); } - if (System.nanoTime() >= nextControl) { - nextControl = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(controlIntervalMs); - try { control(); } catch (Exception e) { diagnostics.accept("provider_control_unavailable: " + safeFailure(e)); } + if (System.nanoTime() >= nextOutcomes) { + try { flushEvents(); } + catch (Exception e) { nextOutcomes = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); diagnostics.accept("provider_events_unavailable: " + safeFailure(e)); } } - try { flushEvents(); } catch (Exception e) { diagnostics.accept("provider_events_unavailable: " + safeFailure(e)); } }, 1000, 1000, TimeUnit.MILLISECONDS); return redactedRegistration(); }); } @@ -130,7 +128,6 @@ private void validateDiscovery() throws IOException { if (intervalMs < 1000 || intervalMs > 30000) throw new IOException("Unsupported heartbeat interval"); JsonObject limits = discovery.getAsJsonObject("limits"); if (limits.get("maxBodyBytes").getAsLong() < 1 || limits.get("maxBodyBytes").getAsLong() > 65536 - || limits.get("maxControlPage").getAsLong() < 1 || limits.get("maxControlPage").getAsLong() > 100 || limits.get("clockSkewMs").getAsLong() < 0 || limits.get("clockSkewMs").getAsLong() > 60000) throw new IOException("Unsupported provider limits"); } @@ -141,7 +138,7 @@ private JsonObject recoveryRequest(String registrationId) { } private void recoverExisting() throws Exception { String registrationId = registration("registrationId"); - completeRecovery(unsigned("recover", recoveryRequest(registrationId)), registrationId); + completeRecovery(unsigned("register", recoveryRequest(registrationId)), registrationId); } private void completeRecovery(JsonObject challenge, String registrationId) throws Exception { ProviderContract.require("challenge", challenge); @@ -167,10 +164,10 @@ private void completeRecovery(JsonObject challenge, String registrationId) throw if (!state.getAsJsonObject("registration").get(field).equals(recovered.get(field))) throw new IOException("Recovered instance identity changed"); registrationExtensions = ProtocolExtensions.copy(recovered); recovered.remove("extensions"); recovered.remove("ticketKey"); state.add("registration", recovered); state.addProperty("generation", recovered.get("leaseGeneration").getAsLong()); - if (!state.has("sequence")) state.addProperty("sequence", 0); + state.addProperty("sequence", 0); if (!state.has("ticketKeys")) state.add("ticketKeys", new JsonArray()); state.remove("challenge"); - // Sequence is monotonic within a generation; the previous durable reservation is retained. + // Completion starts a fresh fenced generation; operational sequencing starts at zero. if (pending) { state.add("privateKey", state.remove("pendingPrivateKey")); state.add("publicKeyJwk", state.remove("pendingPublicKeyJwk")); privateKey = key; } save(); } @@ -179,7 +176,7 @@ private void enroll() throws Exception { if (state.has("challenge")) { String registrationId = state.getAsJsonObject("challenge").get("challengeId").getAsString(); JsonObject recoveredChallenge = null; - try { recoveredChallenge = unsigned("recover", recoveryRequest(registrationId)); } + try { recoveredChallenge = unsigned("register", recoveryRequest(registrationId)); } catch (ProviderException e) { if (e.status != 403) throw e; } if (recoveredChallenge != null) { completeRecovery(recoveredChallenge, registrationId); return; } challenge = state.getAsJsonObject("challenge"); @@ -188,7 +185,7 @@ private void enroll() throws Exception { request.addProperty("profile", config.profile()); request.add("publicKeyJwk", state.get("publicKeyJwk")); if (config.label() != null) request.addProperty("label", config.label()); JsonObject authorization = new JsonObject(); authorization.addProperty("scheme", config.authorizationScheme()); request.add("authorization", authorization); if (config.region() != null) { JsonObject p = new JsonObject(); p.addProperty("region", config.region()); p.addProperty("pool", config.pool()); if (!config.tags().isEmpty()) p.add("tags", JSON.toJsonTree(config.tags())); request.add("placement", p); } - challenge = unsigned("challenges", request, config.authorizationToken()); state.add("challenge", challenge); save(); + challenge = unsigned("register", request, config.authorizationToken()); state.add("challenge", challenge); save(); } ProviderContract.require("challenge", challenge); if (!ProviderCrypto.PROTOCOL.equals(challenge.get("protocol").getAsString()) || !ProviderCrypto.SIGNATURE.equals(challenge.get("signature").getAsString()) || !origin.equals(challenge.get("audience").getAsString()) || !ProviderCrypto.thumbprint(state.getAsJsonObject("publicKeyJwk")).equals(challenge.get("thumbprint").getAsString()) || !ProviderCrypto.contextDigest(challenge.getAsJsonObject("context")).equals(challenge.get("contextDigest").getAsString())) throw new IOException("Unbound registration challenge"); @@ -228,27 +225,25 @@ private void validateRegistration(JsonObject registration) throws IOException { if (!expectedTags.equals(actualTags)) throw new IOException("Registration placement tags changed"); } private void installKeys() throws Exception { - if (!state.has("ticketKeys")) state.add("ticketKeys", new JsonArray()); - JsonArray unexpired = new JsonArray(); for (JsonElement e : state.getAsJsonArray("ticketKeys")) if (!e.getAsJsonObject().has("retireAfter") || e.getAsJsonObject().get("retireAfter").getAsLong() > System.currentTimeMillis()) unexpired.add(e); - state.add("ticketKeys", unexpired); save(); - if (state.getAsJsonArray("ticketKeys").isEmpty()) { - JsonObject fresh = signed("ticket-keys", "POST", new JsonObject()); - if (!fresh.has("ticketKey")) throw new IOException("Ticket response was lost; retry fresh provisioning"); - state.getAsJsonArray("ticketKeys").add(fresh.get("ticketKey")); save(); + JsonArray retained = new JsonArray(); + if (state.has("ticketKeys")) for (JsonElement entry : state.getAsJsonArray("ticketKeys")) { + JsonObject key = entry.getAsJsonObject(); + if (!key.has("retireAfter") || key.get("retireAfter").getAsLong() > System.currentTimeMillis()) retained.add(key); + } + if (retained.size() > 8) throw new IOException("Too many admission key epochs"); + state.add("ticketKeys", retained); save(); + if (retained.isEmpty()) { + installedKeyId = null; + if (!state.has("keyRequestId")) { state.addProperty("keyRequestId", UUID.randomUUID().toString()); save(); } + return; } List keys = new ArrayList<>(); - for (JsonElement e : state.getAsJsonArray("ticketKeys")) { JsonObject k = e.getAsJsonObject(); keys.add(new ProviderTransport.TicketKey(k.get("keyId").getAsString(), k.get("secret").getAsString(), k.has("notBefore") ? k.get("notBefore").getAsLong() : 0, k.has("retireAfter") ? k.get("retireAfter").getAsLong() : Long.MAX_VALUE)); } - transport.installTicketKeys(List.copyOf(keys)).toCompletableFuture().get(10, TimeUnit.SECONDS); - JsonObject ack = new JsonObject(); ack.addProperty("keyId", keys.getLast().keyId()); JsonObject acknowledgement = signed("ticket-keys/ack", "POST", ack); - if (acknowledgement.has("retirements")) { - for (JsonElement retired : acknowledgement.getAsJsonArray("retirements")) for (JsonElement stored : state.getAsJsonArray("ticketKeys")) { - JsonObject r = retired.getAsJsonObject(), k = stored.getAsJsonObject(); - if (r.get("keyId").equals(k.get("keyId"))) k.addProperty("retireAfter", Math.min(k.has("retireAfter") ? k.get("retireAfter").getAsLong() : Long.MAX_VALUE, r.get("retireAfter").getAsLong())); - } - save(); List bounded = new ArrayList<>(); - for (JsonElement stored : state.getAsJsonArray("ticketKeys")) { JsonObject k = stored.getAsJsonObject(); long end = k.has("retireAfter") ? k.get("retireAfter").getAsLong() : Long.MAX_VALUE; if (end > System.currentTimeMillis()) bounded.add(new ProviderTransport.TicketKey(k.get("keyId").getAsString(), k.get("secret").getAsString(), k.has("notBefore") ? k.get("notBefore").getAsLong() : 0, end)); } - transport.installTicketKeys(List.copyOf(bounded)).toCompletableFuture().get(10, TimeUnit.SECONDS); + for (JsonElement entry : retained) { JsonObject key = entry.getAsJsonObject(); + keys.add(new ProviderTransport.TicketKey(key.get("keyId").getAsString(), key.get("secret").getAsString(), + key.has("notBefore") ? key.get("notBefore").getAsLong() : 0, key.has("retireAfter") ? key.get("retireAfter").getAsLong() : Long.MAX_VALUE)); } + transport.installTicketKeys(List.copyOf(keys)).toCompletableFuture().get(10, TimeUnit.SECONDS); + installedKeyId = keys.getLast().keyId(); } /** A full immutable snapshot. Callers may update every one of the seven fields. */ public void setServerStatus(ServerStatus status) { explicitStatus.set(Objects.requireNonNull(status)); requestStatusRefresh(); } @@ -266,55 +261,92 @@ private boolean statusChanged() { || !Objects.equals(health.protocolVersion(), lastReportedHealth.protocolVersion()) || !Objects.equals(health.build(), lastReportedHealth.build()); } private void heartbeat() throws Exception { - JsonObject profile = transport.hostProfile().toCompletableFuture().get(10, TimeUnit.SECONDS); - if (profile == null) throw new IOException("Transport profile unavailable"); - boolean supportsSchedule = discovery.getAsJsonObject("limits").has("checkInVersion") - && discovery.getAsJsonObject("limits").get("checkInVersion").getAsInt() == 1 && profile.has("statelessAdmission"); - if (!profile.equals(lastProfile) || !state.has("profilePublishedAt") || (!supportsSchedule && System.currentTimeMillis() - state.get("profilePublishedAt").getAsLong() > 300000)) { - JsonObject published = signed("host-profile", "POST", profile); profileRevision = published.get("revision").getAsString(); lastProfile = profile.deepCopy(); state.addProperty("profilePublishedAt", System.currentTimeMillis()); save(); - } - Health h = healthSupplier.get(); JsonObject body = new JsonObject(); body.addProperty("healthy", h.healthy()); body.addProperty("capacity", h.capacity()); body.addProperty("load", h.load()); body.addProperty("protocolVersion", h.protocolVersion()); body.addProperty("build", h.build()); body.addProperty("hostProfileRevision", profileRevision); - if (config.region() != null) body.addProperty("region", config.region()); - snapshotClock = Math.max(System.currentTimeMillis(), snapshotClock + 1); body.addProperty("clockUnixMillis", snapshotClock); - ServerStatus status = null; - try { status = currentStatus(); if (status != null) body.add("serverStatus", JSON.toJsonTree(status)); } - catch (RuntimeException e) { diagnostics.accept("status_refresh_failed"); /* Omit snapshot; old report timestamp must expire. */ } - if (supportsSchedule) body.addProperty("checkInVersion", 1); - long requestStarted = System.nanoTime(); - JsonObject response = signed("heartbeat", "POST", body); - if (supportsSchedule && response.has("checkIn")) { - CheckInSchedule schedule = CheckInSchedule.parse(response); - scheduledCheckIns = true; controlIntervalMs = schedule.controlPollAfterMillis(); minUpdateIntervalMs = schedule.minUpdateIntervalMillis(); - // Count network time against the granted interval; retries cannot postpone an absolute lease. - long received = java.time.Instant.parse(response.get("receivedAt").getAsString()).toEpochMilli(); - long remaining = Math.min(schedule.afterMillis(), Math.max(0, response.getAsJsonObject("checkIn").get("nextCheckInAt").getAsLong() - Math.max(received, System.currentTimeMillis()))); - nextHeartbeat = Math.min(requestStarted + TimeUnit.MILLISECONDS.toNanos(schedule.afterMillis()), System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(remaining)); - nextControl = Math.min(nextControl, requestStarted + TimeUnit.MILLISECONDS.toNanos(controlIntervalMs)); - } else { - scheduledCheckIns = false; controlIntervalMs = 1000; - nextHeartbeat = requestStarted + TimeUnit.MILLISECONDS.toNanos(intervalMs + ThreadLocalRandom.current().nextLong(Math.max(1, intervalMs / 10))); - nextControl = Math.min(nextControl, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); - } - nextStatusUpdate = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(scheduledCheckIns ? minUpdateIntervalMs : intervalMs); - lastReportedStatus = status; lastReportedHealth = h; - } - private void control() throws Exception { - JsonObject page = signed("control", "GET", null); - JsonArray commands = page.has("commands") ? page.getAsJsonArray("commands") : new JsonArray(); - if (commands.size() > discovery.getAsJsonObject("limits").get("maxControlPage").getAsInt()) throw new IOException("Control page exceeds limit"); - boolean terminal = true; - for (JsonElement item : commands) { - JsonObject command = item.getAsJsonObject(); String kind = command.has("kind") ? command.get("kind").getAsString() : ""; - if (!Set.of("noop", "drain", "suspend", "revoke").contains(kind)) { - terminal = false; diagnostics.accept("unsupported_control_command"); continue; + // Key delivery and application acknowledgements can need an immediate second exchange. + for (int exchange = 0; exchange < 3; exchange++) { + JsonObject body = new JsonObject(), profile = null; + if (installedKeyId != null && hostState.equals("serving")) { + profile = transport.hostProfile().toCompletableFuture().get(10, TimeUnit.SECONDS); + if (profile == null) throw new IOException("Transport profile unavailable"); + if (!profile.equals(lastProfile)) body.add("hostProfile", profile); + else if (profileRevision != null) body.addProperty("hostProfileRevision", profileRevision); + } else if (profileRevision != null) body.addProperty("hostProfileRevision", profileRevision); + if (installedKeyId != null) { + JsonArray installed = new JsonArray(); + for (JsonElement key : state.getAsJsonArray("ticketKeys")) installed.add(key.getAsJsonObject().get("keyId")); + body.add("installedKeyIds", installed); } - ProviderTransport.ApplyResult result = transport.applyControl(command.deepCopy()).toCompletableFuture().get(10, TimeUnit.SECONDS); - if (result == ProviderTransport.ApplyResult.PENDING) terminal = false; - } - if (terminal && !commands.isEmpty() && page.has("cursor")) { - // Native terminal results are replay-safe; merely staged volatile admissions never reach here. - String cursor = page.get("cursor").getAsString(); JsonObject ack = new JsonObject(); ack.addProperty("cursor", cursor); signed("control/ack", "POST", ack); state.addProperty("cursor", cursor); save(); + if (state.has("keyRequestId")) body.add("keyRequestId", state.get("keyRequestId")); + Health health = healthSupplier.get(); + body.addProperty("healthy", health.healthy() && installedKeyId != null && hostState.equals("serving")); + body.addProperty("capacity", health.capacity()); body.addProperty("load", health.load()); + body.addProperty("protocolVersion", health.protocolVersion()); body.addProperty("build", health.build()); + if (config.region() != null) body.addProperty("region", config.region()); + snapshotClock = Math.max(System.currentTimeMillis(), snapshotClock + 1); + body.addProperty("clockUnixMillis", snapshotClock); body.addProperty("checkInVersion", 1); + body.addProperty("state", hostState); body.addProperty("appliedStateRevision", appliedStateRevision); + body.addProperty("gameOutcomes", transport.supportsGameOutcomes() ? "available" : "unavailable"); + ServerStatus status = null; + try { status = currentStatus(); if (status != null) body.add("serverStatus", JSON.toJsonTree(status)); } + catch (RuntimeException failure) { diagnostics.accept("status_refresh_failed"); } + long requestStarted = System.nanoTime(); + JsonObject response = signed("heartbeat", "POST", body); + ProtocolExtensions.validate(response); + if (body.has("hostProfile")) { + if (!response.has("hostProfileRevision") || response.get("hostProfileRevision").isJsonNull()) throw new IOException("Profile acknowledgement missing"); + profileRevision = response.get("hostProfileRevision").getAsString(); lastProfile = profile.deepCopy(); + state.addProperty("profilePublishedAt", System.currentTimeMillis()); save(); + } + boolean again = false; + if (response.has("ticketKey")) { + JsonObject key = response.remove("ticketKey").getAsJsonObject(); + if (!state.has("keyRequestId") || !response.has("keyRequest") || + !state.get("keyRequestId").equals(response.getAsJsonObject("keyRequest").get("id")) || + !key.get("keyId").equals(response.getAsJsonObject("keyRequest").get("keyId"))) throw new IOException("Unbound admission key response"); + state.getAsJsonArray("ticketKeys").add(key); state.remove("keyRequestId"); save(); + installKeys(); lastProfile = null; again = true; + } else if (state.has("keyRequestId") && response.has("keyRequest") && + state.get("keyRequestId").equals(response.getAsJsonObject("keyRequest").get("id"))) { + // The provider confirms delivery but the one-time response was lost. + state.addProperty("keyRequestId", UUID.randomUUID().toString()); save(); again = true; + } + if (response.has("retirements") && !response.getAsJsonArray("retirements").isEmpty()) { + for (JsonElement retirement : response.getAsJsonArray("retirements")) for (JsonElement stored : state.getAsJsonArray("ticketKeys")) { + JsonObject retired = retirement.getAsJsonObject(), key = stored.getAsJsonObject(); + if (retired.get("keyId").equals(key.get("keyId"))) key.addProperty("retireAfter", Math.min( + key.has("retireAfter") ? key.get("retireAfter").getAsLong() : Long.MAX_VALUE, retired.get("retireAfter").getAsLong())); + } + save(); installKeys(); + } + if (response.has("checkIn")) { + CheckInSchedule schedule = CheckInSchedule.parse(response); + scheduledCheckIns = true; minUpdateIntervalMs = schedule.minUpdateIntervalMillis(); + long received = java.time.Instant.parse(response.get("receivedAt").getAsString()).toEpochMilli(); + long remaining = Math.min(schedule.afterMillis(), Math.max(0, response.getAsJsonObject("checkIn").get("nextCheckInAt").getAsLong() - Math.max(received, System.currentTimeMillis()))); + nextHeartbeat = Math.min(requestStarted + TimeUnit.MILLISECONDS.toNanos(schedule.afterMillis()), System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(remaining)); + } else { + scheduledCheckIns = false; + nextHeartbeat = requestStarted + TimeUnit.MILLISECONDS.toNanos(intervalMs); + } + nextStatusUpdate = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(scheduledCheckIns ? minUpdateIntervalMs : intervalMs); + lastReportedStatus = status; lastReportedHealth = health; lastHeartbeat = response.deepCopy(); + JsonObject desired = response.getAsJsonObject("desiredState"); + if (desired == null || !desired.has("revision") || !desired.has("state")) throw new IOException("Provider state missing"); + long revision = desired.getAsJsonPrimitive("revision").getAsBigDecimal().longValueExact(); String target = desired.get("state").getAsString(); + if (revision < appliedStateRevision || !Set.of("serving", "draining", "closed").contains(target)) throw new IOException("Unsupported provider state"); + if (revision > appliedStateRevision) { + ProviderTransport.ApplyResult applied = target.equals("serving") ? ProviderTransport.ApplyResult.APPLIED + : transport.applyState(target).toCompletableFuture().get(10, TimeUnit.SECONDS); + if (applied == ProviderTransport.ApplyResult.APPLIED) { + appliedStateRevision = revision; + if (!target.equals("serving")) { hostState = target; again = true; } + } else { + diagnostics.accept("provider_state_not_applied"); + nextHeartbeat = Math.min(nextHeartbeat, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + } + } + if (!again) return; } + nextHeartbeat = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); } private void flushEvents() throws Exception { if (!state.has("pendingEvents")) state.add("pendingEvents", new JsonArray()); @@ -324,23 +356,19 @@ private void flushEvents() throws Exception { for (JsonObject event : fresh) { // Persist only the existing redacted telemetry fields, never native SDP or secret extensions. JsonObject safe = new JsonObject(); - for (String field : List.of("stage", "type", "ticketId", "decisionId", "occurredAt", "reason")) if (event.has(field)) safe.add(field, event.get(field)); - if ((!safe.has("stage") && !safe.has("type")) || !safe.has("occurredAt")) throw new IOException("Malformed transport event"); + for (String field : List.of("stage", "ticketId", "occurredAt", "reason")) if (event.has(field)) safe.add(field, event.get(field)); + if (!safe.has("stage") || !safe.has("ticketId") || !safe.has("occurredAt")) throw new IOException("Malformed transport event"); pending.add(safe); } if (pending.isEmpty()) return; save(); - for (String operation : List.of("ticket-events", "events")) { - JsonArray batch = new JsonArray(); - for (JsonElement e : pending) if (e.getAsJsonObject().has(operation.equals("events") ? "type" : "stage") && batch.size() < 100) batch.add(e); - if (batch.isEmpty()) continue; - JsonObject body = new JsonObject(); body.add("events", batch); signed(operation, "POST", body); - for (JsonElement sent : batch) pending.remove(sent); save(); - } + JsonArray batch = new JsonArray(); + for (JsonElement event : pending) if (batch.size() < 100) batch.add(event); + JsonObject body = new JsonObject(); body.add("events", batch); signed("outcomes", "POST", body); + for (JsonElement sent : batch) pending.remove(sent); save(); } - public CompletableFuture readiness() { return submit(() -> { - JsonObject response = signed("readiness", "GET", null); ProtocolExtensions.validate(response); return response; - }); } + /** Refresh through the ordinary heartbeat and return its readiness observation. */ + public CompletableFuture readiness() { return submit(() -> { heartbeat(); return lastHeartbeat.deepCopy(); }); } /** Opaque optional extension metadata; the application decides what it means. */ public CompletableFuture extensions() { return submit(() -> registrationExtensions.deepCopy()); } /** Explicit application request to an advertised extension operation, never automatic execution. */ @@ -362,8 +390,9 @@ public CompletableFuture deregister() { return submit(() -> { signed("deregister", "POST", new JsonObject()); transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); started = false; return null; }); } public CompletableFuture rotateTicketKey() { return submit(() -> { - JsonObject result = signed("ticket-keys", "POST", new JsonObject()); if (!result.has("ticketKey")) throw new IOException("Fresh ticket provisioning required"); - state.getAsJsonArray("ticketKeys").add(result.get("ticketKey")); save(); installKeys(); lastProfile = null; heartbeat(); return redactedRegistration(); + installKeys(); + if (state.getAsJsonArray("ticketKeys").size() >= 8) throw new IOException("Wait for retiring admission epochs before rotating again"); + state.addProperty("keyRequestId", UUID.randomUUID().toString()); save(); heartbeat(); return redactedRegistration(); }); } public CompletableFuture rotateMachineKey() { return submit(() -> { KeyPair replacement = ProviderCrypto.generate(); JsonObject jwk = ProviderCrypto.publicJwk(replacement.getPublic()); @@ -374,15 +403,18 @@ public CompletableFuture rotateMachineKey() { return submit(() -> { state.add("privateKey", state.remove("pendingPrivateKey")); state.add("publicKeyJwk", state.remove("pendingPublicKeyJwk")); state.getAsJsonObject("registration").addProperty("keyId", result.get("keyId").getAsString()); save(); privateKey = replacement.getPrivate(); JsonObject retire = new JsonObject(); retire.addProperty("keyId", oldKey); signed("retire", "POST", retire); return result; }); } - public CompletableFuture drain() { return submit(() -> { signed("drain", "POST", new JsonObject()); transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); started = false; return null; }); } + public CompletableFuture drain() { return submit(() -> { drainAndReport(); started = false; return null; }); } + private void drainAndReport() throws Exception { + if (!hostState.equals("closed")) { transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); hostState = "draining"; } + heartbeat(); + } private JsonObject unsigned(String op, JsonObject body) throws Exception { return unsigned(op, body, null); } private JsonObject unsigned(String op, JsonObject body, String bearerToken) throws Exception { return exchange(operation(op), "POST", JSON.toJson(body), false, null, bearerToken); } private JsonObject signed(String op, String method, JsonObject body) throws Exception { return signed(op, method, body, UUID.randomUUID().toString()); } private JsonObject signed(String op, String method, JsonObject body, String intent) throws Exception { long sequence = state.has("sequence") ? state.get("sequence").getAsLong() + 1 : 1; state.addProperty("sequence", sequence); save(); URI uri = operation(op); - if (op.equals("control") && state.has("cursor")) uri = URI.create(uri + "?cursor=" + java.net.URLEncoder.encode(state.get("cursor").getAsString(), java.nio.charset.StandardCharsets.UTF_8)); - return exchange(uri, method, body == null ? null : JSON.toJson(body), true, intent, null); + return exchange(uri, method, body == null ? null : JSON.toJson(body), true, intent, null, op.equals("outcomes") ? 3 : 15, op.equals("outcomes") ? 1 : 3); } private URI operation(String op) throws IOException { if (!discovery.getAsJsonObject("operations").has(op)) throw new IOException("Missing provider operation: " + op); return trusted(URI.create(discovery.getAsJsonObject("operations").get(op).getAsString())); } private URI trusted(URI uri) throws IOException { @@ -390,9 +422,12 @@ private URI trusted(URI uri) throws IOException { if (!ProviderCrypto.origin(authority).equals(origin) || uri.getUserInfo() != null || uri.getFragment() != null) throw new IOException("Untrusted provider operation"); return uri; } private JsonObject exchange(URI uri, String method, String body, boolean signed, String intent, String bearerToken) throws Exception { + return exchange(uri, method, body, signed, intent, bearerToken, 15, 3); + } + private JsonObject exchange(URI uri, String method, String body, boolean signed, String intent, String bearerToken, int timeoutSeconds, int attempts) throws Exception { trusted(uri); String raw = body == null ? "" : body; - for (int attempt = 0; attempt < 3; attempt++) { - HttpRequest.Builder b = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(15)).header("accept", "application/json").method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)); + for (int attempt = 0; attempt < attempts; attempt++) { + HttpRequest.Builder b = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(timeoutSeconds)).header("accept", "application/json").method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)); if (body != null) b.header("content-type", "application/json"); if (bearerToken != null) b.header("authorization", "Bearer " + bearerToken); if (signed) { @@ -402,15 +437,15 @@ private JsonObject exchange(URI uri, String method, String body, boolean signed, } HttpResponse response; var responseFuture = http.sendAsync(b.build(), info -> new LimitedBodySubscriber(65536)); - try { response = responseFuture.get(20, TimeUnit.SECONDS); } + try { response = responseFuture.get(timeoutSeconds + 1, TimeUnit.SECONDS); } catch (ExecutionException | TimeoutException failure) { responseFuture.cancel(true); - if (attempt == 2) throw new IOException("Provider transport unavailable", failure); + if (attempt == attempts - 1) throw new IOException("Provider transport unavailable", failure); Thread.sleep((250L << attempt) + ThreadLocalRandom.current().nextLong(100)); continue; } String text = new String(response.body(), java.nio.charset.StandardCharsets.UTF_8); int status = response.statusCode(); - if ((status == 429 || status == 503 || status == 502 || status == 504) && attempt < 2) { long delay = 250L << attempt; + if ((status == 429 || status == 503 || status == 502 || status == 504) && attempt < attempts - 1) { long delay = 250L << attempt; try { delay = Math.max(delay, Long.parseLong(response.headers().firstValue("retry-after").orElse("0")) * 1000); } catch (NumberFormatException ignored) { } if (delay > 10000) throw new ProviderException(status, "retry_later"); Thread.sleep(delay + ThreadLocalRandom.current().nextLong(100)); continue; } @@ -431,7 +466,7 @@ private CompletableFuture submit(Callable fn) { } public CompletionStage stop() { if (!closing.compareAndSet(false, true)) return stopped; - executor.execute(() -> { try { if (started) { signed("drain", "POST", new JsonObject()); transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); } } catch (Exception e) { diagnostics.accept("provider_drain_unavailable"); } + executor.execute(() -> { try { if (started) { drainAndReport(); flushEvents(); } } catch (Exception e) { diagnostics.accept("provider_drain_unavailable"); } finally { closed = true; started = false; if (timer != null) timer.cancel(false); try { transport.close().toCompletableFuture().get(10, TimeUnit.SECONDS); } diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java index 625ab3ff..dcffb469 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java @@ -11,9 +11,11 @@ enum ApplyResult { PENDING, APPLIED, REJECTED } 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. */ + /** Apply serving/draining/closed background state before acknowledging its revision. */ + CompletionStage applyState(String state); + /** Whether this integration can observe the application join/rejection boundary. */ + default boolean supportsGameOutcomes() { return false; } + /** Bounded ticket-correlated transport and application observations. */ List pollEvents(); CompletionStage drain(); CompletionStage close(); diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java index 6ff1eae1..dfc88326 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java @@ -105,14 +105,12 @@ private static List checkedEndpoints(List 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. + @Override public CompletionStage applyState(String state) { + if (state == null) return CompletableFuture.completedFuture(ApplyResult.REJECTED); + return switch (state) { + case "serving" -> CompletableFuture.completedFuture(ApplyResult.APPLIED); + case "draining" -> drain().thenApply(ignored -> ApplyResult.APPLIED); + case "closed" -> close().thenApply(ignored -> ApplyResult.APPLIED); default -> CompletableFuture.completedFuture(ApplyResult.REJECTED); }; } diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java index 7c5357ba..314c1983 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java @@ -11,12 +11,12 @@ private JsonObject response(long delay) { response.addProperty("receivedAt", java.time.Instant.ofEpochMilli(now).toString()); schedule.addProperty("version", 1); schedule.addProperty("afterMillis", delay); schedule.addProperty("nextCheckInAt", now + delay); schedule.addProperty("leaseExpiresAt", now + delay + 30000); - schedule.addProperty("controlPollAfterMillis", delay); schedule.addProperty("minUpdateIntervalMillis", 1000); + schedule.addProperty("minUpdateIntervalMillis", 1000); response.add("checkIn", schedule); return response; } @Test void acceptsChangedPolicyWithoutHardCodedIdleThresholds() throws Exception { assertEquals(900000, CheckInSchedule.parse(response(900000)).afterMillis()); - assertEquals(3600000, CheckInSchedule.parse(response(3600000)).controlPollAfterMillis()); + assertEquals(3600000, CheckInSchedule.parse(response(3600000)).afterMillis()); assertEquals(45000, CheckInSchedule.parse(response(45000)).afterMillis()); } @Test void rejectsUnboundedFractionalAndInconsistentSchedules() { diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java index e511c7ba..5ddf96f8 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java @@ -15,6 +15,8 @@ public final class IndependentProviderStub implements AutoCloseable { final Map challenges = new HashMap<>(), keys = new HashMap<>(), placements = new HashMap<>(); JsonObject registration; volatile JsonObject lastHeartbeat; volatile int failHeartbeats; + volatile boolean failOutcomes; + volatile int outcomeAttempts; volatile boolean loseCompletionResponse; volatile long checkInMillis; volatile int controlPolls; @@ -26,7 +28,11 @@ public final class IndependentProviderStub implements AutoCloseable { volatile JsonObject extensionMetadata; volatile int extensionRequests, keyAcknowledgements; boolean draining; - volatile JsonArray commands = new JsonArray(); + volatile String desiredState = "serving"; + volatile long desiredRevision = 1, appliedRevision; + String keyRequestId; JsonObject requestedKey; + int epoch = 1, profileRevision; + final List operationsSeen = new java.util.concurrent.CopyOnWriteArrayList<>(); public IndependentProviderStub() throws IOException { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); origin = "http://127.0.0.1:" + server.getAddress().getPort(); server.createContext("/", this::handle); server.start(); @@ -44,14 +50,15 @@ private JsonObject dispatch(HttpExchange e) throws Exception { if (path.equals("/.well-known/nethernet-external-signalling")) { JsonObject d = new JsonObject(); d.addProperty("provider", origin); d.addProperty("controlOrigin", origin); d.add("protocols", strings(ProviderCrypto.PROTOCOL)); d.add("signatures", strings(ProviderCrypto.SIGNATURE)); d.add("modes", strings("new-service", "attach-instance")); d.add("profiles", strings("nxs-admission-v1")); - JsonObject operations = new JsonObject(); for (String op : List.of("challenges", "complete", "recover", "activate", "heartbeat", "host-profile", "readiness", "control", "control/ack", "drain", "rotate", "retire", "ticket-keys", "ticket-keys/ack", "ticket-events", "events", "deregister")) operations.addProperty(op, origin + "/example/" + op); + JsonObject operations = new JsonObject(); for (String op : List.of("register", "complete", "heartbeat", "outcomes", "rotate", "retire", "deregister")) operations.addProperty(op, origin + "/example/" + op); if (extensionMetadata != null) d.add("extensions", extensionMetadata.deepCopy()); - d.add("operations", operations); JsonObject limits = new JsonObject(); limits.addProperty("heartbeatIntervalMs", 1000); if (checkInMillis > 0) limits.addProperty("checkInVersion", 1); limits.addProperty("maxControlPage", 100); limits.addProperty("leaseMs", 30000); limits.addProperty("maxBodyBytes", 65536); limits.addProperty("clockSkewMs", 60000); d.add("limits", limits); + d.add("operations", operations); JsonObject limits = new JsonObject(); limits.addProperty("heartbeatIntervalMs", 1000); if (checkInMillis > 0) limits.addProperty("checkInVersion", 1); limits.addProperty("leaseMs", 30000); limits.addProperty("maxBodyBytes", 65536); limits.addProperty("clockSkewMs", 60000); d.add("limits", limits); JsonObject authorization = new JsonObject(); authorization.addProperty("header", "Authorization"); JsonArray schemes = new JsonArray(); schemes.add(authorizationScheme("anonymous-proof-of-work", "new-service")); schemes.add(authorizationScheme("bearer-token", "new-service", "attach-instance")); authorization.add("schemes", schemes); d.add("authorization", authorization); return d; } - if (path.equals("/example/challenges") || path.equals("/example/recover")) { - boolean recovery = path.endsWith("recover"); + operationsSeen.add(path); + if (path.equals("/example/register")) { + boolean recovery = body.has("registrationId"); if (!ProviderCrypto.PROTOCOL.equals(body.get("protocol").getAsString()) || !"nxs-admission-v1".equals(body.get("profile").getAsString())) throw new Failure(400, "unsupported_profile"); if (recovery && (registration == null || !registration.get("registrationId").equals(body.get("registrationId")))) throw new Failure(403, "recovery_unavailable"); JsonObject key = recovery ? keys.get(registration.get("keyId").getAsString()) : body.getAsJsonObject("publicKeyJwk"); @@ -75,38 +82,48 @@ private JsonObject dispatch(HttpExchange e) throws Exception { if (c == null) throw new Failure(409, "challenge_consumed"); String proof = ProviderCrypto.proof(c, body.get("proofNonce").getAsString(), body.get("idempotencyKey").getAsString()); if (!ProviderCrypto.verify(keys.get(id), body.get("signature").getAsString(), proof) || !ProviderCrypto.meetsDifficulty(ProviderCrypto.digest(proof), c.getAsJsonObject("pow").get("difficulty").getAsInt())) throw new Failure(401, "proof_invalid"); - challenges.remove(id); + challenges.remove(id); generation++; sequence = 0; draining = false; appliedRevision = 0; profileRevision = 0; if (c.getAsJsonObject("context").get("mode").getAsString().equals("recover")) { JsonObject r = registration.deepCopy(); r.remove("ticketKey"); r.addProperty("leaseGeneration", generation); return r; } if (registration != null) throw new Failure(409, "already_registered"); registrations++; - registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", 0); registration.addProperty("leaseDeadline", 0); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); if (loseCompletionResponse) { loseCompletionResponse = false; e.close(); throw new Failure(503, "completion_response_lost"); } return registration.deepCopy(); + registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", generation); registration.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); if (loseCompletionResponse) { loseCompletionResponse = false; e.close(); throw new Failure(503, "completion_response_lost"); } return registration.deepCopy(); } if (path.equals("/example/heartbeat") && failHeartbeats-- > 0) throw new Failure(503, "fixture_transient"); authenticate(e, raw); JsonObject ok = new JsonObject(); ok.addProperty("accepted", true); switch (path) { - case "/example/activate" -> { if (!"nxs-admission-v1".equals(body.get("profile").getAsString())) throw new Failure(400, "unsupported_profile"); generation++; sequence = 0; draining = false; ok.addProperty("leaseGeneration", generation); ok.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); } - case "/example/host-profile" -> { - if (keyAcknowledgements == 0 || !"nethernet.stateless-admission.v1".equals(body.getAsJsonObject("statelessAdmission").get("capability").getAsString()) - || !body.get("dtlsFingerprint").getAsString().matches("sha-256 [0-9A-F]{2}(?::[0-9A-F]{2}){31}")) throw new Failure(400, "invalid_host_profile"); - ok.addProperty("revision", "example-profile-revision"); - } - case "/example/heartbeat" -> { if (draining) throw new Failure(403, "draining"); lastHeartbeat = body; heartbeats++; + case "/example/heartbeat" -> { + lastHeartbeat = body; heartbeats++; + if (body.has("installedKeyIds")) keyAcknowledgements++; + if (body.has("hostProfile")) { + JsonObject profile = body.getAsJsonObject("hostProfile"); + if (keyAcknowledgements == 0 || !"nethernet.stateless-admission.v1".equals(profile.getAsJsonObject("statelessAdmission").get("capability").getAsString()) + || !profile.get("dtlsFingerprint").getAsString().matches("sha-256 [0-9A-F]{2}(?::[0-9A-F]{2}){31}")) throw new Failure(400, "invalid_host_profile"); + profileRevision++; + } + if (body.has("keyRequestId")) { + String wanted = body.get("keyRequestId").getAsString(); + if (!wanted.equals(keyRequestId)) { keyRequestId = wanted; requestedKey = ticket(); requestedKey.addProperty("keyId", String.format("T%03d", ++epoch)); ok.add("ticketKey", requestedKey.deepCopy()); } + JsonObject request = new JsonObject(); request.addProperty("id", keyRequestId); request.add("keyId", requestedKey.get("keyId")); ok.add("keyRequest", request); + } + draining = !body.get("state").getAsString().equals("serving"); + long applied = body.get("appliedStateRevision").getAsLong(); + if (applied > appliedRevision) { appliedRevision = applied; acknowledgements++; } + JsonObject desired = new JsonObject(); desired.addProperty("revision", desiredRevision); desired.addProperty("state", desiredState); ok.add("desiredState", desired); + ok.addProperty("hostProfileRevision", "example-profile-" + profileRevision); + ok.addProperty("routable", profileRevision > 0 && keyAcknowledgements > 0 && !draining); + JsonObject ready = new JsonObject(); ready.addProperty("routable", ok.get("routable").getAsBoolean()); ready.add("reasons", new JsonArray()); ok.add("readiness", ready); + if (extensionMetadata != null) ok.add("extensions", extensionMetadata.deepCopy()); if (checkInMillis > 0 && body.has("checkInVersion")) { long now = System.currentTimeMillis(); JsonObject schedule = new JsonObject(); schedule.addProperty("version", 1); schedule.addProperty("afterMillis", checkInMillis); schedule.addProperty("nextCheckInAt", now + checkInMillis); schedule.addProperty("leaseExpiresAt", now + checkInMillis + 30000); - schedule.addProperty("minUpdateIntervalMillis", 1000); schedule.addProperty("controlPollAfterMillis", checkInMillis); + schedule.addProperty("minUpdateIntervalMillis", 1000); ok.add("checkIn", schedule); ok.addProperty("receivedAt", java.time.Instant.ofEpochMilli(now).toString()); - } } - case "/example/control" -> { controlPolls++; ok.add("commands", commands.deepCopy()); ok.addProperty("cursor", "example-cursor"); ok.addProperty("serverTime", java.time.Instant.now().toString()); } - case "/example/control/ack" -> { acknowledgements++; commands = new JsonArray(); } - case "/example/readiness" -> { ok.addProperty("routable", heartbeats > 0 && !draining); if (extensionMetadata != null) ok.add("extensions", extensionMetadata.deepCopy()); } + } + } case "/example/extension" -> { extensionRequests++; } case "/example/deregister" -> { draining = true; } - case "/example/drain" -> draining = true; - case "/example/ticket-keys" -> ok.add("ticketKey", ticket()); - case "/example/ticket-keys/ack" -> { keyAcknowledgements++; } - case "/example/ticket-events", "/example/events" -> { for (JsonElement event : body.getAsJsonArray("events")) events.add(event.getAsJsonObject()); } + case "/example/outcomes" -> { outcomeAttempts++; if (failOutcomes) throw new Failure(503, "fixture_outcome_unavailable"); for (JsonElement event : body.getAsJsonArray("events")) events.add(event.getAsJsonObject()); } case "/example/rotate" -> { String old = e.getRequestHeaders().getFirst("nxs-key-id"), intent = e.getRequestHeaders().getFirst("idempotency-key"); JsonObject key = body.getAsJsonObject("publicKeyJwk"); if (!ProviderCrypto.verify(key, body.get("proof").getAsString(), ProviderCrypto.array(ProviderCrypto.PROTOCOL, "rotate", origin, "example-machine-1", old, ProviderCrypto.thumbprint(key), generation, intent))) throw new Failure(401, "replacement_proof_invalid"); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java index 7d5285e0..6310ee4e 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java @@ -21,7 +21,7 @@ public CompletionStage hostProfile() { JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", keyId); p.addProperty("dtlsFingerprint", "sha-256 " + String.join(":", Collections.nCopies(32, "11"))); p.addProperty("sctpPort", 5000); p.addProperty("maxMessageSize", 262144); JsonObject c = new JsonObject(); c.addProperty("address", "127.0.0.1"); c.addProperty("port", 19133); c.addProperty("foundation", "fixture"); c.addProperty("component", 1); c.addProperty("priority", 100); c.addProperty("protocol", "udp"); c.addProperty("type", "host"); JsonArray candidates = new JsonArray(); candidates.add(c); p.add("candidates", candidates); JsonObject capability = new JsonObject(); capability.addProperty("capability", "nethernet.stateless-admission.v1"); capability.addProperty("incarnation", incarnation); p.add("statelessAdmission", capability); return CompletableFuture.completedFuture(p); } - public CompletionStage applyControl(JsonObject c) { return CompletableFuture.completedFuture(ApplyResult.REJECTED); } + public CompletionStage applyState(String state) { return CompletableFuture.completedFuture(ApplyResult.REJECTED); } public List pollEvents() { return List.of(); } public CompletionStage drain() { return CompletableFuture.completedFuture(null); } public CompletionStage close() { return CompletableFuture.completedFuture(null); } diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java index 007d90d5..00165525 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java @@ -55,15 +55,15 @@ class ProviderClientTest { () -> new ProviderClient.Health(true, 40, players.get() / 40.0, "nethernet", "fixture"), message -> {}); try { client.start().get(20, TimeUnit.SECONDS); - eventually(() -> stub.controlPolls == 1); + assertEquals(0, stub.controlPolls); Thread.sleep(2200); - assertEquals(1, stub.heartbeats); assertEquals(1, stub.controlPolls); + assertEquals(1, stub.heartbeats); assertEquals(0, stub.controlPolls); for (int i = 0; i < 100; i++) client.requestStatusRefresh(); Thread.sleep(1200); assertEquals(1, stub.heartbeats, "Unchanged local refreshes must not send requests"); stub.checkInMillis = 1000; players.set(1); client.requestStatusRefresh(); eventually(() -> stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 1); int busyBefore = stub.heartbeats; - eventually(() -> stub.heartbeats > busyBefore && stub.controlPolls > 1); + eventually(() -> stub.heartbeats > busyBefore && stub.controlPolls == 0); stub.checkInMillis = 3600000; players.set(0); client.requestStatusRefresh(); eventually(() -> stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 0); Thread.sleep(2200); int before = stub.heartbeats; int polls = stub.controlPolls; @@ -89,16 +89,16 @@ static final class FakeTransport implements ProviderTransport { final CompletableFuture closed = new CompletableFuture<>(); final java.util.Queue events = new java.util.concurrent.ConcurrentLinkedQueue<>(); volatile int installed, applied, admissions, drains; - boolean stateless = true; + boolean stateless = true; String ticketKeyId = "T001"; volatile ApplyResult result = ApplyResult.APPLIED; - public CompletionStage hostProfile() { JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", "T001"); p.addProperty("dtlsFingerprint", "sha-256 " + String.join(":", Collections.nCopies(32, "11"))); p.addProperty("sctpPort", 5000); p.addProperty("maxMessageSize", 262144); + public CompletionStage hostProfile() { JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", ticketKeyId); p.addProperty("dtlsFingerprint", "sha-256 " + String.join(":", Collections.nCopies(32, "11"))); p.addProperty("sctpPort", 5000); p.addProperty("maxMessageSize", 262144); JsonObject c = new JsonObject(); c.addProperty("foundation", "fixture"); c.addProperty("component", 1); c.addProperty("protocol", "udp"); c.addProperty("priority", 100); c.addProperty("address", "127.0.0.1"); c.addProperty("port", 19133); c.addProperty("type", "host"); JsonArray candidates = new JsonArray(); candidates.add(c); p.add("candidates", candidates); if (stateless) { JsonObject cap = new JsonObject(); cap.addProperty("capability", "nethernet.stateless-admission.v1"); cap.addProperty("incarnation", "0123456789abcdef0123456789abcdef"); p.add("statelessAdmission", cap); } return CompletableFuture.completedFuture(p); } - public CompletionStage installTicketKeys(List keys) { installed = keys.size(); return CompletableFuture.completedFuture(null); } - public CompletionStage applyControl(JsonObject c) { - applied++; String kind = c.get("kind").getAsString(); + public CompletionStage installTicketKeys(List keys) { installed = keys.size(); ticketKeyId = keys.getLast().keyId(); return CompletableFuture.completedFuture(null); } + public CompletionStage applyState(String state) { + applied++; String kind = state; if (kind.equals("join-admission")) admissions++; - if (kind.equals("drain")) drains++; + if (kind.equals("draining")) drains++; return CompletableFuture.completedFuture(kind.equals("join-admission") ? result : ApplyResult.APPLIED); } public List pollEvents() { List batch = new ArrayList<>(); for (JsonObject event; (event = events.poll()) != null;) batch.add(event); return batch; } @@ -128,7 +128,7 @@ private static void eventually(java.util.function.BooleanSupplier condition) thr while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(40); assertTrue(condition.getAsBoolean(), "Timed out waiting for provider lifecycle"); } - @Test void failedRefreshRetriesAndRejectsPerJoinControlWithoutBlockingDrain(@TempDir Path path) throws Exception { + @Test void failedRefreshRetriesAndRejectsUnknownStateWithoutAcknowledgingIt(@TempDir Path path) throws Exception { try (IndependentProviderStub stub = new IndependentProviderStub()) { AtomicInteger players = new AtomicInteger(2); FakeTransport host = new FakeTransport(); var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"); @@ -143,19 +143,39 @@ private static void eventually(java.util.function.BooleanSupplier condition) thr for (int i = 0; i < 500; i++) client.requestStatusRefresh(); eventually(() -> stub.lastHeartbeat.has("serverStatus") && stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 5); assertTrue(stub.heartbeats - before <= 2, "Burst must coalesce within heartbeat cadence"); - JsonObject join = new JsonObject(); join.addProperty("kind", "join-admission"); - JsonObject unknown = new JsonObject(); unknown.addProperty("kind", "future-command"); - JsonObject drain = new JsonObject(); drain.addProperty("kind", "drain"); - JsonArray commands = new JsonArray(); commands.add(join); commands.add(unknown); commands.add(drain); stub.commands = commands; - eventually(() -> host.drains > 0); - assertEquals(0, host.admissions, "NXS never stages a join from provider control"); - assertEquals(0, stub.acknowledgements, "Unsupported control must not be silently acknowledged"); - JsonArray known = new JsonArray(); known.add(drain); stub.commands = known; - eventually(() -> stub.acknowledgements == 1); + int beforeAck = stub.acknowledgements; + stub.desiredState = "future-state"; stub.desiredRevision = 2; + assertThrows(ExecutionException.class, () -> client.readiness().get(10, TimeUnit.SECONDS)); + assertEquals(0, host.admissions, "NXS has no per-join provider state"); + assertTrue(stub.appliedRevision < 2, "Unknown state cannot be acknowledged"); + stub.desiredState = "draining"; + client.readiness().get(10, TimeUnit.SECONDS); + assertTrue(host.drains > 0); assertEquals(2, stub.appliedRevision); + assertTrue(stub.acknowledgements > beforeAck); client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } } + @Test void outcomeOutageBacksOffWhileHeartbeatsContinue(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + FakeTransport host = new FakeTransport(); stub.failOutcomes = true; + var client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"), + new ProviderStateStore(path), host, () -> null, () -> new ProviderClient.Health(true, 10, 0, "nethernet", "fixture"), message -> {}); + try { + client.start().get(20, TimeUnit.SECONDS); + host.events.add(JsonParser.parseString("{\"ticketId\":\"fixture-ticket\",\"stage\":\"ticket.failed\",\"occurredAt\":\"2026-09-07T00:00:00Z\"}").getAsJsonObject()); + eventually(() -> stub.outcomeAttempts == 1); + int before = stub.heartbeats; + eventually(() -> stub.heartbeats >= before + 2); + assertEquals(1, stub.outcomeAttempts, "Outcome failure must back off independently of heartbeat"); + JsonObject saved = JsonParser.parseString(java.nio.file.Files.readString(path.resolve("provider-state.json"))).getAsJsonObject(); + assertEquals(1, saved.getAsJsonArray("pendingEvents").size()); + assertTrue(saved.get("profilePublishedAt").getAsLong() > 0); + } finally { stub.failOutcomes = false; client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + assertEquals(1, stub.events.size(), "Shutdown retries the durable outcome without losing it"); + } + } + @Test void localPersistenceFailureStopsPublicationAndClosesTransport(@TempDir Path path) throws Exception { try (IndependentProviderStub stub = new IndependentProviderStub()) { FakeTransport host = new FakeTransport(); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java index 370d5a26..dd625ca2 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java @@ -39,7 +39,7 @@ private static ProviderClient client(IndependentProviderStub stub, Path director assertTrue(stub.keyAcknowledgements > 0); assertTrue(instance.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); if (attach) assertEquals("london", result.getAsJsonObject("placement").getAsJsonObject("tags").get("location").getAsString()); - JsonObject event = new JsonObject(); event.addProperty("stage", "ticket.transport_established"); + JsonObject event = new JsonObject(); event.addProperty("stage", "ticket.data_channels_open"); event.addProperty("ticketId", "opaque-correlation"); event.addProperty("occurredAt", java.time.Instant.now().toString()); event.addProperty("reason", "connected"); event.addProperty("privatePayload", "must-not-be-persisted"); transport.events.add(event); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); @@ -119,7 +119,7 @@ private static ProviderClient client(IndependentProviderStub stub, Path director try { JsonObject registration = resumed.start().get(20, TimeUnit.SECONDS); assertEquals(stub.registration.get("registrationId"), registration.get("registrationId")); - assertEquals(1, stub.registrations); assertEquals(1, stub.generation); + assertEquals(1, stub.registrations); assertEquals(2, stub.generation); assertTrue(resumed.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); assertTrue(stub.keyAcknowledgements > 0, "Lost one-time key material is freshly provisioned"); } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java index 7830e718..6cffb8b3 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionIntegrationTest.java @@ -379,7 +379,7 @@ private static byte[] nominatedBinding(String username, String password) throws 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.signalling.ProviderTransport.ApplyResult.REJECTED,transport.applyControl(command).toCompletableFuture().get()); + assertEquals(org.cloudburstmc.netty.signalling.ProviderTransport.ApplyResult.REJECTED,transport.applyState("join-admission").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.signalling.ProviderTransport.TicketKey("K001",TestSignallingProvider.SECRET,0,System.currentTimeMillis()+60_000), From 907f7df41c149a26bc7449db7f13ce96093d9c4e Mon Sep 17 00:00:00 2001 From: Zulu Date: Mon, 7 Sep 2026 22:41:42 +0100 Subject: [PATCH 13/15] feat: separate NXS runtime counts and optional public endpoint metadata --- docs/external-signalling/README.md | 3 +- docs/external-signalling/fixtures.mjs | 14 +++++ docs/external-signalling/nxs-v1.fixtures.json | 51 +++++++++++++++++- docs/external-signalling/nxs-v1.schema.json | 46 ++++++++++++++-- docs/external-signalling/wire-reference.md | 36 ++++++++++++- external-signalling/README.md | 3 ++ .../netty/signalling/ProviderClient.java | 22 +++++++- .../signalling/IndependentProviderStub.java | 5 +- .../netty/signalling/ProviderBench.java | 2 +- .../netty/signalling/ProviderClientTest.java | 54 +++++++++++++++++++ .../admission/ProviderNativeBench.java | 5 +- 11 files changed, 226 insertions(+), 15 deletions(-) diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md index 3d3ab699..4d57e278 100644 --- a/docs/external-signalling/README.md +++ b/docs/external-signalling/README.md @@ -40,7 +40,8 @@ issuance, ownership claims and fleet administration belong to the provider. Send a signed `heartbeat` immediately after startup and whenever its returned schedule says to check in. The request carries: -- Health, capacity, load and optional public server status. +- Health, admission capacity, load, optional actual player counts with sample time, + and independent optional public server status. - `hostProfile` when endpoint details change; otherwise `hostProfileRevision`. - `installedKeyIds`, listing installed admission epochs with the active one last. - Local `state` (`serving`, `draining` or `closed`), the applied provider-state diff --git a/docs/external-signalling/fixtures.mjs b/docs/external-signalling/fixtures.mjs index 7c6b7652..7e195a0c 100644 --- a/docs/external-signalling/fixtures.mjs +++ b/docs/external-signalling/fixtures.mjs @@ -51,3 +51,17 @@ const provenance = {specification:'urn:nethernet:external-signalling:v1', files: if (update) write('provenance.json',provenance); assert.deepEqual(read('provenance.json'),provenance); console.log('NXS canonical signing, stateless encryption, and fixture hashes verified.'); + +// Fleet examples keep public endpoint metadata optional and runtime counts independent. +const schema = read('nxs-v1.schema.json'); +for (const [document, value] of Object.entries(f.fleetExamples)) { + for (const field of schema.$defs[document].required) assert(field in value, document + ' omitted ' + field); +} +assert(!schema.$defs.registration.required.includes('serviceId')); +assert(!schema.$defs.registration.required.includes('publicAddress')); +assert(!('serviceId' in f.fleetExamples.registration)); +assert(!('publicAddress' in f.fleetExamples.registration)); +assert.equal(schema.$defs.heartbeat.properties.playerCount.$ref, '#/$defs/playerCount'); +assert.equal(f.fleetExamples.heartbeat.playerCount.connectedPlayers, 3); +assert.equal(f.fleetExamples.heartbeat.serverStatus.players, 25000); +assert(f.fleetExamples.heartbeat.playerCount.sampledAt <= f.fleetExamples.heartbeat.clockUnixMillis); diff --git a/docs/external-signalling/nxs-v1.fixtures.json b/docs/external-signalling/nxs-v1.fixtures.json index f46fb48c..1ca6218c 100644 --- a/docs/external-signalling/nxs-v1.fixtures.json +++ b/docs/external-signalling/nxs-v1.fixtures.json @@ -32,7 +32,7 @@ "context": { "mode": "attach-instance", "profile": "nxs-admission-v1", - "label": "EU café 🦊\n", + "label": "EU caf\u00e9 \ud83e\udd8a\n", "authorizationId": "auth_fixture", "serviceId": "service_neutral", "region": "EU", @@ -69,9 +69,56 @@ "idempotencyKey": "intent_fixture_0001", "generation": 2, "sequence": 17, - "body": "{\"name\":\"café 🦊\",\"players\":0}\n" + "body": "{\"name\":\"caf\u00e9 \ud83e\udd8a\",\"players\":0}\n" }, "payload": "[\"nethernet-external-signalling-v1\",\"nxs-es384-v1\",\"https://provider.example\",\"POST\",\"/renew?region=EU&label=caf%C3%A9\",1788484200123,\"machine_neutral\",\"key_fixture\",\"intent_fixture_0001\",2,17,\"hW1_XbRZsu7XCnVbFpxNepqnGsOafEGkN_VFWwKb4jQ\"]", "signature": "SMg1I5sJM8nQMSLcGMx8ajKG2HuF2b0qXolYIyPNtuC4jRT89G_MKemggBUGNw1_CtwBhEJiZfdQsM5OTkIDZTFukS2pQdFmLYw8x8GkWeouETOl7CbRnVCAA3SpV3Un" + }, + "fleetExamples": { + "registration": { + "protocol": "nethernet-external-signalling-v1", + "provider": "https://provider.example", + "registrationId": "reg_fleet_1", + "instanceId": "instance_fleet_1", + "keyId": "key_fleet_1", + "profile": "nxs-admission-v1", + "placement": { + "region": "EU", + "pool": "proxy" + }, + "heartbeatIntervalMs": 10000, + "leaseGeneration": 1, + "leaseDeadline": 1788800000000, + "readiness": { + "routable": false, + "reasons": [ + "no_public_endpoint" + ] + } + }, + "heartbeat": { + "healthy": true, + "capacity": 20, + "load": 0.9, + "protocolVersion": "nethernet", + "clockUnixMillis": 1788799970100, + "checkInVersion": 1, + "state": "serving", + "appliedStateRevision": 1, + "gameOutcomes": "unavailable", + "playerCount": { + "connectedPlayers": 3, + "sampledAt": 1788799970000 + }, + "serverStatus": { + "name": "Network listing", + "networkId": 1234, + "levelName": "world", + "version": "fixture", + "players": 25000, + "maxPlayers": 30000, + "gameType": 0 + } + } } } diff --git a/docs/external-signalling/nxs-v1.schema.json b/docs/external-signalling/nxs-v1.schema.json index 36ce7314..40650bf4 100644 --- a/docs/external-signalling/nxs-v1.schema.json +++ b/docs/external-signalling/nxs-v1.schema.json @@ -299,11 +299,9 @@ "protocol", "provider", "registrationId", - "serviceId", "instanceId", "keyId", "profile", - "publicAddress", "placement", "heartbeatIntervalMs", "leaseGeneration", @@ -316,14 +314,30 @@ }, "extensions": { "$ref": "#/$defs/extensions" + }, + "serviceId": { + "type": "string", + "minLength": 1 + }, + "publicAddress": { + "type": "string", + "minLength": 1 } - } + }, + "dependentRequired": { + "serviceId": [ + "publicAddress" + ], + "publicAddress": [ + "serviceId" + ] + }, + "description": "An independent runtime identity. Optional serviceId/publicAddress describe a public endpoint at this observation; they are not stable instance identity. Pool attachments may have no public endpoint or serve several endpoints." }, "readiness": { "type": "object", "required": [ "serverTime", - "serviceId", "instanceId", "serviceAvailable", "instanceRoutable", @@ -594,6 +608,9 @@ }, "extensions": { "$ref": "#/$defs/extensions" + }, + "playerCount": { + "$ref": "#/$defs/playerCount" } } }, @@ -787,6 +804,27 @@ } }, "additionalProperties": false + }, + "playerCount": { + "type": "object", + "additionalProperties": false, + "required": [ + "connectedPlayers", + "sampledAt" + ], + "properties": { + "connectedPlayers": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "sampledAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "description": "Actual players connected to this runtime, including still-connected draining players. sampledAt is Unix milliseconds and covers this count and the heartbeat capacity from the same host observation. Absence means unknown. Never derive this value from public serverStatus, load, tickets or reservations." } }, "x-context-order": [ diff --git a/docs/external-signalling/wire-reference.md b/docs/external-signalling/wire-reference.md index 06811c71..8ecc293b 100644 --- a/docs/external-signalling/wire-reference.md +++ b/docs/external-signalling/wire-reference.md @@ -145,8 +145,8 @@ and single-use completion atomically with resource creation. Retrying completion MUST NOT return one-time key secrets again. If completion was interrupted, recover the registration by proving ownership of the same key. -Completion returns `protocol,provider,registrationId,serviceId,instanceId,keyId, -profile,publicAddress,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, +Completion returns `protocol,provider,registrationId,instanceId,keyId, +profile,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, readiness`, plus optional one-time `ticketKey` and `extensions`. Completion atomically starts a new generation, clears previous readiness and resets the operational sequence to zero. Save the IDs and key material before heartbeat. Recovery uses `register {registrationId,protocol,profile}` and the @@ -193,6 +193,15 @@ to identify which key is current. key. `deregister` carries `{}` and permanently ends registration. Neither is an admission-key rotation or an ordinary graceful drain. +Registration may also return `serviceId` and `publicAddress`, always together. +They describe a public endpoint at that observation and are not stable runtime +identity. Pool attachments can return neither: a pool may have zero or several +public endpoints. Providers bind the token's permitted placement to their pool; +clients cannot choose arbitrary provider-owned resource IDs. Recovery preserves +`instanceId` and `registrationId`; changes to public endpoints do not require a +new runtime identity. The default standalone registration still creates a public +endpoint alongside the runtime. + ## `heartbeat` Required fields: `healthy,capacity,load,protocolVersion,clockUnixMillis, @@ -216,6 +225,29 @@ installedKeyIds,keyRequestId,extensions`. routing capacity/load. Omitted or failed status publication does not refresh a previous status snapshot. +### Actual player counts + +Optional `playerCount: {connectedPlayers, sampledAt}` reports the actual number of +players connected to this runtime, including existing players while it is draining. +`connectedPlayers` is an integer from 0 to 1000000; `sampledAt` is Unix milliseconds +from the host clock. The heartbeat's admission `capacity` must come from the same +observation. Count may exceed capacity after a capacity reduction. Capacity zero +means no admission. `load` remains a separate health/load observation. + +This count is independent of the public `serverStatus.players` and its advertised +`maxPlayers`. A public/global override must never change the count or admission +capacity. Do not estimate connected players from load, successful tickets, reserved +slots or public listing totals. Omit `playerCount` when it is unknown; omission and +zero are distinct. Omission does not refresh the previous count. Providers fence +samples by the authenticated lease generation and sequence, track sample and receipt +times separately, and exclude stale/unknown samples from count-dependent routing +unless an explicit fallback policy applies. A retry must not freshen a sample. + +The Java `Health` supplier accepts an optional `PlayerCount`. Sample the runtime and +capacity together; preserve an old sample's timestamp if returning cached values. +A changed connected count can wake a scheduled check-in even if public status is +unchanged. A new timestamp alone does not cause extra network traffic. + ### Publish the host profile `heartbeat.hostProfile` contains `candidates`, `dtlsFingerprint`, `credentialKeyId`, diff --git a/external-signalling/README.md b/external-signalling/README.md index 4e5ac6f5..7d979523 100644 --- a/external-signalling/README.md +++ b/external-signalling/README.md @@ -15,6 +15,9 @@ or bearer token, token-authorized instance attachment, durable recovery, generat activation, status/profile publication, scheduled heartbeats, key rotation, drain, and asynchronous outcomes. Tokens are enrollment-only and excluded from durable state/logs. One instance owns one private state directory; restarts preserve that directory. +A pool attachment may have no public address. Public endpoints can change without +changing the runtime identity. `Health` accepts an optional `PlayerCount` with actual +connected players and sample time, separate from the public `ServerStatus` supplier. `ProtocolExtensions` carries bounded optional metadata. Applications explicitly interpret known namespaces and invoke only their advertised same-origin operations. The core never diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java index 56764e8f..79e2e823 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java @@ -36,8 +36,17 @@ public Configuration(URI provider, String profile, String label) { } @Override public String toString() { return "Configuration[provider=" + provider + ", profile=" + profile + ", registrationMode=" + registrationMode + ", authorizationScheme=" + authorizationScheme + "]"; } } - public record Health(boolean healthy, int capacity, double load, String protocolVersion, String build) { + /** Sampled actual players on this runtime; keep counting existing players while draining. */ + public record PlayerCount(int connectedPlayers, long sampledAt) { + public PlayerCount { if (connectedPlayers < 0 || connectedPlayers > 1000000 || sampledAt < 0 || sampledAt > 9007199254740991L) throw new IllegalArgumentException("Invalid player count sample"); } + } + /** Capacity and playerCount describe the same observation. Public server status is independent. */ + public record Health(boolean healthy, int capacity, double load, String protocolVersion, String build, PlayerCount playerCount) { public Health { if (capacity < 0 || capacity > 1000000 || !Double.isFinite(load) || load < 0 || load > 1 || protocolVersion == null) throw new IllegalArgumentException("Invalid health"); } + /** Hosts without actual player telemetry report unknown, never a synthetic zero. */ + public Health(boolean healthy, int capacity, double load, String protocolVersion, String build) { + this(healthy, capacity, load, protocolVersion, build, null); + } } public static final class ProviderException extends IOException { private final int status; @@ -160,7 +169,7 @@ private void completeRecovery(JsonObject challenge, String registrationId) throw completion.addProperty("proofNonce", "0"); completion.addProperty("idempotencyKey", intent); completion.addProperty("signature", ProviderCrypto.sign(key, ProviderCrypto.proof(challenge, "0", intent))); JsonObject recovered = unsigned("complete", completion); validateRegistration(recovered); if (!registrationId.equals(recovered.get("registrationId").getAsString())) throw new IOException("Recovered registration changed"); - if (state.has("registration")) for (String field : List.of("instanceId", "serviceId", "registrationId")) + if (state.has("registration")) for (String field : List.of("instanceId", "registrationId")) if (!state.getAsJsonObject("registration").get(field).equals(recovered.get(field))) throw new IOException("Recovered instance identity changed"); registrationExtensions = ProtocolExtensions.copy(recovered); recovered.remove("extensions"); recovered.remove("ticketKey"); state.add("registration", recovered); state.addProperty("generation", recovered.get("leaseGeneration").getAsLong()); @@ -216,6 +225,12 @@ private void enroll() throws Exception { private void validateRegistration(JsonObject registration) throws IOException { ProviderContract.require("registration", registration); ProtocolExtensions.validate(registration); + boolean hasService = registration.has("serviceId"), hasAddress = registration.has("publicAddress"); + if (hasService != hasAddress) throw new IOException("Incomplete public endpoint metadata"); + if (hasService) for (String field : List.of("serviceId", "publicAddress")) { + JsonElement value = registration.get(field); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) throw new IOException("Invalid public endpoint metadata"); + } if (!origin.equals(registration.get("provider").getAsString()) || !config.profile().equals(registration.get("profile").getAsString())) throw new IOException("Registration provider or profile changed"); JsonObject placement = registration.getAsJsonObject("placement"); String expectedRegion = config.region() == null ? "" : config.region(), expectedPool = config.pool() == null ? "" : config.pool(); @@ -258,8 +273,10 @@ private boolean statusChanged() { ServerStatus status = currentStatus(); Health health = healthSupplier.get(); return !Objects.equals(status, lastReportedStatus) || lastReportedHealth == null || health.healthy() != lastReportedHealth.healthy() || health.capacity() != lastReportedHealth.capacity() + || !Objects.equals(connectedPlayers(health), connectedPlayers(lastReportedHealth)) || !Objects.equals(health.protocolVersion(), lastReportedHealth.protocolVersion()) || !Objects.equals(health.build(), lastReportedHealth.build()); } + private static Integer connectedPlayers(Health health) { return health.playerCount() == null ? null : health.playerCount().connectedPlayers(); } private void heartbeat() throws Exception { // Key delivery and application acknowledgements can need an immediate second exchange. for (int exchange = 0; exchange < 3; exchange++) { @@ -279,6 +296,7 @@ private void heartbeat() throws Exception { Health health = healthSupplier.get(); body.addProperty("healthy", health.healthy() && installedKeyId != null && hostState.equals("serving")); body.addProperty("capacity", health.capacity()); body.addProperty("load", health.load()); + if (health.playerCount() != null) body.add("playerCount", JSON.toJsonTree(health.playerCount())); body.addProperty("protocolVersion", health.protocolVersion()); body.addProperty("build", health.build()); if (config.region() != null) body.addProperty("region", config.region()); snapshotClock = Math.max(System.currentTimeMillis(), snapshotClock + 1); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java index 5ddf96f8..c1eeee49 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java @@ -22,6 +22,7 @@ public final class IndependentProviderStub implements AutoCloseable { volatile int controlPolls; final java.util.List events = new java.util.concurrent.CopyOnWriteArrayList<>(); long generation, sequence; + volatile boolean poolOnlyRegistration; volatile int registrations, heartbeats, acknowledgements; volatile String challengeAuthorization; volatile int challengeDifficulty = -1; @@ -83,9 +84,9 @@ private JsonObject dispatch(HttpExchange e) throws Exception { String proof = ProviderCrypto.proof(c, body.get("proofNonce").getAsString(), body.get("idempotencyKey").getAsString()); if (!ProviderCrypto.verify(keys.get(id), body.get("signature").getAsString(), proof) || !ProviderCrypto.meetsDifficulty(ProviderCrypto.digest(proof), c.getAsJsonObject("pow").get("difficulty").getAsInt())) throw new Failure(401, "proof_invalid"); challenges.remove(id); generation++; sequence = 0; draining = false; appliedRevision = 0; profileRevision = 0; - if (c.getAsJsonObject("context").get("mode").getAsString().equals("recover")) { JsonObject r = registration.deepCopy(); r.remove("ticketKey"); r.addProperty("leaseGeneration", generation); return r; } + if (c.getAsJsonObject("context").get("mode").getAsString().equals("recover")) { JsonObject r = registration.deepCopy(); r.remove("ticketKey"); if (poolOnlyRegistration) { r.remove("serviceId"); r.remove("publicAddress"); } r.addProperty("leaseGeneration", generation); return r; } if (registration != null) throw new Failure(409, "already_registered"); registrations++; - registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", generation); registration.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); if (loseCompletionResponse) { loseCompletionResponse = false; e.close(); throw new Failure(503, "completion_response_lost"); } return registration.deepCopy(); + registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); if (!poolOnlyRegistration) registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); if (!poolOnlyRegistration) registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", generation); registration.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); if (loseCompletionResponse) { loseCompletionResponse = false; e.close(); throw new Failure(503, "completion_response_lost"); } return registration.deepCopy(); } if (path.equals("/example/heartbeat") && failHeartbeats-- > 0) throw new Failure(503, "fixture_transient"); authenticate(e, raw); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java index 6310ee4e..bba1571c 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java @@ -39,7 +39,7 @@ public CompletionStage hostProfile() { JsonObject registration = client.start().get(30, TimeUnit.SECONDS); String extensionsFile = System.getProperty("providerExtensionsFile"); if (extensionsFile != null) ExtensionFixtureFile.write(Path.of(extensionsFile), client.extensions().get(10, TimeUnit.SECONDS)); - System.out.println("instance=" + registration.get("instanceId").getAsString() + " service=" + registration.get("serviceId").getAsString()); + System.out.println("instance=" + registration.get("instanceId").getAsString() + " service=" + (registration.has("serviceId") ? registration.get("serviceId").getAsString() : "unassigned")); JsonObject readiness = client.readiness().get(10, TimeUnit.SECONDS); readiness.remove("extensions"); System.out.println(readiness); long hold = Long.parseLong(System.getProperty("providerHoldSeconds", "0")); String stopFile = System.getProperty("providerStopFile"); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java index 00165525..2b2a4da1 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java @@ -8,9 +8,63 @@ import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; class ProviderClientTest { + @Test void poolAttachmentsAndRemovedPublicEndpointsPreserveRuntimeIdentity(@TempDir Path path) throws Exception { + for (boolean standalone : List.of(false, true)) { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.poolOnlyRegistration = !standalone; + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Fleet host", + standalone ? ProviderClient.NEW_SERVICE : ProviderClient.ATTACH_INSTANCE, ProviderClient.BEARER_TOKEN, + "independent-provider-token", standalone ? null : "EU", standalone ? null : "proxy", Map.of()); + JsonObject first = null; + for (int generation = 1; generation <= 2; generation++) { + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path.resolve(standalone ? "standalone" : "pool")), new FakeTransport(), () -> null, + () -> new ProviderClient.Health(true, 20, 0, "nethernet", "fixture"), message -> {}); + try { + JsonObject current = client.start().get(20, TimeUnit.SECONDS); + assertEquals(standalone && generation == 1, current.has("serviceId")); + assertEquals(standalone && generation == 1, current.has("publicAddress")); + assertEquals(generation, current.get("leaseGeneration").getAsInt()); + if (first == null) first = current; + else for (String field : List.of("instanceId", "registrationId")) assertEquals(first.get(field), current.get(field)); + assertFalse(stub.lastHeartbeat.has("playerCount"), "Missing runtime telemetry is unknown, not zero"); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + stub.poolOnlyRegistration = true; + } + assertEquals(1, stub.registrations); + } + } + } + + @Test void runtimeCountsWakeCheckInsWithoutChangingPublicTotalsAndRemainCountedDuringDrain(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.checkInMillis = 900000; + AtomicReference sample = new AtomicReference<>(new ProviderClient.PlayerCount(3, System.currentTimeMillis())); + var transport = new FakeTransport(); transport.stateless = true; + ProviderClient client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Counts"), + new ProviderStateStore(path), transport, () -> new ServerStatus("Global listing", 1234, "fixture", "world", 25000, 30000, 0), + () -> new ProviderClient.Health(true, 20, .9, "nethernet", "fixture", sample.get()), message -> {}); + try { + client.start().get(20, TimeUnit.SECONDS); + assertEquals(3, stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt()); + assertEquals(sample.get().sampledAt(), stub.lastHeartbeat.getAsJsonObject("playerCount").get("sampledAt").getAsLong()); + assertEquals(20, stub.lastHeartbeat.get("capacity").getAsInt()); + assertEquals(25000, stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt()); + int before = stub.heartbeats; + sample.set(new ProviderClient.PlayerCount(3, System.currentTimeMillis())); client.requestStatusRefresh(); + Thread.sleep(1200); assertEquals(before, stub.heartbeats, "Timestamp-only changes use the ordinary schedule"); + sample.set(new ProviderClient.PlayerCount(4, System.currentTimeMillis())); client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt() == 4); + assertEquals(25000, stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt()); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + assertTrue(stub.draining); + assertEquals(4, stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt()); + } + } + @Test void usesProviderNeutralBearerAuthorizationWithoutPowOrPersistingTheToken(@TempDir Path path) throws Exception { try (IndependentProviderStub stub = new IndependentProviderStub()) { var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Hosted customer", diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java index 0c8a2379..8937785f 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java @@ -52,7 +52,10 @@ public static void main(String[] args) throws Exception { JsonObject registration = provider.start().get(45, TimeUnit.SECONDS); if (args.length > 4) ExtensionFixtureFile.write(Path.of(args[4]), provider.extensions().get(10, TimeUnit.SECONDS)); // Emit assigned IDs only; optional metadata and credentials are excluded. - emit("registered", Map.of("serviceId", registration.get("serviceId").getAsString(), "instanceId", registration.get("instanceId").getAsString())); + var assignedIds = new java.util.LinkedHashMap(); + assignedIds.put("instanceId", registration.get("instanceId").getAsString()); + if (registration.has("serviceId")) assignedIds.put("serviceId", registration.get("serviceId").getAsString()); + emit("registered", assignedIds); emit("profile", nativeHost.hostProfile().toCompletableFuture().get()); JsonObject readiness = provider.readiness().get(10, TimeUnit.SECONDS); readiness.remove("extensions"); emit("readiness", readiness); long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(3); From fe09633206d09a7d03bece25112689009b3ee693 Mon Sep 17 00:00:00 2001 From: Zulu Date: Mon, 7 Sep 2026 22:58:18 +0100 Subject: [PATCH 14/15] test: align fleet public status example with canonical field names --- docs/external-signalling/fixtures.mjs | 4 +++- docs/external-signalling/nxs-v1.fixtures.json | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/external-signalling/fixtures.mjs b/docs/external-signalling/fixtures.mjs index 7e195a0c..29f9a44c 100644 --- a/docs/external-signalling/fixtures.mjs +++ b/docs/external-signalling/fixtures.mjs @@ -50,7 +50,6 @@ assert.deepEqual(v.expected,expected); const provenance = {specification:'urn:nethernet:external-signalling:v1', files:Object.fromEntries(['stateless-admission-v1.fixtures.json','cloudburst-protocol-vectors.v1.json'].map(name => [name,digest(readFileSync(path(name))).toString('hex')]))}; if (update) write('provenance.json',provenance); assert.deepEqual(read('provenance.json'),provenance); -console.log('NXS canonical signing, stateless encryption, and fixture hashes verified.'); // Fleet examples keep public endpoint metadata optional and runtime counts independent. const schema = read('nxs-v1.schema.json'); @@ -65,3 +64,6 @@ assert.equal(schema.$defs.heartbeat.properties.playerCount.$ref, '#/$defs/player assert.equal(f.fleetExamples.heartbeat.playerCount.connectedPlayers, 3); assert.equal(f.fleetExamples.heartbeat.serverStatus.players, 25000); assert(f.fleetExamples.heartbeat.playerCount.sampledAt <= f.fleetExamples.heartbeat.clockUnixMillis); + +assert.deepEqual(Object.keys(f.fleetExamples.heartbeat.serverStatus).sort(), ['name','protocol','version','level','players','maxPlayers','gameType'].sort()); +console.log('NXS canonical signing, stateless encryption, fleet examples, and fixture hashes verified.'); diff --git a/docs/external-signalling/nxs-v1.fixtures.json b/docs/external-signalling/nxs-v1.fixtures.json index 1ca6218c..891f6bff 100644 --- a/docs/external-signalling/nxs-v1.fixtures.json +++ b/docs/external-signalling/nxs-v1.fixtures.json @@ -112,12 +112,12 @@ }, "serverStatus": { "name": "Network listing", - "networkId": 1234, - "levelName": "world", "version": "fixture", "players": 25000, "maxPlayers": 30000, - "gameType": 0 + "gameType": 0, + "protocol": 1234, + "level": "world" } } } From f34c0b0a37cd5933ac7f5d46febc3a92a6aad381 Mon Sep 17 00:00:00 2001 From: Zulu Date: Tue, 8 Sep 2026 13:57:53 +0100 Subject: [PATCH 15/15] Select NXS registration mode from provider credential authority --- docs/external-signalling/README.md | 5 +++++ docs/external-signalling/nxs-v1.schema.json | 2 ++ docs/external-signalling/wire-reference.md | 15 +++++++++++-- .../netty/signalling/ProviderClient.java | 11 +++++++--- .../signalling/IndependentProviderStub.java | 8 ++++--- .../signalling/ProviderJourneysTest.java | 21 +++++++++++++++++-- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md index 3d3ab699..e9446fb8 100644 --- a/docs/external-signalling/README.md +++ b/docs/external-signalling/README.md @@ -110,3 +110,8 @@ request signature. URLs come from discovery. Machine-key maintenance is separate from admission-key updates. Exact signing, request fields, key handling, token layout, bounds and retries are in the [wire reference](wire-reference.md). + +Hosts can request automatic registration: the provider uses token authority to +choose account provisioning or attachment. Anonymous hosts create new services. +Geyser exposes only signalling mode, advertised endpoints, token, provider origin +and registration metadata; see the [Geyser configuration](https://github.com/teamziax/GeyserNetherNet/blob/nxs-dev/PROVIDER.md). diff --git a/docs/external-signalling/nxs-v1.schema.json b/docs/external-signalling/nxs-v1.schema.json index 36ce7314..70df733d 100644 --- a/docs/external-signalling/nxs-v1.schema.json +++ b/docs/external-signalling/nxs-v1.schema.json @@ -15,6 +15,7 @@ }, "mode": { "enum": [ + "automatic", "new-service", "attach-instance" ] @@ -172,6 +173,7 @@ "type": "array", "items": { "enum": [ + "automatic", "new-service", "attach-instance" ] diff --git a/docs/external-signalling/wire-reference.md b/docs/external-signalling/wire-reference.md index 06811c71..20eeaaf5 100644 --- a/docs/external-signalling/wire-reference.md +++ b/docs/external-signalling/wire-reference.md @@ -43,8 +43,8 @@ entry has a `scheme` and its supported `modes`: | Scheme | Allowed modes | | --- | --- | -| `anonymous-proof-of-work` | `new-service` | -| `bearer-token` | `new-service`, `attach-instance`, or both | +| `anonymous-proof-of-work` | `automatic`, `new-service` | +| `bearer-token` | `automatic`, `new-service`, `attach-instance` | A provider need only advertise the schemes it accepts. It decides how tokens are issued, what they authorize, and whether they can be reused. Every flow also @@ -87,6 +87,17 @@ If saving state fails, stop advertising healthy readiness. The request contains `protocol`, `mode`, `profile`, `publicKeyJwk`, explicit `authorization: {scheme}`, and optional `label` and `placement`. +`mode: "automatic"` lets the provider select `new-service` or `attach-instance` +from the credential's authority. Without a bearer token it can only select +`new-service`. Discovery must advertise automatic support for the selected scheme. +The challenge contains the selected concrete mode, bound into its digest and proof; +hosts reject unknown modes and anonymous attachment. Opaque token contents are never +parsed by the host. Explicit modes remain available to protocol integrations. + +Metadata may be supplied on anonymous new-service registration when permitted by +the provider. It applies only to the new service and cannot authorize attachment. +Placement is still echoed and digest-bound, including every tag. + Send a bearer credential only to the enrollment `register` operation, in `Authorization: Bearer `. It MUST NOT appear in JSON, proofs, saved state, or logs. `attach-instance` requires both bearer authorization and placement. diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java index 56764e8f..b14d2729 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java @@ -13,6 +13,7 @@ /** One asynchronous, serialized control lifecycle per backend, never one poller per player. */ public final class ProviderClient implements AutoCloseable { + public static final String AUTOMATIC = "automatic"; public static final String NEW_SERVICE = "new-service", ATTACH_INSTANCE = "attach-instance"; public static final String ANONYMOUS_PROOF_OF_WORK = "anonymous-proof-of-work", BEARER_TOKEN = "bearer-token"; public record Configuration(URI provider, String profile, String label, String registrationMode, String authorizationScheme, @@ -23,10 +24,10 @@ public record Configuration(URI provider, String profile, String label, String r ProviderCrypto.origin(provider); if (region != null && (!region.matches("[A-Za-z0-9_-]{1,32}") || pool == null || !pool.matches("[A-Za-z0-9_-]{1,64}"))) throw new IllegalArgumentException("Invalid placement"); tags = tags == null ? Map.of() : Collections.unmodifiableMap(new TreeMap<>(tags)); - if (!Set.of(NEW_SERVICE, ATTACH_INSTANCE).contains(registrationMode)) throw new IllegalArgumentException("Invalid provider registration mode"); + if (!Set.of(AUTOMATIC, NEW_SERVICE, ATTACH_INSTANCE).contains(registrationMode)) throw new IllegalArgumentException("Invalid provider registration mode"); if (!Set.of(ANONYMOUS_PROOF_OF_WORK, BEARER_TOKEN).contains(authorizationScheme)) throw new IllegalArgumentException("Invalid provider authorization scheme"); if ((BEARER_TOKEN.equals(authorizationScheme)) != (authorizationToken != null && !authorizationToken.isBlank())) throw new IllegalArgumentException("Bearer authorization requires exactly one token"); - if (ANONYMOUS_PROOF_OF_WORK.equals(authorizationScheme) && !NEW_SERVICE.equals(registrationMode)) throw new IllegalArgumentException("Anonymous proof of work can only create a service"); + if (ANONYMOUS_PROOF_OF_WORK.equals(authorizationScheme) && !Set.of(AUTOMATIC, NEW_SERVICE).contains(registrationMode)) throw new IllegalArgumentException("Anonymous proof of work can only create a service"); if (ATTACH_INSTANCE.equals(registrationMode) && (region == null || region.isBlank() || pool == null || pool.isBlank())) throw new IllegalArgumentException("Attached instances require region and pool"); if ((region == null) != (pool == null) || (!tags.isEmpty() && region == null)) throw new IllegalArgumentException("Provider placement requires region and pool together"); if (tags.size() > 16 || tags.entrySet().stream().anyMatch(e -> !e.getKey().matches("[A-Za-z0-9_.-]{1,32}") || e.getValue() == null || !e.getValue().equals(e.getValue().trim()) || e.getValue().isEmpty() || e.getValue().length() > 64 || e.getValue().codePoints().anyMatch(c -> c < 32 || c == 127))) throw new IllegalArgumentException("Invalid provider placement tags"); @@ -190,7 +191,7 @@ private void enroll() throws Exception { ProviderContract.require("challenge", challenge); if (!ProviderCrypto.PROTOCOL.equals(challenge.get("protocol").getAsString()) || !ProviderCrypto.SIGNATURE.equals(challenge.get("signature").getAsString()) || !origin.equals(challenge.get("audience").getAsString()) || !ProviderCrypto.thumbprint(state.getAsJsonObject("publicKeyJwk")).equals(challenge.get("thumbprint").getAsString()) || !ProviderCrypto.contextDigest(challenge.getAsJsonObject("context")).equals(challenge.get("contextDigest").getAsString())) throw new IOException("Unbound registration challenge"); JsonObject context = challenge.getAsJsonObject("context"); - if (!config.profile().equals(context.get("profile").getAsString()) || !config.registrationMode().equals(context.get("mode").getAsString())) throw new IOException("Challenge registration context changed"); + if (!config.profile().equals(context.get("profile").getAsString()) || !acceptsRegistrationMode(context.get("mode").getAsString())) throw new IOException("Challenge registration context changed"); String expectedTagsDigest = ProviderCrypto.tagsDigest(config.tags()); if (config.region() == null) { if (!context.get("region").getAsString().isEmpty() || !context.get("pool").getAsString().isEmpty() || context.has("tagsDigest")) throw new IOException("Challenge placement changed"); @@ -213,6 +214,10 @@ private void enroll() throws Exception { if (registration.has("ticketKey")) { state.getAsJsonArray("ticketKeys").add(registration.remove("ticketKey")); } save(); } + private boolean acceptsRegistrationMode(String selected) { + if (!AUTOMATIC.equals(config.registrationMode())) return config.registrationMode().equals(selected); + return NEW_SERVICE.equals(selected) || (BEARER_TOKEN.equals(config.authorizationScheme()) && ATTACH_INSTANCE.equals(selected)); + } private void validateRegistration(JsonObject registration) throws IOException { ProviderContract.require("registration", registration); ProtocolExtensions.validate(registration); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java index 5ddf96f8..487cea8f 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java @@ -24,6 +24,7 @@ public final class IndependentProviderStub implements AutoCloseable { long generation, sequence; volatile int registrations, heartbeats, acknowledgements; volatile String challengeAuthorization; + volatile String selectedMode; volatile int challengeDifficulty = -1; volatile JsonObject extensionMetadata; volatile int extensionRequests, keyAcknowledgements; @@ -49,12 +50,12 @@ private JsonObject dispatch(HttpExchange e) throws Exception { JsonObject body = raw.isEmpty() ? new JsonObject() : JsonParser.parseString(raw).getAsJsonObject(); if (path.equals("/.well-known/nethernet-external-signalling")) { JsonObject d = new JsonObject(); d.addProperty("provider", origin); d.addProperty("controlOrigin", origin); - d.add("protocols", strings(ProviderCrypto.PROTOCOL)); d.add("signatures", strings(ProviderCrypto.SIGNATURE)); d.add("modes", strings("new-service", "attach-instance")); d.add("profiles", strings("nxs-admission-v1")); + d.add("protocols", strings(ProviderCrypto.PROTOCOL)); d.add("signatures", strings(ProviderCrypto.SIGNATURE)); d.add("modes", strings("automatic", "new-service", "attach-instance")); d.add("profiles", strings("nxs-admission-v1")); JsonObject operations = new JsonObject(); for (String op : List.of("register", "complete", "heartbeat", "outcomes", "rotate", "retire", "deregister")) operations.addProperty(op, origin + "/example/" + op); if (extensionMetadata != null) d.add("extensions", extensionMetadata.deepCopy()); d.add("operations", operations); JsonObject limits = new JsonObject(); limits.addProperty("heartbeatIntervalMs", 1000); if (checkInMillis > 0) limits.addProperty("checkInVersion", 1); limits.addProperty("leaseMs", 30000); limits.addProperty("maxBodyBytes", 65536); limits.addProperty("clockSkewMs", 60000); d.add("limits", limits); JsonObject authorization = new JsonObject(); authorization.addProperty("header", "Authorization"); JsonArray schemes = new JsonArray(); - schemes.add(authorizationScheme("anonymous-proof-of-work", "new-service")); schemes.add(authorizationScheme("bearer-token", "new-service", "attach-instance")); authorization.add("schemes", schemes); d.add("authorization", authorization); return d; + schemes.add(authorizationScheme("anonymous-proof-of-work", "automatic", "new-service")); schemes.add(authorizationScheme("bearer-token", "automatic", "new-service", "attach-instance")); authorization.add("schemes", schemes); d.add("authorization", authorization); return d; } operationsSeen.add(path); if (path.equals("/example/register")) { @@ -69,9 +70,10 @@ private JsonObject dispatch(HttpExchange e) throws Exception { if (!"Bearer independent-provider-token".equals(challengeAuthorization)) throw new Failure(401, "invalid_bearer_token"); } JsonObject c = new JsonObject(); c.addProperty("protocol", ProviderCrypto.PROTOCOL); c.addProperty("signature", ProviderCrypto.SIGNATURE); c.addProperty("challengeId", UUID.randomUUID().toString()); c.addProperty("audience", origin); c.addProperty("nonce", UUID.randomUUID().toString()); c.addProperty("thumbprint", ProviderCrypto.thumbprint(key)); c.addProperty("expiresAt", System.currentTimeMillis() + 60000); c.addProperty("serverTime", System.currentTimeMillis()); - JsonObject context = new JsonObject(); for (String f : List.of("label", "authorizationId", "serviceId", "region", "pool", "registrationId")) context.addProperty(f, ""); context.addProperty("mode", recovery ? "recover" : body.get("mode").getAsString()); context.addProperty("profile", "nxs-admission-v1"); if (recovery) context.add("registrationId", body.get("registrationId")); + JsonObject context = new JsonObject(); for (String f : List.of("label", "authorizationId", "serviceId", "region", "pool", "registrationId")) context.addProperty(f, ""); context.addProperty("mode", recovery ? "recover" : body.get("mode").getAsString()); if (!recovery && "automatic".equals(context.get("mode").getAsString())) context.addProperty("mode", authorization.equals("bearer-token") && body.has("placement") ? "attach-instance" : "new-service"); context.addProperty("profile", "nxs-admission-v1"); if (recovery) context.add("registrationId", body.get("registrationId")); if (!recovery && authorization.equals("bearer-token")) { context.addProperty("authorizationId", "independent-authority"); JsonObject selected = new JsonObject(); selected.addProperty("scheme", authorization); selected.addProperty("reference", "independent-authority"); c.add("authorization", selected); } if (!recovery && body.has("placement")) { JsonObject placement = body.getAsJsonObject("placement"); context.add("region", placement.get("region")); context.add("pool", placement.get("pool")); if (placement.has("tags")) { Map tags = new TreeMap<>(); for (var tag : placement.getAsJsonObject("tags").entrySet()) tags.put(tag.getKey(), tag.getValue().getAsString()); context.addProperty("tagsDigest", ProviderCrypto.tagsDigest(tags)); } } + if (!recovery && selectedMode != null) context.addProperty("mode", selectedMode); c.add("context", context); c.addProperty("contextDigest", ProviderCrypto.contextDigest(context)); JsonObject pow = new JsonObject(); pow.addProperty("algorithm", "sha256-leading-zero-bits-v0"); challengeDifficulty = recovery || authorization.equals("bearer-token") ? 0 : 2; pow.addProperty("difficulty", challengeDifficulty); c.add("pow", pow); challenges.put(c.get("challengeId").getAsString(), c.deepCopy()); keys.put(c.get("challengeId").getAsString(), key); if (!recovery && body.has("placement")) placements.put(c.get("challengeId").getAsString(), body.getAsJsonObject("placement").deepCopy()); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java index dd625ca2..f80e703f 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java @@ -17,14 +17,16 @@ private static ProviderClient client(IndependentProviderStub stub, Path director () -> new ProviderClient.Health(true, 100, .01, "nethernet", "fixture"), message -> {}); } - @Test void allFourOperatorJourneysUseOneNeutralLifecycle(@TempDir Path directory) throws Exception { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void allFourOperatorJourneysUseOneNeutralLifecycle(boolean automatic, @TempDir Path directory) throws Exception { String[] journeys = {"anonymous-standalone", "token-new-service", "token-fleet-attachment", "custom-host-provider"}; for (String journey : journeys) { try (IndependentProviderStub stub = new IndependentProviderStub()) { boolean bearer = !journey.equals("anonymous-standalone"); boolean attach = journey.equals("token-fleet-attachment"); var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", journey, - attach ? ProviderClient.ATTACH_INSTANCE : ProviderClient.NEW_SERVICE, + automatic ? ProviderClient.AUTOMATIC : attach ? ProviderClient.ATTACH_INSTANCE : ProviderClient.NEW_SERVICE, bearer ? ProviderClient.BEARER_TOKEN : ProviderClient.ANONYMOUS_PROOF_OF_WORK, bearer ? "independent-provider-token" : null, attach ? "EU" : null, attach ? "proxy" : null, attach ? Map.of("location", "london", "role", "proxy") : Map.of()); @@ -52,6 +54,21 @@ private static ProviderClient client(IndependentProviderStub stub, Path director } } + @Test void automaticRejectsUnknownModesAndAnonymousAttachment(@TempDir Path directory) throws Exception { + for (String selected : new String[]{"automatic", "unknown", "attach-instance"}) { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.selectedMode = selected; + var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Mode test", + ProviderClient.AUTOMATIC, ProviderClient.ANONYMOUS_PROOF_OF_WORK, null, null, null, Map.of()); + ProviderClient instance = client(stub, directory.resolve(selected), configuration, new ProviderClientTest.FakeTransport()); + try { + assertThrows(java.util.concurrent.ExecutionException.class, () -> instance.start().get(10, TimeUnit.SECONDS)); + assertFalse(stub.operationsSeen.contains("/example/complete")); + } finally { instance.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + } + @Test void profileMigrationPreservesDurableIdentityAndAssignedIds(@TempDir Path directory) throws Exception { try (IndependentProviderStub stub = new IndependentProviderStub()) { var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Migration");