diff --git a/external-signalling/README.md b/external-signalling/README.md index 9ec8fd2..4e5ac6f 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 0000000..553dde3 --- /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 1ad5c25..6ff1eae 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 0000000..6fccce0 --- /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 ba45220..7830e71 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");