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
3 changes: 2 additions & 1 deletion docs/external-signalling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ issuance, ownership claims and fleet administration belong to the provider.
Send a signed `heartbeat` immediately after startup and whenever its returned
schedule says to check in. The request carries:

- Health, capacity, load and optional public server status.
- Health, admission capacity, load, optional actual player counts with sample time,
and independent optional public server status.
- `hostProfile` when endpoint details change; otherwise `hostProfileRevision`.
- `installedKeyIds`, listing installed admission epochs with the active one last.
- Local `state` (`serving`, `draining` or `closed`), the applied provider-state
Expand Down
18 changes: 17 additions & 1 deletion docs/external-signalling/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,20 @@ assert.deepEqual(v.expected,expected);
const provenance = {specification:'urn:nethernet:external-signalling:v1', files:Object.fromEntries(['stateless-admission-v1.fixtures.json','cloudburst-protocol-vectors.v1.json'].map(name => [name,digest(readFileSync(path(name))).toString('hex')]))};
if (update) write('provenance.json',provenance);
assert.deepEqual(read('provenance.json'),provenance);
console.log('NXS canonical signing, stateless encryption, and fixture hashes verified.');

// Fleet examples keep public endpoint metadata optional and runtime counts independent.
const schema = read('nxs-v1.schema.json');
for (const [document, value] of Object.entries(f.fleetExamples)) {
for (const field of schema.$defs[document].required) assert(field in value, document + ' omitted ' + field);
}
assert(!schema.$defs.registration.required.includes('serviceId'));
assert(!schema.$defs.registration.required.includes('publicAddress'));
assert(!('serviceId' in f.fleetExamples.registration));
assert(!('publicAddress' in f.fleetExamples.registration));
assert.equal(schema.$defs.heartbeat.properties.playerCount.$ref, '#/$defs/playerCount');
assert.equal(f.fleetExamples.heartbeat.playerCount.connectedPlayers, 3);
assert.equal(f.fleetExamples.heartbeat.serverStatus.players, 25000);
assert(f.fleetExamples.heartbeat.playerCount.sampledAt <= f.fleetExamples.heartbeat.clockUnixMillis);

assert.deepEqual(Object.keys(f.fleetExamples.heartbeat.serverStatus).sort(), ['name','protocol','version','level','players','maxPlayers','gameType'].sort());
console.log('NXS canonical signing, stateless encryption, fleet examples, and fixture hashes verified.');
51 changes: 49 additions & 2 deletions docs/external-signalling/nxs-v1.fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"context": {
"mode": "attach-instance",
"profile": "nxs-admission-v1",
"label": "EU café 🦊\n<proxy>",
"label": "EU caf\u00e9 \ud83e\udd8a\n<proxy>",
"authorizationId": "auth_fixture",
"serviceId": "service_neutral",
"region": "EU",
Expand Down Expand Up @@ -69,9 +69,56 @@
"idempotencyKey": "intent_fixture_0001",
"generation": 2,
"sequence": 17,
"body": "{\"name\":\"café 🦊\",\"players\":0}\n"
"body": "{\"name\":\"caf\u00e9 \ud83e\udd8a\",\"players\":0}\n"
},
"payload": "[\"nethernet-external-signalling-v1\",\"nxs-es384-v1\",\"https://provider.example\",\"POST\",\"/renew?region=EU&label=caf%C3%A9\",1788484200123,\"machine_neutral\",\"key_fixture\",\"intent_fixture_0001\",2,17,\"hW1_XbRZsu7XCnVbFpxNepqnGsOafEGkN_VFWwKb4jQ\"]",
"signature": "SMg1I5sJM8nQMSLcGMx8ajKG2HuF2b0qXolYIyPNtuC4jRT89G_MKemggBUGNw1_CtwBhEJiZfdQsM5OTkIDZTFukS2pQdFmLYw8x8GkWeouETOl7CbRnVCAA3SpV3Un"
},
"fleetExamples": {
"registration": {
"protocol": "nethernet-external-signalling-v1",
"provider": "https://provider.example",
"registrationId": "reg_fleet_1",
"instanceId": "instance_fleet_1",
"keyId": "key_fleet_1",
"profile": "nxs-admission-v1",
"placement": {
"region": "EU",
"pool": "proxy"
},
"heartbeatIntervalMs": 10000,
"leaseGeneration": 1,
"leaseDeadline": 1788800000000,
"readiness": {
"routable": false,
"reasons": [
"no_public_endpoint"
]
}
},
"heartbeat": {
"healthy": true,
"capacity": 20,
"load": 0.9,
"protocolVersion": "nethernet",
"clockUnixMillis": 1788799970100,
"checkInVersion": 1,
"state": "serving",
"appliedStateRevision": 1,
"gameOutcomes": "unavailable",
"playerCount": {
"connectedPlayers": 3,
"sampledAt": 1788799970000
},
"serverStatus": {
"name": "Network listing",
"version": "fixture",
"players": 25000,
"maxPlayers": 30000,
"gameType": 0,
"protocol": 1234,
"level": "world"
}
}
}
}
46 changes: 42 additions & 4 deletions docs/external-signalling/nxs-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -301,11 +301,9 @@
"protocol",
"provider",
"registrationId",
"serviceId",
"instanceId",
"keyId",
"profile",
"publicAddress",
"placement",
"heartbeatIntervalMs",
"leaseGeneration",
Expand All @@ -318,14 +316,30 @@
},
"extensions": {
"$ref": "#/$defs/extensions"
},
"serviceId": {
"type": "string",
"minLength": 1
},
"publicAddress": {
"type": "string",
"minLength": 1
}
}
},
"dependentRequired": {
"serviceId": [
"publicAddress"
],
"publicAddress": [
"serviceId"
]
},
"description": "An independent runtime identity. Optional serviceId/publicAddress describe a public endpoint at this observation; they are not stable instance identity. Pool attachments may have no public endpoint or serve several endpoints."
},
"readiness": {
"type": "object",
"required": [
"serverTime",
"serviceId",
"instanceId",
"serviceAvailable",
"instanceRoutable",
Expand Down Expand Up @@ -596,6 +610,9 @@
},
"extensions": {
"$ref": "#/$defs/extensions"
},
"playerCount": {
"$ref": "#/$defs/playerCount"
}
}
},
Expand Down Expand Up @@ -789,6 +806,27 @@
}
},
"additionalProperties": false
},
"playerCount": {
"type": "object",
"additionalProperties": false,
"required": [
"connectedPlayers",
"sampledAt"
],
"properties": {
"connectedPlayers": {
"type": "integer",
"minimum": 0,
"maximum": 1000000
},
"sampledAt": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"description": "Actual players connected to this runtime, including still-connected draining players. sampledAt is Unix milliseconds and covers this count and the heartbeat capacity from the same host observation. Absence means unknown. Never derive this value from public serverStatus, load, tickets or reservations."
}
},
"x-context-order": [
Expand Down
36 changes: 34 additions & 2 deletions docs/external-signalling/wire-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ and single-use completion atomically with resource creation.
Retrying completion MUST NOT return one-time key secrets again. If completion
was interrupted, recover the registration by proving ownership of the same key.

Completion returns `protocol,provider,registrationId,serviceId,instanceId,keyId,
profile,publicAddress,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline,
Completion returns `protocol,provider,registrationId,instanceId,keyId,
profile,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline,
readiness`, plus optional one-time `ticketKey` and `extensions`. Completion atomically starts a new generation, clears previous readiness and
resets the operational sequence to zero. Save the IDs and key material before
heartbeat. Recovery uses `register {registrationId,protocol,profile}` and the
Expand Down Expand Up @@ -204,6 +204,15 @@ to identify which key is current.
key. `deregister` carries `{}` and permanently ends registration. Neither is an
admission-key rotation or an ordinary graceful drain.

Registration may also return `serviceId` and `publicAddress`, always together.
They describe a public endpoint at that observation and are not stable runtime
identity. Pool attachments can return neither: a pool may have zero or several
public endpoints. Providers bind the token's permitted placement to their pool;
clients cannot choose arbitrary provider-owned resource IDs. Recovery preserves
`instanceId` and `registrationId`; changes to public endpoints do not require a
new runtime identity. The default standalone registration still creates a public
endpoint alongside the runtime.

## `heartbeat`

Required fields: `healthy,capacity,load,protocolVersion,clockUnixMillis,
Expand All @@ -227,6 +236,29 @@ installedKeyIds,keyRequestId,extensions`.
routing capacity/load. Omitted or failed status publication does not refresh
a previous status snapshot.

### Actual player counts

Optional `playerCount: {connectedPlayers, sampledAt}` reports the actual number of
players connected to this runtime, including existing players while it is draining.
`connectedPlayers` is an integer from 0 to 1000000; `sampledAt` is Unix milliseconds
from the host clock. The heartbeat's admission `capacity` must come from the same
observation. Count may exceed capacity after a capacity reduction. Capacity zero
means no admission. `load` remains a separate health/load observation.

This count is independent of the public `serverStatus.players` and its advertised
`maxPlayers`. A public/global override must never change the count or admission
capacity. Do not estimate connected players from load, successful tickets, reserved
slots or public listing totals. Omit `playerCount` when it is unknown; omission and
zero are distinct. Omission does not refresh the previous count. Providers fence
samples by the authenticated lease generation and sequence, track sample and receipt
times separately, and exclude stale/unknown samples from count-dependent routing
unless an explicit fallback policy applies. A retry must not freshen a sample.

The Java `Health` supplier accepts an optional `PlayerCount`. Sample the runtime and
capacity together; preserve an old sample's timestamp if returning cached values.
A changed connected count can wake a scheduled check-in even if public status is
unchanged. A new timestamp alone does not cause extra network traffic.

### Publish the host profile

`heartbeat.hostProfile` contains `candidates`, `dtlsFingerprint`, `credentialKeyId`,
Expand Down
3 changes: 3 additions & 0 deletions external-signalling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ or bearer token, token-authorized instance attachment, durable recovery, generat
activation, status/profile publication, scheduled heartbeats, key rotation, drain, and
asynchronous outcomes. Tokens are enrollment-only and excluded from durable state/logs.
One instance owns one private state directory; restarts preserve that directory.
A pool attachment may have no public address. Public endpoints can change without
changing the runtime identity. `Health` accepts an optional `PlayerCount` with actual
connected players and sample time, separate from the public `ServerStatus` supplier.

`ProtocolExtensions` carries bounded optional metadata. Applications explicitly interpret
known namespaces and invoke only their advertised same-origin operations. The core never
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,17 @@ public Configuration(URI provider, String profile, String label) {
}
@Override public String toString() { return "Configuration[provider=" + provider + ", profile=" + profile + ", registrationMode=" + registrationMode + ", authorizationScheme=" + authorizationScheme + "]"; }
}
public record Health(boolean healthy, int capacity, double load, String protocolVersion, String build) {
/** Sampled actual players on this runtime; keep counting existing players while draining. */
public record PlayerCount(int connectedPlayers, long sampledAt) {
public PlayerCount { if (connectedPlayers < 0 || connectedPlayers > 1000000 || sampledAt < 0 || sampledAt > 9007199254740991L) throw new IllegalArgumentException("Invalid player count sample"); }
}
/** Capacity and playerCount describe the same observation. Public server status is independent. */
public record Health(boolean healthy, int capacity, double load, String protocolVersion, String build, PlayerCount playerCount) {
public Health { if (capacity < 0 || capacity > 1000000 || !Double.isFinite(load) || load < 0 || load > 1 || protocolVersion == null) throw new IllegalArgumentException("Invalid health"); }
/** Hosts without actual player telemetry report unknown, never a synthetic zero. */
public Health(boolean healthy, int capacity, double load, String protocolVersion, String build) {
this(healthy, capacity, load, protocolVersion, build, null);
}
}
public static final class ProviderException extends IOException {
private final int status;
Expand Down Expand Up @@ -161,7 +170,7 @@ private void completeRecovery(JsonObject challenge, String registrationId) throw
completion.addProperty("proofNonce", "0"); completion.addProperty("idempotencyKey", intent); completion.addProperty("signature", ProviderCrypto.sign(key, ProviderCrypto.proof(challenge, "0", intent)));
JsonObject recovered = unsigned("complete", completion); validateRegistration(recovered);
if (!registrationId.equals(recovered.get("registrationId").getAsString())) throw new IOException("Recovered registration changed");
if (state.has("registration")) for (String field : List.of("instanceId", "serviceId", "registrationId"))
if (state.has("registration")) for (String field : List.of("instanceId", "registrationId"))
if (!state.getAsJsonObject("registration").get(field).equals(recovered.get(field))) throw new IOException("Recovered instance identity changed");
registrationExtensions = ProtocolExtensions.copy(recovered); recovered.remove("extensions"); recovered.remove("ticketKey");
state.add("registration", recovered); state.addProperty("generation", recovered.get("leaseGeneration").getAsLong());
Expand Down Expand Up @@ -221,6 +230,12 @@ private boolean acceptsRegistrationMode(String selected) {
private void validateRegistration(JsonObject registration) throws IOException {
ProviderContract.require("registration", registration);
ProtocolExtensions.validate(registration);
boolean hasService = registration.has("serviceId"), hasAddress = registration.has("publicAddress");
if (hasService != hasAddress) throw new IOException("Incomplete public endpoint metadata");
if (hasService) for (String field : List.of("serviceId", "publicAddress")) {
JsonElement value = registration.get(field);
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) throw new IOException("Invalid public endpoint metadata");
}
if (!origin.equals(registration.get("provider").getAsString()) || !config.profile().equals(registration.get("profile").getAsString())) throw new IOException("Registration provider or profile changed");
JsonObject placement = registration.getAsJsonObject("placement");
String expectedRegion = config.region() == null ? "" : config.region(), expectedPool = config.pool() == null ? "" : config.pool();
Expand Down Expand Up @@ -263,8 +278,10 @@ private boolean statusChanged() {
ServerStatus status = currentStatus(); Health health = healthSupplier.get();
return !Objects.equals(status, lastReportedStatus) || lastReportedHealth == null
|| health.healthy() != lastReportedHealth.healthy() || health.capacity() != lastReportedHealth.capacity()
|| !Objects.equals(connectedPlayers(health), connectedPlayers(lastReportedHealth))
|| !Objects.equals(health.protocolVersion(), lastReportedHealth.protocolVersion()) || !Objects.equals(health.build(), lastReportedHealth.build());
}
private static Integer connectedPlayers(Health health) { return health.playerCount() == null ? null : health.playerCount().connectedPlayers(); }
private void heartbeat() throws Exception {
// Key delivery and application acknowledgements can need an immediate second exchange.
for (int exchange = 0; exchange < 3; exchange++) {
Expand All @@ -284,6 +301,7 @@ private void heartbeat() throws Exception {
Health health = healthSupplier.get();
body.addProperty("healthy", health.healthy() && installedKeyId != null && hostState.equals("serving"));
body.addProperty("capacity", health.capacity()); body.addProperty("load", health.load());
if (health.playerCount() != null) body.add("playerCount", JSON.toJsonTree(health.playerCount()));
body.addProperty("protocolVersion", health.protocolVersion()); body.addProperty("build", health.build());
if (config.region() != null) body.addProperty("region", config.region());
snapshotClock = Math.max(System.currentTimeMillis(), snapshotClock + 1);
Expand Down
Loading
Loading