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
5 changes: 5 additions & 0 deletions docs/external-signalling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 2 additions & 0 deletions docs/external-signalling/nxs-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"mode": {
"enum": [
"automatic",
"new-service",
"attach-instance"
]
Expand Down Expand Up @@ -172,6 +173,7 @@
"type": "array",
"items": {
"enum": [
"automatic",
"new-service",
"attach-instance"
]
Expand Down
15 changes: 13 additions & 2 deletions docs/external-signalling/wire-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <token>`. It MUST NOT appear in JSON, proofs, saved state,
or logs. `attach-instance` requires both bearer authorization and placement.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")) {
Expand All @@ -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<String, String> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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");
Expand Down
Loading