From f34c0b0a37cd5933ac7f5d46febc3a92a6aad381 Mon Sep 17 00:00:00 2001 From: Zulu Date: Tue, 8 Sep 2026 13:57:53 +0100 Subject: [PATCH] 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 3d3ab69..e9446fb 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 36ce731..70df733 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 06811c7..20eeaaf 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 56764e8..b14d272 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 5ddf96f..487cea8 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 dd625ca..f80e703 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");