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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions external-signalling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<List<InetSocketAddress>> advertisedAddresses;
private final ScheduledFuture<?> retireTask;
private List<Epoch> 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<List<InetSocketAddress>> 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. */
Expand All @@ -35,10 +36,13 @@ public static CompletionStage<NativeProviderTransport> open(ServerBootstrap boot
}
/** Explicit advertised candidate supports wildcard/local binds and operator-provisioned NAT mappings. */
public static CompletionStage<NativeProviderTransport> 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<NativeProviderTransport> open(ServerBootstrap bootstrap, InetSocketAddress bind, Supplier<List<InetSocketAddress>> advertised, Path certificate, Path privateKey, AdmissionGate.Limits limits) {
CompletableFuture<NativeProviderTransport> 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);
Expand All @@ -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<InetSocketAddress> 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<InetSocketAddress> checkedEndpoints(List<InetSocketAddress> endpoints) {
List<InetSocketAddress> 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<Void> installTicketKeys(List<TicketKey> keys) {
if (closed) return CompletableFuture.failedFuture(new IllegalStateException("Native endpoint closed"));
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading