Skip to content
This repository was archived by the owner on Sep 11, 2026. It is now read-only.
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
52 changes: 44 additions & 8 deletions PROVIDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,49 @@ The directory and key use owner-only permissions. Identity creation needs no
external OpenSSL command. Existing machine identity, registration and ticket-key
state retain their paths and values.

The default binds UDP `0.0.0.0:19133`. A concrete bind address is also the default
advertised candidate. With a wildcard bind, the extension selects an address only
when exactly one suitable interface exists. A multihomed host must set
`provider.advertised-address` or `NETHERNET_PROVIDER_ADVERTISED_ADDRESS` explicitly.
Set `advertised-port` when external forwarding differs from `udp-port`; arranging
that forwarding and choosing an address reachable by clients remain host tasks.
Wildcard addresses are never published as candidates. Keep this UDP endpoint
separate from Geyser's RakNet listener.
The default binds UDP `0.0.0.0:19133` and advertises suitable IPv4 addresses from
every active interface, including VPN interfaces. A suitable concrete bind advertises
that address only. Configure `bind-address: '::'` for the pinned native transport's
dual-stack wildcard listener: it advertises suitable IPv4 and IPv6 addresses.
An explicit IPv6 address binds only that address. Interface discovery is refreshed
on background provider check-ins; it does not send network probes.

Use `provider.advertised-endpoints` for external IP/port pairs supplied by a host's
forwarding configuration. These are added to the bound addresses. Each entry has
an `address` containing a numeric IPv4 or IPv6 literal, and a `port`; omitted or
zero ports reuse `udp-port`. Brackets and interface scope suffixes are not used in
the address field. For example, replace both documentation IPs with your own:

```yaml
provider:
bind-address: '::'
udp-port: 19133
advertised-endpoints:
- address: '203.0.113.10' # Replace with your actual public IPv4.
port: 29133
- address: '2001:db8::10' # Replace with your actual public IPv6.
port: 39133
```

The legacy `advertised-address`/`advertised-port` settings remain supported as an
additional endpoint. A legacy port override without an address adds that port for
each discovered bind address. Configured forwarders may translate address families;
operators must arrange the forwarding themselves. A proxy may forward an external
endpoint to a loopback bind; only the usable external endpoint is advertised in that
case. Configuration changes take effect
after restart, while interface changes on a wildcard listener are rediscovered.

Endpoints are deduplicated and limited to 32. If the combined set exceeds that limit,
use a concrete bind or fewer explicit mappings. Wildcard, link-local, multicast,
loopback, documentation and other unusable addresses are not published to external
providers. Explicit loopback/documentation fixtures are permitted only when the
provider itself is local. Keep NetherNet UDP separate from Geyser's RakNet listener.

Startup warns when all advertised endpoints are private/shared, or when all are
IPv6. Private/shared addresses require client LAN, VPN or routed connectivity;
Warden does not relay game traffic. IPv6-only endpoints cannot serve IPv4-only
clients. These warnings use the combined bound and external endpoint set, so a
public forwarded address avoids a misleading warning about a private local bind.

Existing `warden-admission-v1` configuration profiles migrate to
`nxs-admission-v1`; configured URLs, state directories, keys, tokens and endpoints
Expand Down Expand Up @@ -90,6 +125,7 @@ For managed environments, these provider-neutral variables override YAML:
| `NETHERNET_PROVIDER_STATE_DIRECTORY` | Durable private state path |
| `NETHERNET_PROVIDER_BIND_ADDRESS`, `NETHERNET_PROVIDER_UDP_PORT` | Native UDP bind |
| `NETHERNET_PROVIDER_ADVERTISED_ADDRESS`, `NETHERNET_PROVIDER_ADVERTISED_PORT` | Reachable UDP candidate; advertised port 0 reuses the bind port |
| `NETHERNET_PROVIDER_ADVERTISED_ENDPOINTS` | JSON array of `{"address":"IP","port":29133}` objects; overrides the YAML list |
| `NETHERNET_PROVIDER_CAPACITY` | Routing capacity, separate from player count |

Environment token value takes precedence over environment token file, which takes
Expand Down
2 changes: 1 addition & 1 deletion registration-network.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
repository=teamziax/NetworkCompatible
commit=b531b33971b71bb78e4560229c8ae828aed787a2
commit=a6e73dfa9061fa7fcd35d61d221441371b5b6ca2
branch=nxs-dev
11 changes: 10 additions & 1 deletion src/main/java/org/geyser/extension/nethernet/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Comment;
import java.util.Map;
import java.util.List;

@ConfigSerializable
public interface Config {
Expand Down Expand Up @@ -35,10 +36,12 @@ interface ProviderConfig {
@Comment("Separate UDP endpoint from the RakNet listener")
@DefaultString("0.0.0.0") String bindAddress();
default int udpPort() { return 19133; }
@Comment("Reachable UDP candidate address. Empty uses a concrete bind or the only suitable interface; set explicitly on multihomed/NAT hosts")
@Comment("Legacy single external IP. Added to bound endpoints; advertised-endpoints supports multiple external IP/port pairs")
@DefaultString("") String advertisedAddress();
@Comment("Reachable UDP candidate port; 0 uses udp-port. Configure forwarding separately when using NAT")
default int advertisedPort() { return 0; }
@Comment("Additional external UDP endpoints for port forwarding. Each entry has address (numeric IP) and port (0 reuses udp-port)")
default List<AdvertisedEndpointConfig> advertisedEndpoints() { return List.of(); }
@Comment("Routing admission capacity; independent of advertised maxPlayers")
default int capacity() { return 100; }
@Comment("World name when unavailable from the query response")
Expand All @@ -49,6 +52,12 @@ interface ProviderConfig {
@DefaultBoolean(false) boolean fakeTransport();
}

@ConfigSerializable
interface AdvertisedEndpointConfig {
String address();
default int port() { return 0; }
}

@Comment("HTTPS settings")
HttpsConfig https();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,11 @@ private void startProvider() {
eventLoopGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
ServerBootstrap bootstrap = new ServerBootstrap().group(eventLoopGroup).childHandler(new NetherNetChannelInitialiser(GeyserImpl.getInstance(), gameOutcomes));
ProviderHostFactory.Host host = factory.open(bootstrap, new InetSocketAddress(runtime.bindAddress(), runtime.udpPort()), Map.of("stateDirectory", statePath.toAbsolutePath().toString(), "profile", runtime.profile(),
"advertisedAddress", runtime.advertisedAddress(), "advertisedPort", Integer.toString(runtime.advertisedPort()))).toCompletableFuture().get(30, java.util.concurrent.TimeUnit.SECONDS);
"advertisedEndpoints", runtime.encodedAdvertisedEndpoints(),
"legacyAdvertisedPort", Integer.toString(runtime.advertisedAddress().isBlank() ? runtime.advertisedPort() : 0),
"localDevelopment", Boolean.toString(java.util.Set.of("127.0.0.1", "localhost", "[::1]").contains(origin.getHost())))).toCompletableFuture().get(30, java.util.concurrent.TimeUnit.SECONDS);
netherNetChannel = host.channel(); transport = host.transport();
host.warnings().forEach(message -> logger().warning(message));
if (stopping) { transport.close(); netherNetChannel.close(); eventLoopGroup.shutdownGracefully(); return; }
}
initializingTransport = transport;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
import java.net.InetSocketAddress;
import java.nio.file.Path;
import java.util.Map;
import java.util.ArrayList;
import java.util.List;
import java.io.UncheckedIOException;
import org.cloudburstmc.netty.signalling.admission.EndpointAddress;
import com.google.gson.JsonParser;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

Expand All @@ -17,12 +22,21 @@ public final class NativeProviderHostFactory implements ProviderHostFactory {
String directory = options.get("stateDirectory");
if (directory == null || directory.isBlank()) throw new IllegalArgumentException("Provider stateDirectory required");
Path state = Path.of(directory);
ProviderEndpoint endpoint = ProviderEndpoint.resolve(udpBind, options.get("advertisedAddress"),
Integer.parseInt(options.getOrDefault("advertisedPort", "0")));
List<InetSocketAddress> external = new ArrayList<>();
for (var value : JsonParser.parseString(options.getOrDefault("advertisedEndpoints", "[]")).getAsJsonArray()) {
var address = value.getAsJsonObject();
external.add(new InetSocketAddress(EndpointAddress.parse(address.get("address").getAsString()), address.get("port").getAsInt()));
}
boolean localDevelopment = Boolean.parseBoolean(options.getOrDefault("localDevelopment", "false"));
int legacyAdvertisedPort = Integer.parseInt(options.getOrDefault("legacyAdvertisedPort", "0"));
ProviderEndpoint endpoint = ProviderEndpoint.resolve(udpBind, external, localDevelopment, legacyAdvertisedPort);
var identity = ProviderHostIdentity.ensure(state);
return NativeProviderTransport.open(bootstrap, endpoint.bind(), endpoint.advertised(),
return NativeProviderTransport.open(bootstrap, endpoint.bind(), () -> {
try { return ProviderEndpoint.resolve(udpBind, external, localDevelopment, legacyAdvertisedPort).advertised(); }
catch (java.io.IOException unavailable) { throw new UncheckedIOException(unavailable); }
},
identity.certificate(), identity.privateKey(), AdmissionGate.Limits.defaults())
.thenApply(transport -> new Host(transport, transport.channel()));
.thenApply(transport -> new Host(transport, transport.channel(), endpoint.warnings()));
} catch (Exception invalid) { return CompletableFuture.failedFuture(invalid); }
}
}
Original file line number Diff line number Diff line change
@@ -1,50 +1,76 @@
package org.geyser.extension.nethernet.admission;

import org.cloudburstmc.netty.signalling.admission.EndpointAddress;
import java.io.IOException;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/** Keeps the local socket binding separate from the candidate reachable by clients. */
public record ProviderEndpoint(InetSocketAddress bind, InetSocketAddress advertised) {
public static ProviderEndpoint resolve(InetSocketAddress bind, String advertisedAddress, int advertisedPort) throws IOException {
boolean selectInterface = (advertisedAddress == null || advertisedAddress.isBlank())
&& !bind.isUnresolved() && bind.getAddress().isAnyLocalAddress();
return resolve(bind, advertisedAddress, advertisedPort, selectInterface ? interfaces() : List.of());
import java.net.*;
import java.util.*;

/** Bound addresses plus operator-provisioned forwarding endpoints. Discovery never guesses NAT mappings. */
public record ProviderEndpoint(InetSocketAddress bind, List<InetSocketAddress> advertised) {
public static ProviderEndpoint resolve(InetSocketAddress bind, List<InetSocketAddress> external, boolean localDevelopment) throws IOException {
return resolve(bind, external, localDevelopment, 0);
}

public static ProviderEndpoint resolve(InetSocketAddress bind, List<InetSocketAddress> external, boolean localDevelopment, int legacyAdvertisedPort) throws IOException {
if (bind.isUnresolved()) throw new IOException("Provider bind-address could not be resolved");
return resolve(bind, external, localDevelopment, bind.getAddress().isAnyLocalAddress() ? interfaces() : List.of(), legacyAdvertisedPort);
}

static ProviderEndpoint resolve(InetSocketAddress bind, String advertisedAddress, int advertisedPort,
static ProviderEndpoint resolve(InetSocketAddress bind, List<InetSocketAddress> external, boolean localDevelopment,
List<InetAddress> interfaces) throws IOException {
return resolve(bind, external, localDevelopment, interfaces, 0);
}

static ProviderEndpoint resolve(InetSocketAddress bind, List<InetSocketAddress> external, boolean localDevelopment,
List<InetAddress> interfaces, int legacyAdvertisedPort) throws IOException {
if (bind.isUnresolved() || bind.getPort() < 1 || bind.getAddress().isMulticastAddress())
throw new IOException("Provider bind-address must resolve to a local unicast or wildcard address and a fixed UDP port");
if (advertisedPort < 0 || advertisedPort > 65535) throw new IOException("Invalid provider advertised-port");
int port = advertisedPort == 0 ? bind.getPort() : advertisedPort;
InetAddress address;
if (advertisedAddress != null && !advertisedAddress.isBlank()) {
InetAddress[] resolved = InetAddress.getAllByName(advertisedAddress);
if (resolved.length != 1) throw new IOException("Set provider.advertised-address to one explicit IP address");
address = resolved[0];
} else if (!bind.getAddress().isAnyLocalAddress()) {
address = bind.getAddress();
throw new IOException("Provider bind-address must resolve to a local unicast or wildcard address and fixed UDP port");
Set<InetSocketAddress> endpoints = new LinkedHashSet<>();
// Configured mappings are explicit assertions about the forwarder, which may translate IP families.
for (InetSocketAddress endpoint : external) {
if (endpoint.isUnresolved() || endpoint.getPort() < 1 || !EndpointAddress.advertisable(endpoint.getAddress(), localDevelopment))
throw new IOException("Advertised endpoint must be a usable numeric unicast IP and UDP port");
endpoints.add(endpoint);
}
if (!bind.getAddress().isAnyLocalAddress()) {
// A local proxy may forward a configured external endpoint to a loopback listener.
if (EndpointAddress.advertisable(bind.getAddress(), localDevelopment)) {
InetAddress unscoped = InetAddress.getByAddress(bind.getAddress().getAddress());
endpoints.add(new InetSocketAddress(unscoped, bind.getPort()));
if (legacyAdvertisedPort > 0) endpoints.add(new InetSocketAddress(unscoped, legacyAdvertisedPort));
}
} else {
List<InetAddress> candidates = interfaces.stream().filter(candidate ->
!candidate.isAnyLocalAddress() && !candidate.isLoopbackAddress()
&& !candidate.isLinkLocalAddress() && !candidate.isMulticastAddress()
&& (candidate instanceof Inet4Address) == (bind.getAddress() instanceof Inet4Address))
.distinct().toList();
if (candidates.size() != 1) throw new IOException(
"Cannot select a unique reachable UDP address; set provider.advertised-address or NETHERNET_PROVIDER_ADVERTISED_ADDRESS");
address = candidates.getFirst();
for (InetAddress address : interfaces) {
// The pinned native listener uses IPV6_V6ONLY=0 for ::. A 0.0.0.0 socket is IPv4 only.
if (!address.isLoopbackAddress() && EndpointAddress.advertisable(address, localDevelopment)
&& (!(bind.getAddress() instanceof Inet4Address) || address instanceof Inet4Address)) {
InetAddress unscoped = InetAddress.getByAddress(address.getAddress());
endpoints.add(new InetSocketAddress(unscoped, bind.getPort()));
if (legacyAdvertisedPort > 0) endpoints.add(new InetSocketAddress(unscoped, legacyAdvertisedPort));
}
}
}
if (address.isAnyLocalAddress() || address.isMulticastAddress() || address.isLinkLocalAddress())
throw new IOException("Provider advertised-address must be a concrete reachable unicast address");
if ((address instanceof Inet4Address) != (bind.getAddress() instanceof Inet4Address))
throw new IOException("Provider bind-address and advertised-address must use the same IP family");
return new ProviderEndpoint(bind, new InetSocketAddress(address, port));
if (endpoints.isEmpty()) throw new IOException("No usable UDP endpoints; configure provider.advertised-endpoints for external forwarding");
if (endpoints.size() > 32) throw new IOException("More than 32 UDP endpoints; bind to a specific address to limit interface discovery");
List<InetSocketAddress> sorted = endpoints.stream().sorted(Comparator
.comparingInt(ProviderEndpoint::rank)
.thenComparing(endpoint -> endpoint.getAddress().getHostAddress())
.thenComparingInt(InetSocketAddress::getPort)).toList();
return new ProviderEndpoint(bind, sorted);
}

public List<String> warnings() {
List<String> messages = new ArrayList<>();
if (advertised.stream().noneMatch(endpoint -> EndpointAddress.scope(endpoint.getAddress()) == EndpointAddress.Scope.PUBLIC))
messages.add("Only private/shared or local addresses are advertised. Clients need LAN, VPN or routed connectivity to these endpoints. External signalling providers may advertise them; Warden does not relay game traffic.");
if (advertised.stream().allMatch(endpoint -> endpoint.getAddress() instanceof Inet6Address))
messages.add("Only IPv6 endpoints are advertised. Clients without working IPv6 connectivity to this host cannot join. Configure a reachable IPv4 endpoint for IPv4 clients.");
return List.copyOf(messages);
}

private static int rank(InetSocketAddress endpoint) {
return (EndpointAddress.scope(endpoint.getAddress()) == EndpointAddress.Scope.PUBLIC ? 0 : 2)
+ (endpoint.getAddress() instanceof Inet6Address ? 0 : 1);
}

private static List<InetAddress> interfaces() throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
import org.cloudburstmc.netty.signalling.ProviderTransport;
import java.net.InetSocketAddress;
import java.util.Map;
import java.util.List;
import java.util.concurrent.CompletionStage;

/** Supplied by the native admission integration; the extension owns the Bedrock child pipeline. */
public interface ProviderHostFactory {
CompletionStage<Host> open(ServerBootstrap bootstrap, InetSocketAddress udpBind, Map<String, String> options);
record Host(ProviderTransport transport, Channel channel) {}
record Host(ProviderTransport transport, Channel channel, List<String> warnings) {
public Host(ProviderTransport transport, Channel channel) { this(transport, channel, List.of()); }
}
}
Loading
Loading