diff --git a/.github/workflows/deploy-feature-snapshot.yml b/.github/workflows/deploy-feature-snapshot.yml index 550e54df..e3983805 100644 --- a/.github/workflows/deploy-feature-snapshot.yml +++ b/.github/workflows/deploy-feature-snapshot.yml @@ -38,6 +38,7 @@ jobs: echo "Publishing ${publish_version}" deploy: + if: github.repository == 'CloudburstMC/Network' needs: version uses: ./.github/workflows/deploy.yml with: diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index b3578d66..29c77c2b 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -6,6 +6,7 @@ on: jobs: deploy: + if: github.repository == 'CloudburstMC/Network' uses: CloudburstMC/Network/.github/workflows/deploy.yml@develop with: deploy-url: "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" diff --git a/.github/workflows/deploy-snapshot.yml b/.github/workflows/deploy-snapshot.yml index 59e72d2b..8ab40949 100644 --- a/.github/workflows/deploy-snapshot.yml +++ b/.github/workflows/deploy-snapshot.yml @@ -6,6 +6,7 @@ on: jobs: deploy: + if: github.repository == 'CloudburstMC/Network' uses: CloudburstMC/Network/.github/workflows/deploy.yml@develop with: deploy-url: "https://repo.opencollab.dev/maven-snapshots/" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 48964a0c..d9db4557 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,7 @@ on: jobs: publish: + if: github.repository == 'CloudburstMC/Network' runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/nxs-conformance.yml b/.github/workflows/nxs-conformance.yml new file mode 100644 index 00000000..07a7c1b0 --- /dev/null +++ b/.github/workflows/nxs-conformance.yml @@ -0,0 +1,36 @@ +name: External signalling conformance +on: + push: + branches: [nxs-dev] + pull_request: + branches: [upstream, nxs-dev] + workflow_dispatch: +permissions: + contents: read +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 8 + 17 + 21 + - uses: gradle/actions/setup-gradle@v4 + - name: Verify canonical protocol fixtures + run: node docs/external-signalling/fixtures.mjs + - name: Build exact native dependency chain + run: bash scripts/bootstrap-native-admission.sh + - name: Validate transport and independent provider + run: ./gradlew --max-workers=2 build :external-signalling:nativeAdmissionTest + - uses: actions/upload-artifact@v4 + if: always() + with: + name: nxs-conformance + path: | + **/build/test-results/** + **/build/reports/tests/** + .native-deps/maven/**/provenance.json diff --git a/.gitignore b/.gitignore index f9839b3b..b88f135a 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,6 @@ gradle-app.setting *.hprof # End of https://www.toptal.com/developers/gitignore/api/java,gradle,eclipse,netbeans,intellij+all + +# Locally rebuilt immutable native development artifacts +.native-deps/ diff --git a/LICENSES/MPL-2.0.txt b/LICENSES/MPL-2.0.txt new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/LICENSES/MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index 7d7e1ddb..35630047 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,21 @@ repositories { + +## NetherNet and external signalling + +`transport-nethernet` provides NetherNet transport and local HTTP signalling. +`external-signalling` lets a host register with its chosen provider and check +client connection tokens locally. Read the +[NXS v1 specification](docs/external-signalling/README.md) and the +[contribution history and proposed upstream PRs](docs/contribution-provenance.md). + +Build the pinned native development chain with `bash scripts/bootstrap-native-admission.sh`, +then run `./gradlew --max-workers=2 build :external-signalling:nativeAdmissionTest`. +An existing local Maven repository can be selected with +`-PnativeMavenRepository=/absolute/path/to/maven`. The development native artifacts +currently target Linux x86_64 and system OpenSSL. Other platforms need their own +release builds and tests. + +When editing documentation or PR descriptions, follow the +[technical-writing checklist](docs/technical-writing.md). diff --git a/build.gradle.kts b/build.gradle.kts index ffa7cc97..8d4e0391 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,13 @@ * under the License. */ +val nativeDependencies = java.util.Properties().apply { + rootProject.file("native-dependencies.properties").inputStream().use { load(it) } +} +for (key in listOf("nativeJavaGroup", "nativeJavaVersion")) { + if (!rootProject.hasProperty(key)) rootProject.extra[key] = nativeDependencies.getProperty(key) +} + val networkVersion = System.getenv("NETWORK_PUBLISH_VERSION") ?.trim() ?.takeIf { it.isNotEmpty() } @@ -28,6 +35,12 @@ subprojects { version = networkVersion repositories { + maven { + name = "maintainedNativeDevelopment" + url = uri(rootProject.providers.gradleProperty("nativeMavenRepository") + .getOrElse(rootProject.file(".native-deps/maven").toURI().toString())) + content { includeGroup("io.github.teamziax") } + } mavenLocal() mavenCentral() } diff --git a/docs/contribution-provenance.md b/docs/contribution-provenance.md new file mode 100644 index 00000000..f3f975c8 --- /dev/null +++ b/docs/contribution-provenance.md @@ -0,0 +1,71 @@ +# Contribution provenance and proposed submission slices + +This maintained proposal targets CloudburstMC/Network `develop` at +`508ed83c8a1fe1ce4287ad6d192c9d38bb0d2ffd`. `upstream` mirrors that exact baseline; +`nxs-dev` is the integrated development proposal. An aggregate internal draft +against `upstream` is for review and must not be merged into the mirror. + +| Slice | Origin and retained source | Reconstruction commit | Dependencies | +| --- | --- | --- | --- | +| N1: NetherNet prerequisites | Kas-tle/NetworkCompatible transport subtree at `9f3c0b7e72fb6f8934a7d36a518c74afe02c8a6d`, including introduction `77489fb0e4b86f5ffffef69230c81c7f4a857fa8` and SDP fixes `dcea2bab06ab35a53912b686a7dc59e0eb5bf62a` | `04ac4abb4d4e9d6f369c38de78ef2a40952ddecc` | Cloudburst baseline; original historical backend is replaced by N2 | +| N2: HTTP signalling and native backend | rtm516/NetworkCompatible transport subtree at `8d1c989ee6fb5eb7a28a9130573d30634f39cba4`; HTTP introduction `37f82650d6268a3470459d758ad89c3cc2026f2e`, backend migration `f4ba1e7b826e932e12974d5a6f329647eab6ee05` | `b96f3094ad1139848cd94f1984bf4122b5136348` | N1 and libdatachannel Java binding | +| N3: Open NXS contract and lifecycle client | teamziax/NetworkCompatible provider work through `01df7a0b4d3629306a72d78955f9b2645c174b1f`, adapted into a new negotiated specification and neutral client | `fc7dcc3534d34a0268e9a26ead49a48794b32c0b` | N2 for integrated transport; specification independently reviewable | +| N4: Stateless native admission | teamziax admission/teardown/first-datagram work at the same source tip, plus separate advertised-address support and downstream primitive probe | `9bf6674c43e6d59eba2805283e94b662d4b6c0ee` | N3 and the maintained mux/lifecycle/identity/ICE binding chain | +| N5: Integration and maintained dependency packaging | Exact dependency manifest, neutral bootstrap, conformance workflow, fixture handoff and final validation | Following integration commit(s) in this proposal | N1–N4; cross-repository pins in `native-dependencies.properties` | + +Cloudburst is the foundation, not newly authored work in this proposal. The two +histories share ancestor `1e26b20d9b9e13726edac99ee47711c958196fa2`; the source +proposal has 100 commits absent from current Cloudburst, while Cloudburst has 36 +commits absent from that source. Only the required transport subtree was imported. +Cloudburst's production `transport-raknet/src/main`, `codec-query` and `codec-rcon` +trees are preserved exactly. Two existing split-helper test constructor calls are +adapted to the upstream `(partId, expectedLength)` API so the baseline suite can +compile; this is the same minimal test repair previously recorded in +`ziaxzulu/Network` commit `2910ce99fcf96075da6b74d84c6d614bc2e3b207`. +Its throttle fixture also limits clients to one attempt, as recorded in +`aa3055bd4c6b3880913cd74911cba50ec4f1cea3`, so retries beyond the test's one-second +window do not invalidate its immediate-throttle assertion. No production RakNet +changes accompany these three test-line repairs. +The fork's removed codecs, unrelated RakNet compatibility behavior, +publishing configuration and historical merge topology were not transplanted. + +N1 removes the source module's additional publishing plugin and uses Cloudburst's +existing Gradle and Maven conventions. N2 adapts the inherited TLS detection call +to Cloudburst's existing Netty API; HTTP behaviour still rejects encrypted traffic +on the plaintext handler. It does not upgrade unrelated RakNet dependencies. +Original contributor names and file notices remain in the source and commit trailers. + +N3 replaces product-labelled packages, signature headers, registration/profile IDs +and domains with explicitly negotiated NXS v1 values. Canonical bytes are versioned; +the change is not a relabelling of old signatures. Durable same-instance recovery +preserves IDs and signing material through explicit recovery/activation. The client +implements no individual join control command and treats extension metadata as +transient, bounded opaque data. An optional application adapter owns its own +account actions. The independent provider exercises the full host lifecycle and +four authorization/placement journeys without such an adapter. + +N4 validates admission tokens in Java and STUN integrity in native code before +creating a peer. It limits pending attempts, used tokens and active sessions. +The native listener retains the first request during asynchronous validation, +coalesces duplicates and continues after acceptance without a client retry. +Peer creation runs outside the receive lock, and capacity remains reserved until +native teardown finishes. +The admission token binds the client fingerprint, ICE credentials, endpoint +incarnation, expiry and opaque caller context. Native integration tests cover +both data channels, first-datagram response, invalid ingress, replay, key retirement, +concurrency and shutdown. These tests do not establish stock-client gameplay. +The moved `AdmissionPrimitiveProbe` preserves its original MPL-2.0 file license; +the license text is in `LICENSES/MPL-2.0.txt`. + +Native patch acceptance is not required to build this proposal: the manifest pins +maintained forks. Fork packaging is intentionally separated from generic API +contributions. Native `master` already provides certificate and ICE-credential C +APIs; the Java binding uses those upstream APIs rather than duplicate entrypoints. +The native artifact provenance records source revisions, platform, ABI assumptions +and SHA-256 hashes. A new native chain must be rebuilt together with its JNI headers. + +Before eventual external submission, refresh the mirror, remove already accepted +slices, review current contribution rules, and describe dependencies explicitly. +NetworkCompatible belongs to a different GitHub fork network from Cloudburst; +this local Git ancestry reconstruction does not change repository ownership or +create an external PR. No repository transfer is part of this maintained proposal. diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md new file mode 100644 index 00000000..49d5ee69 --- /dev/null +++ b/docs/external-signalling/README.md @@ -0,0 +1,118 @@ +# NetherNet External Signalling + +NXS lets a NetherNet host use a signalling provider chosen by its operator. +The integration has three flows: register, heartbeat, and accept joins with +asynchronous feedback. This is an experimental contract, updated in place. +The [wire reference](wire-reference.md), [schema](nxs-v1.schema.json) and +[fixtures](nxs-v1.fixtures.json) specify the exact formats under Apache-2.0. + +## 1. Register with a provider + +Configure the provider's HTTPS origin and fetch +`/.well-known/nethernet-external-signalling`. Check its supported authentication +and use the operation URLs it returns. All URLs must remain on that origin; +never follow redirects when discovering or sending credentials. + +Create and save a P-384 machine key for this instance. Call `register` with the +public key and one of these enrollment choices: + +- `new-service`: create a service and its first instance, using advertised + anonymous proof of work or a bearer token. +- `attach-instance`: add an instance to an existing service, using a bearer + token and authorized placement metadata. + +The provider returns a challenge. Sign its bound proof and call `complete`. +Completion returns the assigned IDs, a fresh process generation, lease deadline +and initial admission key. Save them before publishing readiness. Completion +starts the generation; there is no separate activation call. + +On restart, reuse the saved machine key and call `register` with +`{registrationId,protocol,profile}`. Prove the returned challenge through +`complete` to preserve IDs and fence the previous process. If a completion reply +was lost, recover using the saved challenge ID. Repeating a consumed completion +cannot start another generation or reveal its secrets again. + +Every live replica needs its own key and private state directory. Account/token +issuance, ownership claims and fleet administration belong to the provider. + +## 2. Heartbeat to the provider + +Send a signed `heartbeat` immediately after startup and whenever its returned +schedule says to check in. The request carries: + +- 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 + revision, and whether the integration can report game outcomes. + +The reply returns the accepted profile revision, readiness, lease/schedule, +provider state and any admission-key updates. A host becomes routable only with +a live lease, usable profile and acknowledged installed key. + +Apply provider state before acknowledging its revision. `draining` stops new +joins and preserves existing sessions; `closed` closes the transport. Provider +routing and credential decisions take effect independently of host check-in. + +For a replacement admission key, include a fresh `keyRequestId`. Save and install +the returned key, then immediately heartbeat with the updated profile and +installed IDs. The provider cannot issue new tokens under that epoch before the +acknowledgement. Retain older keys until their reported retirement deadlines. + +On orderly shutdown, stop accepting new joins and immediately heartbeat with +`state: "draining"`. Do not wait for the periodic timer. A provider outage lets +routing leases expire; it does not by itself close established sessions. + +## 3. Accept a stateless join and report the outcome + +The provider gives the client the host's connection details and a short-lived +admission token. The client carries that token in its first STUN packet. The +host validates token authentication, expiry, endpoint/client binding and STUN +integrity locally before creating a peer. DTLS must then verify the client +certificate fingerprint from the token. + +No provider push, poll, lookup or pre-staged client state may gate admission. +The host retains its own background keys and active connection state. + +Send signed `outcomes` batches asynchronously, independently of heartbeat timing: + +| Observation | Required feedback | +| --- | --- | +| Both data channels become usable | `ticket.data_channels_open` | +| An authenticated observed attempt fails before transport becomes usable | `ticket.failed`, with a bounded reason | +| The game admits or rejects the player | `ticket.game_joined` or `ticket.game_rejected`, when the integration observes this boundary | + +Declare game-outcome support as `available` or `unavailable` in heartbeat. A +transport connection never proves successful gameplay. Intermediate ICE, DTLS +and SCTP stages are optional diagnostics in the same stream. + +Each event contains `ticketId`, `stage`, `occurredAt` and optional `reason`. +Retry bounded batches without duplicating observations. Do not send player +identity, SDP, credentials or game payloads. Reporting failure never delays +admission or renews a lease. Missing feedback means an unknown outcome: a client +may never reach the host, or the host may crash before reporting. + +## Operation reference + +All operations use POST; all except `register` and `complete` use the machine +request signature. URLs come from discovery. + +| Operation | Purpose | +| --- | --- | +| `register` | Request an enrollment or recovery challenge | +| `complete` | Prove the challenge and start the process generation | +| `heartbeat` | Exchange host health, profile, keys, lifecycle state and readiness | +| `outcomes` | Report transport and game observations | +| `rotate` | Prove and install a replacement machine signing key | +| `retire` | Retire the previous machine signing key | +| `deregister` | Permanently end this instance's registration | + +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/cloudburst-protocol-vectors.v1.json b/docs/external-signalling/cloudburst-protocol-vectors.v1.json new file mode 100644 index 00000000..29c10ab4 --- /dev/null +++ b/docs/external-signalling/cloudburst-protocol-vectors.v1.json @@ -0,0 +1,36 @@ +{ + "stun": { + "rfc5769": { + "messageIntegrityHex": "9aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a2", + "name": "rfc5769-sample-request", + "packetHex": "000100582112a442b7e7a701bc34d686fa87dfae802200105354554e207465737420636c69656e74002400046e0001ff80290008932ff9b151263b36000600096576746a3a68367659202020000800149aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a280280004e57a3bcf", + "passwordUtf8": "VOkJxbRl1RmTxUk/WvJxBt", + "transactionIdHex": "b7e7a701bc34d686fa87dfae", + "username": "evtj:h6vY" + } + }, + "nethernetFrames": [ + { + "decoded": { + "complete": true, + "payloadHex": "01020304", + "remainingFragments": 0 + }, + "frameHex": "0001020304", + "name": "complete-reliable-payload", + "payloadHex": "01020304", + "remainingFragments": 0 + }, + { + "decoded": { + "complete": false, + "payloadHex": "0a0b", + "remainingFragments": 2 + }, + "frameHex": "020a0b", + "name": "fragment-countdown-payload", + "payloadHex": "0a0b", + "remainingFragments": 2 + } + ] +} diff --git a/docs/external-signalling/fixtures.mjs b/docs/external-signalling/fixtures.mjs new file mode 100644 index 00000000..29f9a44c --- /dev/null +++ b/docs/external-signalling/fixtures.mjs @@ -0,0 +1,69 @@ +// Public fixture keys only. Node's independent crypto implementation verifies JVM conformance. +import { readFileSync, writeFileSync } from 'node:fs'; +import { createHash, createHmac, createCipheriv, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; + +const protocol = 'nethernet-external-signalling-v1', signature = 'nxs-es384-v1'; +const path = name => fileURLToPath(new URL(name, import.meta.url)); +const read = name => JSON.parse(readFileSync(path(name), 'utf8')); +const write = (name, value) => writeFileSync(path(name), JSON.stringify(value, null, 2) + '\n'); +const digest = value => createHash('sha256').update(value).digest(); +const b64 = value => value.toString('base64url'); +const hmac = (key, data) => createHmac('sha256', key).update(data).digest(); +const update = process.argv.includes('--write'); + +const f = read('nxs-v1.fixtures.json'), c = f.challenge; +f.protocol = c.protocol = protocol; c.signature = signature; c.context.profile = 'nxs-admission-v1'; +c.thumbprint = b64(digest(JSON.stringify(Object.fromEntries(['crv', 'kty', 'x', 'y'].map(k => [k, f.publicKeyJwk[k]]))))); +c.contextDigest = b64(digest(JSON.stringify(['mode','profile','label','authorizationId','serviceId','region','pool','registrationId','tagsDigest'].filter(k => k in c.context).map(k => c.context[k])))); +const proof = JSON.stringify([protocol,'complete',c.audience,c.challengeId,c.nonce,c.thumbprint,c.contextDigest,c.expiresAt,f.proofNonce,f.idempotencyKey]); +const i = f.request.input; +const request = JSON.stringify([protocol,signature,i.audience,i.method,i.path,i.timestamp,i.instanceId,i.keyId,i.idempotencyKey,i.generation,i.sequence,b64(digest(i.body))]); +const pub = createPublicKey({ key: f.publicKeyJwk, format: 'jwk' }); +for (const [name, payload] of [['proof',proof],['request',request]]) { + if (update) { + f[name].payload = payload; + f[name].signature = b64(sign('sha384', Buffer.from(payload), { key: createPrivateKey({key:f.privateKeyJwk,format:'jwk'}), dsaEncoding:'ieee-p1363' })); + } + assert.equal(f[name].payload, payload); + assert(verify('sha384', Buffer.from(payload), {key:pub,dsaEncoding:'ieee-p1363'}, Buffer.from(f[name].signature,'base64url'))); +} +if (update) write('nxs-v1.fixtures.json', f); + +const v = read('stateless-admission-v1.fixtures.json'), claims = v.claims, context = v.context; +const plain = Buffer.alloc(67 + claims.clientIcePwd.length); +plain.writeUInt32BE(claims.expiresAt / 1000); Buffer.from(claims.clientFingerprintHex,'hex').copy(plain,4); +plain.writeUInt16BE(claims.clientSctpPort,36); plain.writeUInt32BE(claims.clientMaxMessageSize,38); +Buffer.from(claims.callerContextHashHex,'hex').copy(plain,42); plain.writeBigUInt64BE(BigInt(claims.networkId),58); +plain[66] = claims.clientIcePwd.length; plain.write(claims.clientIcePwd,67,'ascii'); +const nonce = Buffer.from(v.nonceHex,'hex'), header = 'NXS1' + context.keyId; +const key = hmac(context.secret, `nxs-stateless-aead-v1\0${context.audience}`); +const cipher = createCipheriv('aes-256-gcm', key, nonce); +cipher.setAAD(Buffer.from(`nxs-stateless-admission-v1\0${header}\0${context.audience}\0${v.clientIceUfrag}`)); +const encrypted = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]); plain.fill(0); +const localUfrag = header + Buffer.concat([nonce,encrypted]).toString('base64').replaceAll('=',''); +const icePwd = hmac(context.secret,`nxs-stateless-ice-v1\0${context.audience}\0${localUfrag}`).subarray(0,24).toString('base64'); +const expected = {localUfrag,icePwd,ufragLength:localUfrag.length}; +if (update) { v.expected = expected; write('stateless-admission-v1.fixtures.json',v); } +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); + +// 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.'); diff --git a/docs/external-signalling/nxs-v1.fixtures.json b/docs/external-signalling/nxs-v1.fixtures.json new file mode 100644 index 00000000..891f6bff --- /dev/null +++ b/docs/external-signalling/nxs-v1.fixtures.json @@ -0,0 +1,124 @@ +{ + "warning": "LOCAL CONFORMANCE KEY ONLY. Never deploy this key.", + "protocol": "nethernet-external-signalling-v1", + "publicKeyJwk": { + "key_ops": [ + "verify" + ], + "ext": true, + "kty": "EC", + "x": "7onqrvcQqP_J5uJk-j3M7KhZqAB3OwxxFkg2XodPmV9KmC7ALcVeK0CQ-pJqX88F", + "y": "7mSiEjHsP4o6StG48-3Vvb2BfG8WTuYxmfrYmaS_CZDVHoWibgPWvmkGUbVQG9xq", + "crv": "P-384" + }, + "privateKeyJwk": { + "key_ops": [ + "sign" + ], + "ext": true, + "kty": "EC", + "x": "7onqrvcQqP_J5uJk-j3M7KhZqAB3OwxxFkg2XodPmV9KmC7ALcVeK0CQ-pJqX88F", + "y": "7mSiEjHsP4o6StG48-3Vvb2BfG8WTuYxmfrYmaS_CZDVHoWibgPWvmkGUbVQG9xq", + "crv": "P-384", + "d": "yjEPqO0LygBnf919VCmdziREaPgeFeCT4M4QC_lW1Nvkda9iOoZHuRlQAUUGYua4" + }, + "challenge": { + "protocol": "nethernet-external-signalling-v1", + "signature": "nxs-es384-v1", + "challengeId": "challenge_fixture", + "nonce": "8WH8OdS12DO6ju0_", + "audience": "https://provider.example", + "thumbprint": "FV0R5dHP--qigTPgspmBk6zImSz6BhG0368xJW7Gxlo", + "context": { + "mode": "attach-instance", + "profile": "nxs-admission-v1", + "label": "EU caf\u00e9 \ud83e\udd8a\n", + "authorizationId": "auth_fixture", + "serviceId": "service_neutral", + "region": "EU", + "pool": "proxy", + "registrationId": "", + "tagsDigest": "CEHUNj2V3qUPpjRczgqi9TCVca5-uQ8g29G8YT4qtng" + }, + "contextDigest": "d7Aq1Xak842s6La_FqpAOA_NolAtu7gG3jvS75n6OpM", + "expiresAt": 1788484800000, + "serverTime": 1788484200000, + "pow": { + "algorithm": "sha256-leading-zero-bits-v0", + "difficulty": 0 + }, + "authorization": { + "scheme": "bearer-token", + "reference": "auth_fixture" + } + }, + "proofNonce": "0", + "idempotencyKey": "intent_fixture_0001", + "proof": { + "payload": "[\"nethernet-external-signalling-v1\",\"complete\",\"https://provider.example\",\"challenge_fixture\",\"8WH8OdS12DO6ju0_\",\"FV0R5dHP--qigTPgspmBk6zImSz6BhG0368xJW7Gxlo\",\"d7Aq1Xak842s6La_FqpAOA_NolAtu7gG3jvS75n6OpM\",1788484800000,\"0\",\"intent_fixture_0001\"]", + "signature": "HwAyBojOh_FOHzCt_dABnf3cEgypZYERBg3TJVDySRZB3dPKTcOTCwo8sFmy83c3EyUYx2ynMMbHUlh7NqHLCQMRSsntyeC7yn-wxQ4dC6mx0Lnyq4Telp0H7CfhRNS8" + }, + "request": { + "input": { + "audience": "https://provider.example", + "method": "POST", + "path": "/renew?region=EU&label=caf%C3%A9", + "timestamp": 1788484200123, + "instanceId": "machine_neutral", + "keyId": "key_fixture", + "idempotencyKey": "intent_fixture_0001", + "generation": 2, + "sequence": 17, + "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" + } + } + } +} diff --git a/docs/external-signalling/nxs-v1.schema.json b/docs/external-signalling/nxs-v1.schema.json new file mode 100644 index 00000000..a0401612 --- /dev/null +++ b/docs/external-signalling/nxs-v1.schema.json @@ -0,0 +1,869 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nethernet:external-signalling:v1", + "title": "NetherNet External Signalling v1", + "type": "object", + "required": [ + "protocol", + "mode", + "profile", + "publicKeyJwk" + ], + "properties": { + "protocol": { + "const": "nethernet-external-signalling-v1" + }, + "mode": { + "enum": [ + "automatic", + "new-service", + "attach-instance" + ] + }, + "profile": { + "type": "string" + }, + "publicKeyJwk": { + "type": "object", + "required": [ + "crv", + "kty", + "x", + "y" + ], + "not": { + "required": [ + "d" + ] + }, + "properties": { + "crv": { + "const": "P-384" + }, + "kty": { + "const": "EC" + }, + "x": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{64}$" + }, + "y": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{64}$" + } + } + }, + "label": { + "type": "string", + "maxLength": 128 + }, + "authorization": { + "type": "object", + "additionalProperties": false, + "required": [ + "scheme" + ], + "properties": { + "scheme": { + "enum": [ + "anonymous-proof-of-work", + "bearer-token" + ] + } + } + }, + "placement": { + "type": "object", + "additionalProperties": false, + "required": [ + "region", + "pool" + ], + "properties": { + "region": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,32}$" + }, + "pool": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,64}$" + }, + "tags": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9_.-]{1,32}$" + }, + "additionalProperties": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxProperties": 16 + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "const": "attach-instance" + } + } + }, + "then": { + "required": [ + "placement", + "authorization" + ], + "properties": { + "authorization": { + "properties": { + "scheme": { + "const": "bearer-token" + } + } + } + } + } + } + ], + "$defs": { + "discovery": { + "type": "object", + "required": [ + "provider", + "controlOrigin", + "protocols", + "signatures", + "modes", + "profiles", + "operations", + "authorization", + "limits" + ], + "properties": { + "authorization": { + "type": "object", + "required": [ + "header", + "schemes" + ], + "properties": { + "header": { + "const": "Authorization" + }, + "schemes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "scheme", + "modes" + ], + "properties": { + "scheme": { + "enum": [ + "anonymous-proof-of-work", + "bearer-token" + ] + }, + "modes": { + "type": "array", + "items": { + "enum": [ + "automatic", + "new-service", + "attach-instance" + ] + } + } + } + } + } + } + }, + "extensions": { + "$ref": "#/$defs/extensions" + }, + "operations": { + "type": "object", + "required": [ + "register", + "complete", + "heartbeat", + "outcomes", + "rotate", + "retire", + "deregister" + ], + "properties": { + "register": { + "type": "string", + "format": "uri" + }, + "complete": { + "type": "string", + "format": "uri" + }, + "heartbeat": { + "type": "string", + "format": "uri" + }, + "outcomes": { + "type": "string", + "format": "uri" + }, + "rotate": { + "type": "string", + "format": "uri" + }, + "retire": { + "type": "string", + "format": "uri" + }, + "deregister": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false + } + } + }, + "challenge": { + "type": "object", + "required": [ + "protocol", + "signature", + "challengeId", + "nonce", + "audience", + "thumbprint", + "context", + "contextDigest", + "expiresAt", + "serverTime", + "pow" + ], + "properties": { + "protocol": { + "const": "nethernet-external-signalling-v1" + }, + "signature": { + "const": "nxs-es384-v1" + }, + "expiresAt": { + "type": "integer" + }, + "serverTime": { + "type": "integer" + }, + "authorization": { + "type": "object", + "required": [ + "scheme", + "reference" + ], + "properties": { + "scheme": { + "enum": [ + "anonymous-proof-of-work", + "bearer-token" + ] + }, + "reference": { + "type": "string" + } + } + } + } + }, + "completion": { + "type": "object", + "required": [ + "protocol", + "challengeId", + "proofNonce", + "signature", + "idempotencyKey" + ], + "properties": { + "protocol": { + "const": "nethernet-external-signalling-v1" + } + } + }, + "registration": { + "type": "object", + "required": [ + "protocol", + "provider", + "registrationId", + "instanceId", + "keyId", + "profile", + "placement", + "heartbeatIntervalMs", + "leaseGeneration", + "leaseDeadline", + "readiness" + ], + "properties": { + "protocol": { + "const": "nethernet-external-signalling-v1" + }, + "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", + "instanceId", + "serviceAvailable", + "instanceRoutable", + "readiness", + "leaseGeneration", + "leaseDeadline", + "placement" + ] + }, + "rotation": { + "type": "object", + "required": [ + "publicKeyJwk", + "proof" + ] + }, + "ticketKey": { + "type": "object", + "required": [ + "keyId", + "secret" + ] + }, + "extensions": { + "type": "object", + "maxProperties": 16, + "propertyNames": { + "type": "string", + "maxLength": 128, + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*){1,7}$" + }, + "additionalProperties": { + "type": "object", + "required": [ + "version", + "critical", + "data" + ], + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 999999999 + }, + "critical": { + "type": "boolean" + }, + "data": { + "type": "object" + } + } + }, + "x-max-utf8-bytes": 16384 + }, + "recovery": { + "type": "object", + "required": [ + "registrationId", + "protocol", + "profile" + ], + "properties": { + "registrationId": { + "type": "string", + "minLength": 1 + }, + "protocol": { + "const": "nethernet-external-signalling-v1" + }, + "profile": { + "const": "nxs-admission-v1" + } + } + }, + "register": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/$defs/recovery" + } + ] + }, + "hostState": { + "enum": [ + "serving", + "draining", + "closed" + ] + }, + "hostProfile": { + "type": "object", + "required": [ + "candidates", + "dtlsFingerprint", + "credentialKeyId", + "sctpPort", + "maxMessageSize", + "statelessAdmission" + ], + "properties": { + "candidates": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "required": [ + "foundation", + "component", + "protocol", + "priority", + "address", + "port", + "type" + ], + "properties": { + "foundation": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,32}$" + }, + "component": { + "const": 1 + }, + "protocol": { + "const": "udp" + }, + "priority": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "address": { + "type": "string", + "maxLength": 253 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "type": { + "enum": [ + "host", + "srflx", + "relay" + ] + } + } + } + }, + "dtlsFingerprint": { + "type": "string", + "pattern": "^sha-256 [0-9A-F]{2}(?::[0-9A-F]{2}){31}$" + }, + "credentialKeyId": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + }, + "sctpPort": { + "const": 5000 + }, + "maxMessageSize": { + "const": 262144 + }, + "statelessAdmission": { + "type": "object", + "required": [ + "capability", + "incarnation" + ], + "properties": { + "capability": { + "const": "nethernet.stateless-admission.v1" + }, + "incarnation": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + } + } + } + }, + "heartbeat": { + "type": "object", + "required": [ + "healthy", + "capacity", + "load", + "protocolVersion", + "clockUnixMillis", + "checkInVersion", + "state", + "appliedStateRevision", + "gameOutcomes" + ], + "properties": { + "healthy": { + "type": "boolean" + }, + "capacity": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "load": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "protocolVersion": { + "type": "string", + "maxLength": 128 + }, + "clockUnixMillis": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "checkInVersion": { + "const": 1 + }, + "state": { + "$ref": "#/$defs/hostState" + }, + "appliedStateRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "gameOutcomes": { + "enum": [ + "available", + "unavailable" + ] + }, + "build": { + "type": "string", + "maxLength": 128 + }, + "region": { + "type": "string", + "maxLength": 32 + }, + "serverStatus": { + "type": "object" + }, + "hostProfile": { + "$ref": "#/$defs/hostProfile" + }, + "hostProfileRevision": { + "type": "string", + "maxLength": 184 + }, + "installedKeyIds": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + } + }, + "keyRequestId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{16,128}$" + }, + "extensions": { + "$ref": "#/$defs/extensions" + }, + "playerCount": { + "$ref": "#/$defs/playerCount" + } + } + }, + "checkIn": { + "type": "object", + "required": [ + "version", + "afterMillis", + "nextCheckInAt", + "leaseExpiresAt", + "minUpdateIntervalMillis" + ], + "properties": { + "version": { + "const": 1 + }, + "afterMillis": { + "type": "integer", + "minimum": 1000, + "maximum": 86400000 + }, + "nextCheckInAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "leaseExpiresAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "minUpdateIntervalMillis": { + "type": "integer", + "minimum": 1000, + "maximum": 86400000 + } + } + }, + "heartbeatResponse": { + "type": "object", + "required": [ + "accepted", + "receivedAt", + "staleAfter", + "hostProfileRevision", + "activeKeyId", + "leaseGeneration", + "readiness", + "desiredState", + "retirements" + ], + "properties": { + "accepted": { + "const": true + }, + "receivedAt": { + "type": "string", + "format": "date-time" + }, + "staleAfter": { + "type": "string", + "format": "date-time" + }, + "hostProfileRevision": { + "type": [ + "string", + "null" + ] + }, + "activeKeyId": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + }, + "leaseGeneration": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "readiness": { + "$ref": "#/$defs/readiness" + }, + "desiredState": { + "type": "object", + "required": [ + "revision", + "state" + ], + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "state": { + "$ref": "#/$defs/hostState" + } + } + }, + "checkIn": { + "$ref": "#/$defs/checkIn" + }, + "keyRequest": { + "type": "object", + "required": [ + "id", + "keyId" + ], + "properties": { + "id": { + "type": "string", + "maxLength": 128 + }, + "keyId": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + } + } + }, + "ticketKey": { + "$ref": "#/$defs/ticketKey" + }, + "retirements": { + "type": "array", + "items": { + "type": "object", + "required": [ + "keyId", + "retireAfter" + ], + "properties": { + "keyId": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$" + }, + "retireAfter": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + } + } + } + }, + "outcomes": { + "type": "object", + "required": [ + "events" + ], + "properties": { + "events": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "required": [ + "ticketId", + "stage", + "occurredAt" + ], + "properties": { + "ticketId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "stage": { + "enum": [ + "ticket.ice_seen", + "ticket.ice_connected", + "ticket.dtls_connected", + "ticket.sctp_connected", + "ticket.data_channels_open", + "ticket.game_joined", + "ticket.game_rejected", + "ticket.failed" + ] + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string", + "maxLength": 128 + } + }, + "additionalProperties": false + } + } + }, + "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": [ + "mode", + "profile", + "label", + "authorizationId", + "serviceId", + "region", + "pool", + "registrationId" + ], + "x-optional-context-order": [ + "tagsDigest" + ], + "x-signature-format": "ES384 P1363 r||s 96 bytes unpadded base64url", + "x-canonicalization": "UTF-8 JSON arrays, no whitespace, no Unicode normalization, epoch milliseconds as integers, absent context strings are empty", + "description": "Registration, heartbeat and stateless join feedback for the experimental NXS contract.", + "x-profile": "nxs-admission-v1", + "x-discovery-path": "/.well-known/nethernet-external-signalling", + "x-headers": [ + "nxs-instance-id", + "nxs-key-id", + "nxs-timestamp", + "nxs-signature-version", + "nxs-generation", + "nxs-sequence", + "nxs-signature", + "idempotency-key" + ], + "x-operations": [ + "register", + "complete", + "heartbeat", + "outcomes", + "rotate", + "retire", + "deregister" + ] +} diff --git a/docs/external-signalling/provenance.json b/docs/external-signalling/provenance.json new file mode 100644 index 00000000..01260175 --- /dev/null +++ b/docs/external-signalling/provenance.json @@ -0,0 +1,7 @@ +{ + "specification": "urn:nethernet:external-signalling:v1", + "files": { + "stateless-admission-v1.fixtures.json": "f84db8b78018cf3f5b910625414701f7c41bca1a5fa8056ff22c0d17df99301a", + "cloudburst-protocol-vectors.v1.json": "2bd4c3305a0911f2d4a1166f1c6760ed6979c5af551cbd9660e9badd1f7fb993" + } +} diff --git a/docs/external-signalling/stateless-admission-v1.fixtures.json b/docs/external-signalling/stateless-admission-v1.fixtures.json new file mode 100644 index 00000000..870fe880 --- /dev/null +++ b/docs/external-signalling/stateless-admission-v1.fixtures.json @@ -0,0 +1,47 @@ +{ + "kind": "nxs-stateless-admission-vectors", + "version": 1, + "experimental": true, + "warning": "Public deterministic test material only. Never use fixture keys/nonces in production.", + "context": { + "keyId": "K001", + "secret": "stateless-fixture-secret-32-bytes-minimum", + "audience": "nxs-stateless-host-v1/0123456789abcdef0123456789abcdef" + }, + "claims": { + "expiresAt": 1788484830000, + "clientFingerprintHex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "clientIcePwd": "clientPassword01234567890", + "clientSctpPort": 5000, + "clientMaxMessageSize": 262144, + "callerContextHashHex": "abcdef0123456789abcdef0123456789", + "networkId": "18446744073709551615" + }, + "clientIceUfrag": "clientFixtureUf", + "nonceHex": "000102030405060708090a0b", + "now": 1788484800000, + "maxTtlMs": 60000, + "expected": { + "localUfrag": "NXS1K001AAECAwQFBgcICQoLVMAD+DMWY8K/TmtjsFluCDDdBa2hZL+TXNNyTD2z2mk9pDlsv7HZSWUQcYeCwa1spGxF25/EdEU8Qa7XN2qhYd9Ha7UENSp5AIMknX1jCS8Mw4joQSTW9M1qq5G/t+T8agkcuu9qluHVT/Am", + "icePwd": "hLPAsKmOEIGguJ3cJoj7OQqkkbLN6RWH", + "ufragLength": 168 + }, + "budget": { + "headerAscii": 8, + "nonceBytes": 12, + "fixedPlaintextBytes": 67, + "tagBytes": 16, + "maxUfragChars": 256, + "maxPasswordBytes": 91 + }, + "rejections": [ + "tampered-ciphertext", + "expired", + "wrong-host", + "wrong-profile-incarnation", + "wrong-key", + "wrong-client-ufrag", + "password-over-budget", + "noncanonical-base64" + ] +} diff --git a/docs/external-signalling/wire-reference.md b/docs/external-signalling/wire-reference.md new file mode 100644 index 00000000..d31b8766 --- /dev/null +++ b/docs/external-signalling/wire-reference.md @@ -0,0 +1,462 @@ +# NXS wire reference + +This is the normative format reference for the [three integration flows](README.md). +Protocol identifiers describe the current experimental format; this revision +replaces its earlier operation surface in place. + +## Version and discovery + +| Field | v1 value | +| --- | --- | +| Registration/request protocol | `nethernet-external-signalling-v1` | +| Machine request signature | `nxs-es384-v1` | +| Operational profile | `nxs-admission-v1` | +| Discovery path | `/.well-known/nethernet-external-signalling` | +| Stateless capability | `nethernet.stateless-admission.v1` | +| Stateless carrier prefix | `NXS1` | + +### Provider origin and operation URLs + +The configured origin MUST use HTTPS. HTTP is permitted only for loopback +development. Normalize the origin by lowercasing its scheme and host and omitting +default ports. It cannot contain credentials, a path, a query, or a fragment. + +Fetch discovery with an unauthenticated `GET`. Its `provider` and `controlOrigin` +MUST equal the configured origin. Each operation URL MUST have that same origin +and contain no userinfo or fragment. Clients MUST disable redirects for discovery +and for calls that carry credentials. Sign encoded paths and query strings +exactly as transmitted. + +Discovery contains `provider`, `controlOrigin`, the arrays `protocols`, +`signatures`, `profiles`, and `modes`, an `operations` map, `authorization`, +`limits`, and optional `extensions`. Before sending credentials, clients reject +an unsupported protocol, profile, signature, mode, or required extension. + +The [operation table](README.md#operation-reference) defines the operation names. Clients get their +URLs from discovery. `/v1/nxs/` is a recommended path, but providers +can use other paths. + +### Authorization and limits + +`authorization` contains `header: "Authorization"` and a `schemes` array. Each +entry has a `scheme` and its supported `modes`: + +| Scheme | Allowed modes | +| --- | --- | +| `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 +requires proof that the instance owns its signing key. + +| Limit | v1 constraint | +| --- | --- | +| `maxBodyBytes` | At most 65536 | +| `clockSkewMs` | At most 60000 | +| `heartbeatIntervalMs` | 1000–30000 | +| `leaseMs` | Advertised lease duration | + +`checkInVersion: 1` enables the provider to set the next check-in time in its +response. A provider MUST advertise every limit it enforces, reject oversized +bodies, and return errors as `{"code":"lowercase_machine_code"}` with an +appropriate HTTP failure status. Clients limit response size before parsing. + +On a transient transport failure or HTTP 429, 502, 503, or 504, the supplied +client makes at most three attempts in total. Retries use exponential delays +with jitter and an upper bound. A `Retry-After` value over ten seconds returns +a retry-later result. Outcome uploads use one attempt with a three-second timeout +and a ten-second failure backoff so they cannot starve heartbeat renewal. +Retrying never extends a lease or challenge expiry. + +## Registration and persistent identity + +### Save the instance key + +Generate a fresh P-384 machine signing key for each logical instance. Save it +before requesting a challenge. A restart reuses that instance's saved state; +live replicas cannot share a key or state directory. Images and templates MUST +contain neither machine identity nor DTLS private keys. + +Clients lock their state directory and write private state atomically with +owner-only permissions. Sync both files and directories to durable storage. +If saving state fails, stop advertising healthy readiness. + +### `register` request + +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. +The token authorizes access to the service; a client-provided label grants no +permission. + +Placement is `{region,pool,tags?}`: + +| Field | Constraint | +| --- | --- | +| `region` | Immutable routing label matching `[A-Za-z0-9_-]{1,32}` | +| `pool` | Immutable routing label matching `[A-Za-z0-9_-]{1,64}` | +| `tags` | At most 16 keys matching `[A-Za-z0-9_.-]{1,32}`; values are trimmed strings of 1–64 characters with no control characters | + +The challenge binds the exact placement. At completion, the provider rechecks +that the token authorizes it as part of the same atomic operation that creates +the registration. These fields do not prescribe how a provider selects a host. + +The public JWK is EC/P-384. Its `x` and `y` values use canonical, unpadded +base64url and each encode exactly 48 bytes. It MUST NOT contain `d`. The RFC 7638 +thumbprint is SHA-256 of UTF-8 JSON with members in this exact order: +`crv,kty,x,y`. ES384 signatures use the 96-byte IEEE-P1363 form `r || s`, encoded +as unpadded base64url. Reject DER signatures and noncanonical base64url. + +The challenge response contains `protocol`, `signature`, `challengeId`, `nonce`, +`audience`, `thumbprint`, `context`, `contextDigest`, `expiresAt`, `serverTime`, +and `pow: {algorithm:"sha256-leading-zero-bits-v0",difficulty}`. + +Proof-of-work difficulty is 0–24. Bearer-authorized and recovery flows use zero. +An authorization reference is an opaque identifier, never the credential itself. +Expiry and server times are integer epoch milliseconds. + +### `complete` request + +Canonical arrays use UTF-8 JSON with no whitespace or Unicode normalization. +Use an empty string for a missing context string. `contextDigest` is the +unpadded base64url SHA-256 digest of: + +```text +[mode,profile,label,authorizationId,serviceId,region,pool,registrationId] +``` + +When tags are nonempty, append `tagsDigest` to that array. Compute `tagsDigest` +in the same way from sorted `[key,value]` pairs. The completion proof is: + +```text +[protocol,"complete",audience,challengeId,nonce,thumbprint,contextDigest, + expiresAt,proofNonce,idempotencyKey] +``` + +Proof of work counts the leading zero bits in SHA-256 of those bytes. Send +`protocol,challengeId,proofNonce,idempotencyKey,signature` to complete registration. +The provider MUST check expiry, binding, signature, difficulty, current authority, +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,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 +same completion proof. A deregistered instance cannot recover. Remove secrets from registration results exposed +to applications and from diagnostic output. + +## Signed requests and machine-key maintenance + +### Sign operational requests + +Use the registered machine key for every operational request. The enrollment +bearer token is used only for the challenge request. + +Required headers are `nxs-instance-id`, `nxs-key-id`, `nxs-timestamp`, +`nxs-signature-version`, `nxs-generation`, `nxs-sequence`, `nxs-signature`, and +`idempotency-key`. The timestamp is epoch milliseconds. Generation and sequence +are nonnegative integers. Save each reserved sequence number before sending its +request. The signature covers this array: + +```text +[protocol,signatureVersion,audience,method,encodedPathAndQuery,timestamp, + instanceId,keyId,idempotencyKey,generation,sequence,base64url(sha256(bodyBytes))] +``` + +For an empty body, hash a zero-length byte sequence. Providers reject stale +generations, reused sequence numbers, invalid timestamps, and invalid signatures. +An idempotent retry can return the recorded result, with secrets removed, if its +intent and semantic request are unchanged. It cannot apply the operation again. + +Each completed registration/recovery starts a generation exactly once. The +provider rejects writes from previous generations. A replay cannot advance the +generation or extend the original lease. Replaying a consumed completion returns +a recovery-required error; recover with a fresh challenge. + +### Rotate a machine key + +The rotation proof bytes are `[protocol,"rotate",audience,instanceId,oldKeyId, +newThumbprint,generation,idempotencyKey]`. Save the replacement private key before +requesting rotation. Save the result before retiring the old key. After an +interrupted rotation, recovery can use the provider's returned key thumbprint +to identify which key is current. + +`retire` carries `{keyId}` and must be signed by a different, current machine +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, +checkInVersion,state,appliedStateRevision,gameOutcomes`. +Optional fields: `build,region,serverStatus,hostProfile,hostProfileRevision, +installedKeyIds,keyRequestId,extensions`. + +- `capacity` is an integer from 0 to 1000000; `load` is a finite number from 0 to 1. +- `state` is `serving`, `draining` or `closed`. A draining endpoint cannot resume + serving in the same generation; a fresh endpoint requires recovery/completion. +- `gameOutcomes` is `available` when the integration observes game acceptance and + rejection, otherwise `unavailable`. +- `appliedStateRevision` is a nonnegative integer. A response carries + `desiredState: {revision,state}`. Reject unknown states or regressing revisions; + acknowledge only state that finished applying. Pending application triggers + a bounded earlier heartbeat. Receipt alone is not acknowledgement. +- `clockUnixMillis` is an increasing snapshot clock within the generation and + must be within 30000 milliseconds of provider time. +- `region` cannot change authorized placement. `serverStatus` contains + `name,protocol,version,level,players,maxPlayers,gameType`; it is independent of + 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`, +`sctpPort`, `maxMessageSize`, and `statelessAdmission: {capability,incarnation}`. +Generate a fresh random 16-byte `incarnation`, encoded as lowercase hex, for +each bound native endpoint. The fingerprint is `sha-256 ` followed by the +certificate's digest bytes in colon-separated uppercase hex. + +Each candidate contains `foundation,component,protocol,priority,address,port,type`. +Publish 1–32 candidates. Foundations match `[A-Za-z0-9._:-]{1,32}`; component is +1, protocol is `udp`, priority is 1–2147483647, port is 1–65535, and type is +`host`, `srflx` or `relay`. Addresses are IP literals. +Publish only reachable UDP candidates that are explicitly chosen for advertisement. +The bind address and the advertised address serve different purposes. A host can +bind to all interfaces, but it cannot advertise wildcard `0.0.0.0` or `::`. +The deployment or provider must establish reachability through NAT or a relay; +a passing registration test does not prove that clients can reach the address. + +Prepare the host's DTLS certificate and key before publishing its profile. Keep +the private key local. All peers using that profile use that certificate, so +clients see the fingerprint the provider advertised. The host may use a new +certificate for a later endpoint incarnation after publishing its new fingerprint. +A permanent certificate shared across a fleet is neither required nor advised. + +Three types of key have separate jobs: + +| Key | Purpose | +| --- | --- | +| Machine signing key | Authenticate the host's requests to the provider | +| DTLS certificate and private key | Authenticate the host during the client connection | +| Admission key | Protect and validate the client's admission token | + +The provider assigns `hostProfileRevision` in its reply. Send that revision +on later heartbeats until the profile changes. A request retry returns the same +revision. Profile publication, acknowledgement of its installed key, and the +lease update must commit consistently. A profile using an uninstalled epoch +cannot become routable. + +### Admission-key exchange + +`installedKeyIds` contains at most eight distinct four-character uppercase +alphanumeric IDs, ordered with the active epoch last. Save and install every +listed key before sending it. The last ID must be the provider's current or +pending epoch. Publish a matching profile when changing the active epoch. + +To provision a replacement, include a random `keyRequestId` of 16–128 URL-safe +characters, saved before sending. The response includes +`keyRequest: {id,keyId}` and, on first delivery only, `ticketKey: {keyId,secret}`. +A key secret has 32–256 UTF-8 characters; optional `notBefore` and `retireAfter` +are epoch milliseconds. An idempotent retry cannot mint another key or return +the secret again. If its delivery was lost, use a fresh request ID to provision +a replacement. A provider may retire an unacknowledged, superseded pending key. + +Install the key atomically, then immediately publish its profile and acknowledge +it in heartbeat. Replies include `retirements: [{keyId,retireAfter}]` for older +reported epochs. Repeated replies preserve the original deadlines; they cannot +extend key life. Providers must allow outstanding tokens their defined overlap +window. Reject tokens before activation or after retirement and erase retired +material. Key rotation never extends token expiry. + +### Readiness, lease and schedule + +The reply includes `receivedAt` (ISO8601), `hostProfileRevision`, `activeKeyId`, +`leaseGeneration`, `readiness: {routable,reasons}`, and: + +```text +checkIn: {version:1,afterMillis,nextCheckInAt,leaseExpiresAt,minUpdateIntervalMillis} +``` + +Schedule timestamps are epoch milliseconds. `nextCheckInAt` precedes lease +expiry. `checkInVersion: 1` requests scheduling; while an initial usable profile +is unavailable the provider can omit `checkIn` and use its discovery cadence +and `staleAfter` ISO8601 deadline. A draining/closed host is never routable. + +Readiness is a current provider observation; only the recorded `checkIn` or +`staleAfter` grants a lease. Request replay returns that original grant. Hosts +use monotonic timers, count network time against the interval, and publish +changed activity/status earlier subject to the returned rate limit. Restart +immediately publishes fresh state and discards the prior schedule. Existing +sessions survive a control-plane outage. + +## `outcomes` + +Request: `{events:[{ticketId,stage,occurredAt,reason?}]}` with at most 100 events. +`occurredAt` is ISO8601; `reason` is a bounded code of at most 128 characters. +The ticket ID derives from the authenticated admission carrier, never from an +unauthenticated packet. The provider scopes correlation to the signed instance. +No provider-specific routing decision ID is required. + +Required stages are `ticket.data_channels_open` and `ticket.failed` for observed +transport attempts, plus `ticket.game_joined`/`ticket.game_rejected` when +`gameOutcomes` is `available`. Optional diagnostic stages are `ticket.ice_seen`, +`ticket.ice_connected`, `ticket.dtls_connected` and `ticket.sctp_connected`. +Success and failure describe the observed boundary, not an inferred later stage. + +A successful response acknowledges the whole batch. Repeated observations must +be deduplicated by instance, ticket, stage, occurrence time and reason, including +when a retry uses a new request ID. Queue and persist redacted reports with a +finite bound; the reference client retains at most 1000 pending entries and +flushes at most 100 per tick independently of idle heartbeat timing. Backpressure +must not block native admission or lease renewal. Neither absent reports nor an +unreachable host proves a particular client's outcome. Never send SDP, private +keys, player identity or game payloads. + +## Stateless admission carrier + +### Carry the token in the ICE username + +The client's first STUN USERNAME is `:`, where: + +```text +answerUfrag = "NXS1" + keyId + unpaddedBase64(nonce || ciphertext || tag) +``` + +Use the standard base64 alphabet, including `+` and `/`, which ICE permits. +Do not use base64url. The total ufrag length is at most 256 characters. Before +allocating peer state, reject noncanonical encoding, trailing padding, a wrong +prefix, unknown key epochs, and oversized input. + +AES-256-GCM uses a random 12-byte nonce and a 16-byte tag. Its key is +`HMAC-SHA256(secret, "nxs-stateless-aead-v1" || NUL || audience)`. +The audience is `nxs-stateless-host-v1/`. The additional authenticated +data (AAD) is +`"nxs-stateless-admission-v1" || NUL || ("NXS1"+keyId) || NUL || audience || NUL || clientUfrag`. + +| Plaintext offset | Size | Meaning, unsigned big-endian where numeric | +| --- | --- | --- | +| 0 | 4 | Expiry in epoch seconds, exactly representable in milliseconds | +| 4 | 32 | SHA-256 client certificate fingerprint | +| 36 | 2 | Client SCTP port, 1–65535 | +| 38 | 4 | Client maximum message size, 1–262144 | +| 42 | 16 | Opaque caller-context hash, no account-specific interpretation | +| 58 | 8 | NetherNet network ID, unsigned 64-bit | +| 66 | 1 | Client ICE password length, 22–91 | +| 67 | N | Client ICE password in ICE base64 alphabet | + +The host's local ICE password is the unpadded standard base64 encoding of the +first 24 bytes of +`HMAC-SHA256(secret, "nxs-stateless-ice-v1" || NUL || audience || NUL || answerUfrag)`. +The ticket correlation ID is the first 16 bytes of SHA-256 of the ASCII answer +ufrag, encoded as lowercase hex. + +### Validate the first packet + +A token can be valid for at most 120 seconds. Before assigning the UDP tuple to a +peer or allocating a peer connection, the host checks expiry, field bounds, GCM +authentication, client binding, and STUN MESSAGE-INTEGRITY. The DTLS handshake +MUST then verify the client fingerprint from the token. + +Only a retransmission of the identical token from the same UDP tuple can reuse +a reservation. Reject the same token from another tuple. Also reject a conflicting +admission on an occupied tuple. + +Limit the number of sessions, pending handshakes, used-token records, queued +validation tasks, and retained requests. Duplicate requests for the same pending +attempt share one decision. Preserve enough of the first request to respond after +acceptance: completing admission MUST NOT depend on the client retransmitting. +Release admission capacity only after the connection's resources have been +released. A failed integrity check MUST NOT consume the token, since a copied +token alone does not prove that the sender has its ICE password. + +#### Reference implementation + +The supplied Network implementation uses a 60-second token limit. libjuice +retains the first STUN packet and sends parsed metadata to Java for asynchronous +token validation. libdatachannel verifies STUN integrity before creating the +peer, outside the UDP receive lock. It then processes the retained request after +the application has installed its callbacks. Established transport packets stay +native, and capacity remains reserved until native teardown finishes. + +Other implementations may meet the requirements above using different languages, +threading models, and transport libraries. + +## Optional extensions + +Providers can add optional application metadata without making it part of NXS. +For example, a product could supply an account-claim link. NXS does not define +what claiming an account means or require other providers to implement it. + +`extensions` is an object with at most 16 reverse-DNS namespace keys, such as +`com.example.feature`, and at most 16384 bytes of encoded UTF-8 JSON. Keys use +lowercase domain-style labels and have at most 128 characters. Each value is +`{version:positiveInteger,critical:boolean,data:object}`. + +Pass through or ignore unknown optional extensions; never execute them +automatically. Reject unsupported critical extensions before sending credentials +or publishing readiness. An optional extension cannot change the core protocol rules. +TLS and request signatures still authenticate bodies and operation paths. + +An extension can advertise URLs in `data.operations`. An application can request +one of these operations only after validating the namespace, version, and meaning. +The generic transport still requires the same provider origin and signs the +exact path. + +## Conformance + +Run `node docs/external-signalling/fixtures.mjs` for independent signature and +admission fixtures. The Java tests consume the same schema/fixtures and exercise +an independent provider with no product accounts. Native tests separately cover +local admission and real UDP/ICE/DTLS/SCTP. Report stock-client gameplay separately +from these checks. diff --git a/docs/technical-writing.md b/docs/technical-writing.md new file mode 100644 index 00000000..f45f710d --- /dev/null +++ b/docs/technical-writing.md @@ -0,0 +1,41 @@ +# Writing for reviewers and implementers + +Explain what changes, why it matters, and how the reader can check it. Assume the +reader understands software but has not followed this project's discussions. + +- Start with the problem and the resulting behavior. Put audit details later. +- Name the actor and action: “the host checks the token,” for example. +- Give each sentence one main idea. Split sentences that ask the reader to hold + several conditions in mind. +- Use familiar words. Keep a technical term when it adds precision, and explain + it on first use. +- Show a small example before a complex rule. Use a table for comparisons or a + sequence for a flow. +- Keep exact API names, field names, limits, and signing formats. Simpler prose + must not weaken a protocol requirement. +- State what the tests establish and what remains untested. Support performance + or reliability claims with measurements. +- Link to detailed history and build records. Keep attribution and dependency + tables where reviewers need them, but avoid repeating full commit hashes in + prose. + +For a PR, lead with the behavior change, then explain the approach and relevant +validation. For an aggregate proposal, also show who contributed each part and +which future PRs depend on others. Describe the final change rather than the +sequence of attempts used to build it. + +For a specification, start with its purpose, a short example, and the terms a +reader needs. Follow with the exact rules. Keep optional product behavior clearly +identified so an independent implementation knows what it needs to support. + +| Before | After | +| --- | --- | +| “Old processes are fenced.” | “After activation, the provider rejects requests from the previous generation.” | +| “Bounded namespaced extensions.” | “Optional metadata uses named extensions with limits on their number and size.” | +| “Persistent endpoint identity.” | “The host reuses its DTLS certificate, so clients see the advertised fingerprint.” | + +Before publishing, read the opening paragraph aloud. A reviewer should be able +to explain the benefit without first reading the implementation. + +This checklist follows [Google's advice on short sentences](https://developers.google.com/tech-writing/one/short-sentences) +and [Microsoft's style and voice guidance](https://learn.microsoft.com/en-us/style-guide/top-10-tips-style-voice). diff --git a/external-signalling/README.md b/external-signalling/README.md new file mode 100644 index 00000000..7d979523 --- /dev/null +++ b/external-signalling/README.md @@ -0,0 +1,45 @@ +# NetherNet External Signalling + +Java 21 client for the open [NXS v1 specification](../docs/external-signalling/README.md). +Published coordinates follow Cloudburst conventions: `org.cloudburstmc.netty:netty-external-signalling`. +The independent provider and fixtures require no product account or proprietary control plane. + +```sh +./gradlew --max-workers=2 :external-signalling:test :transport-nethernet:test +./gradlew --max-workers=2 :external-signalling:providerStub +node docs/external-signalling/fixtures.mjs +``` + +`ProviderClient` supports new-service registration by advertised anonymous proof of work +or bearer token, token-authorized instance attachment, durable recovery, generation-fenced +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 +performs product account/claim actions or stages individual joins from provider control. + +`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 +`:external-signalling:nativeAdmissionTest`; native packaging must match the immutable JNI +revision in `native-dependencies.properties`. Never combine new headers with older native +binaries. Native tests prove transport conformance, not stock-client gameplay. diff --git a/external-signalling/build.gradle.kts b/external-signalling/build.gradle.kts new file mode 100644 index 00000000..3f521d42 --- /dev/null +++ b/external-signalling/build.gradle.kts @@ -0,0 +1,61 @@ +description = "NetherNet External Signalling client and stateless admission" +java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } } +dependencies { + api(libs.gson) + implementation(project(":transport-nethernet")) + testImplementation(libs.bundles.junit) + testImplementation(project(":transport-raknet")) + testRuntimeOnly(libs.junit.platform.launcher) + testRuntimeOnly("${rootProject.property("nativeJavaGroup")}:libdatachannel-java:${rootProject.property("nativeJavaVersion")}:x86_64") +} +tasks.jar { manifest.attributes["Automatic-Module-Name"] = "org.cloudburstmc.netty.signalling" } + + +tasks.test { useJUnitPlatform { excludeTags("native") } } +tasks.register("nativeBenchClasspath") { + dependsOn(tasks.testClasses) + // Printing a classpath does not otherwise make Gradle build its project JARs. + dependsOn(sourceSets.test.get().runtimeClasspath) + doLast { println(sourceSets.test.get().runtimeClasspath.asPath) } +} +tasks.register("nativeAdmissionTest") { + description = "Real fixed-UDP stateless host integration against the pinned JNI library" + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + javaLauncher.set(javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(21)) }) + useJUnitPlatform { includeTags("native") } + maxParallelForks = 1 + testLogging { showStandardStreams = true } +} + +tasks.register("providerStub") { + dependsOn(tasks.testClasses) + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("org.cloudburstmc.netty.signalling.IndependentProviderStub") +} + +tasks.register("providerBench") { + dependsOn(tasks.testClasses) + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("org.cloudburstmc.netty.signalling.ProviderBench") + listOf("providerOrigin", "providerState", "providerMode", "providerToken", "providerRegistrationMode", "providerRegion", "providerPool", "providerTags", "providerHoldSeconds", "providerStopFile", "providerExtensionsFile").forEach { name -> + providers.gradleProperty(name).orNull?.let { systemProperty(name, it) } + } +} + +// Avoid shell-specific dependency-cache paths in the cross-repository local workflow. +tasks.register("providerBenchClasspath") { + dependsOn(tasks.testClasses) + dependsOn(sourceSets.test.get().runtimeClasspath) + doLast { println(sourceSets.test.get().runtimeClasspath.asPath) } +} + +tasks.processResources { from(rootProject.file("docs/external-signalling/nxs-v1.schema.json")) } + +tasks.processTestResources { + from(rootProject.file("docs/external-signalling/nxs-v1.fixtures.json")) + from(rootProject.file("docs/external-signalling")) { + include("stateless-admission-v1.fixtures.json", "cloudburst-protocol-vectors.v1.json", "provenance.json") + into("nxs") + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java new file mode 100644 index 00000000..4fbbff4b --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/CheckInSchedule.java @@ -0,0 +1,25 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.io.IOException; + +/** Validates the scheduling contract; policy and idle thresholds belong to the provider. */ +record CheckInSchedule(long afterMillis, long minUpdateIntervalMillis) { + static CheckInSchedule parse(JsonObject response) throws IOException { + try { + JsonObject s = response.getAsJsonObject("checkIn"); + if (number(s, "version") != 1) throw new IllegalArgumentException(); + long after = number(s, "afterMillis"), minimum = number(s, "minUpdateIntervalMillis"); + long next = number(s, "nextCheckInAt"), expires = number(s, "leaseExpiresAt"); + long received = java.time.Instant.parse(response.get("receivedAt").getAsString()).toEpochMilli(); + if (after < 1000 || after > 86400000 || minimum < 1000 || minimum > after + || next - received != after || expires <= next || expires - next > 300000) throw new IllegalArgumentException(); + return new CheckInSchedule(after, minimum); + } catch (RuntimeException invalid) { throw new IOException("Invalid provider check-in schedule", invalid); } + } + private static long number(JsonObject object, String field) { + var value = object.getAsJsonPrimitive(field); + if (!value.isNumber()) throw new IllegalArgumentException(); + return value.getAsBigDecimal().longValueExact(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/LimitedBodySubscriber.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/LimitedBodySubscriber.java new file mode 100644 index 00000000..9d50b820 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/LimitedBodySubscriber.java @@ -0,0 +1,27 @@ +package org.cloudburstmc.netty.signalling; + +import java.io.IOException; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; + +/** Enforces the cap before allocating a response body; completion includes all bytes. */ +final class LimitedBodySubscriber implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber delegate = HttpResponse.BodySubscribers.ofByteArray(); + private final int limit; + private Flow.Subscription upstream; + private int count; + private boolean failed; + LimitedBodySubscriber(int limit) { this.limit = limit; } + public CompletionStage getBody() { return delegate.getBody(); } + public void onSubscribe(Flow.Subscription subscription) { upstream = subscription; delegate.onSubscribe(subscription); } + public void onNext(List buffers) { + if (failed) return; + for (ByteBuffer b : buffers) { if (b.remaining() > limit - count) { failed = true; upstream.cancel(); delegate.onError(new IOException("Provider response exceeds limit")); return; } count += b.remaining(); } + delegate.onNext(buffers); + } + public void onError(Throwable error) { if (!failed) { failed = true; delegate.onError(error); } } + public void onComplete() { if (!failed) delegate.onComplete(); } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProtocolExtensions.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProtocolExtensions.java new file mode 100644 index 00000000..b10fd16c --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProtocolExtensions.java @@ -0,0 +1,38 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; + +/** Bounded, authenticated metadata whose optional contents are interpreted by applications. */ +public final class ProtocolExtensions { + public static final int MAX_BYTES = 16_384; + public static final int MAX_ENTRIES = 16; + private ProtocolExtensions() {} + + public static void validate(JsonObject document) { + if (!document.has("extensions")) return; + JsonElement value = document.get("extensions"); + if (!value.isJsonObject()) throw new IllegalArgumentException("Invalid extensions object"); + JsonObject extensions = value.getAsJsonObject(); + if (extensions.size() > MAX_ENTRIES || extensions.toString().getBytes(StandardCharsets.UTF_8).length > MAX_BYTES) + throw new IllegalArgumentException("Extensions exceed limits"); + for (var entry : extensions.entrySet()) { + if (!entry.getKey().matches("[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*){1,7}") || entry.getKey().length() > 128 || !entry.getValue().isJsonObject()) + throw new IllegalArgumentException("Invalid extension namespace"); + JsonObject extension = entry.getValue().getAsJsonObject(); + if (!extension.has("version") || !extension.get("version").isJsonPrimitive() + || !extension.getAsJsonPrimitive("version").isNumber() || !extension.get("version").getAsString().matches("[1-9][0-9]{0,8}") + || !extension.has("critical") || !extension.get("critical").isJsonPrimitive() + || !extension.getAsJsonPrimitive("critical").isBoolean() || !extension.has("data") || !extension.get("data").isJsonObject()) + throw new IllegalArgumentException("Invalid extension envelope"); + // This core implements no mandatory extension. Applications cannot silently weaken core checks. + if (extension.get("critical").getAsBoolean()) throw new IllegalArgumentException("Unsupported required extension: " + entry.getKey()); + } + } + + public static JsonObject copy(JsonObject document) { + validate(document); + return document.has("extensions") ? document.getAsJsonObject("extensions").deepCopy() : new JsonObject(); + } +} 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 new file mode 100644 index 00000000..296c8d7c --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java @@ -0,0 +1,504 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import java.io.*; +import java.net.URI; +import java.net.http.*; +import java.security.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.*; + +/** 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, + String authorizationToken, String region, String pool, Map tags) { + public Configuration { + Objects.requireNonNull(provider); Objects.requireNonNull(profile); Objects.requireNonNull(registrationMode); Objects.requireNonNull(authorizationScheme); + if (!"nxs-admission-v1".equals(profile)) throw new IllegalArgumentException("Unsupported operational profile"); + 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(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) && !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"); + } + public Configuration(URI provider, String profile, String label) { + this(provider, profile, label, NEW_SERVICE, ANONYMOUS_PROOF_OF_WORK, null, null, null, Map.of()); + } + @Override public String toString() { return "Configuration[provider=" + provider + ", profile=" + profile + ", registrationMode=" + registrationMode + ", authorizationScheme=" + authorizationScheme + "]"; } + } + /** 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; + ProviderException(int status, String code) { super("Provider request failed: " + status + " " + code); this.status = status; } + public int status() { return status; } + } + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().create(); + private final Configuration config; + private final ProviderStateStore store; + private final ProviderTransport transport; + private final Supplier statusSupplier; + private final Supplier healthSupplier; + private final Consumer diagnostics; + private final String origin; + private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "nethernet-provider"); t.setDaemon(true); return t; }); + private final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).connectTimeout(Duration.ofSeconds(10)).build(); + private final AtomicReference explicitStatus = new AtomicReference<>(); + private final AtomicBoolean refreshQueued = new AtomicBoolean(); + private JsonObject state, discovery; + private JsonObject registrationExtensions = new JsonObject(); + private PrivateKey privateKey; + private String profileRevision; + private JsonObject lastProfile; + private long intervalMs = 10000, nextHeartbeat, snapshotClock; + private boolean started, closed, scheduledCheckIns; + private long nextOutcomes, nextStatusUpdate, minUpdateIntervalMs = 1000, appliedStateRevision; + private String hostState = "serving", installedKeyId; + private JsonObject lastHeartbeat = new JsonObject(); + private ServerStatus lastReportedStatus; + private Health lastReportedHealth; + private final AtomicBoolean closing = new AtomicBoolean(); + private final CompletableFuture stopped = new CompletableFuture<>(); + private ScheduledFuture timer; + public ProviderClient(Configuration config, ProviderStateStore store, ProviderTransport transport, Supplier statusSupplier, Supplier healthSupplier, Consumer diagnostics) { + this.config = config; this.store = store; this.transport = transport; this.statusSupplier = statusSupplier; this.healthSupplier = healthSupplier; + this.diagnostics = diagnostics; this.origin = ProviderCrypto.origin(config.provider()); + } + public CompletableFuture start() { return submit(() -> { + if (started) throw new IllegalStateException("Already started"); + discovery = exchange(URI.create(origin + "/.well-known/nethernet-external-signalling"), "GET", null, false, null, null); + validateDiscovery(); state = store.read(); + if (state.has("provider") && !origin.equals(state.get("provider").getAsString())) throw new IOException("State belongs to another provider; use a separate directory"); + if (!state.has("privateKey")) { + KeyPair pair = ProviderCrypto.generate(); state.addProperty("provider", origin); state.addProperty("privateKey", ProviderCrypto.base64(pair.getPrivate().getEncoded())); state.add("publicKeyJwk", ProviderCrypto.publicJwk(pair.getPublic())); save(); + } + privateKey = ProviderCrypto.privateKey(state.get("privateKey").getAsString()); + if (!state.has("registration")) enroll(); + else recoverExisting(); + JsonObject registration = state.getAsJsonObject("registration"); + if (!registration.get("provider").getAsString().equals(origin)) throw new IOException("Registration audience changed"); + state.addProperty("protocol", ProviderCrypto.PROTOCOL); state.addProperty("profile", config.profile()); + state.addProperty("generation", registration.get("leaseGeneration").getAsLong()); state.addProperty("sequence", 0); + state.remove("cursor"); state.remove("pendingAdmissions"); save(); + installKeys(); + started = true; heartbeat(); + timer = executor.scheduleWithFixedDelay(() -> { + if (closed) return; + try { + if (started && (System.nanoTime() >= nextHeartbeat || statusChanged())) heartbeat(); + } catch (Exception e) { + nextHeartbeat = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + nextStatusUpdate = nextHeartbeat; + diagnostics.accept("provider_status_unavailable: " + safeFailure(e)); + } + if (System.nanoTime() >= nextOutcomes) { + try { flushEvents(); } + catch (Exception e) { nextOutcomes = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); diagnostics.accept("provider_events_unavailable: " + safeFailure(e)); } + } + }, 1000, 1000, TimeUnit.MILLISECONDS); + return redactedRegistration(); + }); } + private void validateDiscovery() throws IOException { + ProviderContract.require("discovery", discovery); + ProtocolExtensions.validate(discovery); + if (!origin.equals(discovery.get("controlOrigin").getAsString()) || !origin.equals(discovery.get("provider").getAsString())) throw new IOException("Discovery provider mismatch"); + for (String[] pair : List.of(new String[]{"protocols", ProviderCrypto.PROTOCOL}, new String[]{"signatures", ProviderCrypto.SIGNATURE}, new String[]{"profiles", config.profile()}, new String[]{"modes", config.registrationMode()})) { + if (!discovery.getAsJsonArray(pair[0]).contains(new JsonPrimitive(pair[1]))) throw new IOException("Unsupported required provider capability: " + pair[0]); + } + boolean authorizationSupported = false; + for (JsonElement item : discovery.getAsJsonObject("authorization").getAsJsonArray("schemes")) { + JsonObject scheme = item.getAsJsonObject(); + if (config.authorizationScheme().equals(scheme.get("scheme").getAsString()) && scheme.getAsJsonArray("modes").contains(new JsonPrimitive(config.registrationMode()))) authorizationSupported = true; + } + if (!authorizationSupported || !"Authorization".equals(discovery.getAsJsonObject("authorization").get("header").getAsString())) throw new IOException("Unsupported provider authorization"); + for (String op : ProviderContract.operations()) if (!discovery.getAsJsonObject("operations").has(op)) throw new IOException("Missing required operation: " + op); + for (var op : discovery.getAsJsonObject("operations").entrySet()) trusted(URI.create(op.getValue().getAsString())); + intervalMs = discovery.getAsJsonObject("limits").get("heartbeatIntervalMs").getAsLong(); + if (intervalMs < 1000 || intervalMs > 30000) throw new IOException("Unsupported heartbeat interval"); + JsonObject limits = discovery.getAsJsonObject("limits"); + if (limits.get("maxBodyBytes").getAsLong() < 1 || limits.get("maxBodyBytes").getAsLong() > 65536 + || limits.get("clockSkewMs").getAsLong() < 0 || limits.get("clockSkewMs").getAsLong() > 60000) + throw new IOException("Unsupported provider limits"); + } + private JsonObject recoveryRequest(String registrationId) { + JsonObject recovery = new JsonObject(); recovery.addProperty("registrationId", registrationId); + recovery.addProperty("protocol", ProviderCrypto.PROTOCOL); recovery.addProperty("profile", config.profile()); + return recovery; + } + private void recoverExisting() throws Exception { + String registrationId = registration("registrationId"); + completeRecovery(unsigned("register", recoveryRequest(registrationId)), registrationId); + } + private void completeRecovery(JsonObject challenge, String registrationId) throws Exception { + ProviderContract.require("challenge", challenge); + JsonObject context = challenge.getAsJsonObject("context"); + if (!origin.equals(challenge.get("audience").getAsString()) || !ProviderCrypto.PROTOCOL.equals(challenge.get("protocol").getAsString()) + || !ProviderCrypto.SIGNATURE.equals(challenge.get("signature").getAsString()) || !"recover".equals(context.get("mode").getAsString()) + || !config.profile().equals(context.get("profile").getAsString()) || !registrationId.equals(context.get("registrationId").getAsString()) + || !ProviderCrypto.contextDigest(context).equals(challenge.get("contextDigest").getAsString()) + || challenge.get("expiresAt").getAsLong() <= System.currentTimeMillis() + || !"sha256-leading-zero-bits-v0".equals(challenge.getAsJsonObject("pow").get("algorithm").getAsString()) + || challenge.getAsJsonObject("pow").get("difficulty").getAsInt() != 0) + throw new IOException("Unbound recovery challenge"); + String thumbprint = challenge.get("thumbprint").getAsString(); + boolean pending = state.has("pendingPublicKeyJwk") && ProviderCrypto.thumbprint(state.getAsJsonObject("pendingPublicKeyJwk")).equals(thumbprint); + PrivateKey key = pending ? ProviderCrypto.privateKey(state.get("pendingPrivateKey").getAsString()) : privateKey; + if (!pending && !ProviderCrypto.thumbprint(state.getAsJsonObject("publicKeyJwk")).equals(thumbprint)) throw new IOException("Recovery key does not match durable state"); + String intent = UUID.randomUUID().toString(); JsonObject completion = new JsonObject(); + completion.addProperty("protocol", ProviderCrypto.PROTOCOL); completion.addProperty("challengeId", challenge.get("challengeId").getAsString()); + 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", "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()); + state.addProperty("sequence", 0); + if (!state.has("ticketKeys")) state.add("ticketKeys", new JsonArray()); + state.remove("challenge"); + // Completion starts a fresh fenced generation; operational sequencing starts at zero. + if (pending) { state.add("privateKey", state.remove("pendingPrivateKey")); state.add("publicKeyJwk", state.remove("pendingPublicKeyJwk")); privateKey = key; } + save(); + } + private void enroll() throws Exception { + JsonObject challenge; + if (state.has("challenge")) { + String registrationId = state.getAsJsonObject("challenge").get("challengeId").getAsString(); + JsonObject recoveredChallenge = null; + try { recoveredChallenge = unsigned("register", recoveryRequest(registrationId)); } + catch (ProviderException e) { if (e.status != 403) throw e; } + if (recoveredChallenge != null) { completeRecovery(recoveredChallenge, registrationId); return; } + challenge = state.getAsJsonObject("challenge"); + } else { + JsonObject request = new JsonObject(); request.addProperty("protocol", ProviderCrypto.PROTOCOL); request.addProperty("mode", config.registrationMode()); + request.addProperty("profile", config.profile()); request.add("publicKeyJwk", state.get("publicKeyJwk")); if (config.label() != null) request.addProperty("label", config.label()); + JsonObject authorization = new JsonObject(); authorization.addProperty("scheme", config.authorizationScheme()); request.add("authorization", authorization); + if (config.region() != null) { JsonObject p = new JsonObject(); p.addProperty("region", config.region()); p.addProperty("pool", config.pool()); if (!config.tags().isEmpty()) p.add("tags", JSON.toJsonTree(config.tags())); request.add("placement", p); } + challenge = unsigned("register", request, config.authorizationToken()); state.add("challenge", challenge); save(); + } + 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()) || !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"); + } else if (!config.region().equals(context.get("region").getAsString()) || !config.pool().equals(context.get("pool").getAsString()) || + (expectedTagsDigest == null ? context.has("tagsDigest") : !context.has("tagsDigest") || !expectedTagsDigest.equals(context.get("tagsDigest").getAsString()))) throw new IOException("Challenge placement changed"); + if (challenge.has("authorization") && !config.authorizationScheme().equals(challenge.getAsJsonObject("authorization").get("scheme").getAsString())) throw new IOException("Challenge authorization changed"); + if (BEARER_TOKEN.equals(config.authorizationScheme()) && (!challenge.has("authorization") || challenge.getAsJsonObject("authorization").get("reference").getAsString().isBlank())) throw new IOException("Bearer challenge omitted its authority reference"); + String intent = UUID.randomUUID().toString(), nonce = null; + int bits = challenge.getAsJsonObject("pow").get("difficulty").getAsInt(); + if (!"sha256-leading-zero-bits-v0".equals(challenge.getAsJsonObject("pow").get("algorithm").getAsString()) || bits < 0 || bits > 24) throw new IOException("Unsupported proof of work"); + if (BEARER_TOKEN.equals(config.authorizationScheme()) && bits != 0) throw new IOException("Bearer-authorized registration unexpectedly requires proof of work"); + long deadline = challenge.get("expiresAt").getAsLong(); + for (long i = 0; System.currentTimeMillis() < deadline; i++) { String candidate = Long.toString(i); if (ProviderCrypto.meetsDifficulty(ProviderCrypto.digest(ProviderCrypto.proof(challenge, candidate, intent)), bits)) { nonce = candidate; break; } } + if (nonce == null) throw new IOException("Challenge expired before proof completed"); + JsonObject completion = new JsonObject(); completion.addProperty("protocol", ProviderCrypto.PROTOCOL); completion.addProperty("challengeId", challenge.get("challengeId").getAsString()); completion.addProperty("proofNonce", nonce); completion.addProperty("idempotencyKey", intent); completion.addProperty("signature", ProviderCrypto.sign(privateKey, ProviderCrypto.proof(challenge, nonce, intent))); + JsonObject registration = unsigned("complete", completion); ProviderContract.require("registration", registration); validateRegistration(registration); + registrationExtensions = ProtocolExtensions.copy(registration); registration.remove("extensions"); + state.add("registration", registration); state.addProperty("generation", registration.get("leaseGeneration").getAsLong()); state.addProperty("sequence", 0); + state.add("ticketKeys", new JsonArray()); state.remove("challenge"); + 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); + 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(); + if (!expectedRegion.equals(placement.get("region").getAsString()) || !expectedPool.equals(placement.get("pool").getAsString())) throw new IOException("Registration placement changed"); + JsonObject expectedTags = JSON.toJsonTree(config.tags()).getAsJsonObject(); + JsonObject actualTags = placement.has("tags") ? placement.getAsJsonObject("tags") : new JsonObject(); + if (!expectedTags.equals(actualTags)) throw new IOException("Registration placement tags changed"); + } + private void installKeys() throws Exception { + JsonArray retained = new JsonArray(); + if (state.has("ticketKeys")) for (JsonElement entry : state.getAsJsonArray("ticketKeys")) { + JsonObject key = entry.getAsJsonObject(); + if (!key.has("retireAfter") || key.get("retireAfter").getAsLong() > System.currentTimeMillis()) retained.add(key); + } + if (retained.size() > 8) throw new IOException("Too many admission key epochs"); + state.add("ticketKeys", retained); save(); + if (retained.isEmpty()) { + installedKeyId = null; + if (!state.has("keyRequestId")) { state.addProperty("keyRequestId", UUID.randomUUID().toString()); save(); } + return; + } + List keys = new ArrayList<>(); + for (JsonElement entry : retained) { JsonObject key = entry.getAsJsonObject(); + keys.add(new ProviderTransport.TicketKey(key.get("keyId").getAsString(), key.get("secret").getAsString(), + key.has("notBefore") ? key.get("notBefore").getAsLong() : 0, key.has("retireAfter") ? key.get("retireAfter").getAsLong() : Long.MAX_VALUE)); + } + transport.installTicketKeys(List.copyOf(keys)).toCompletableFuture().get(10, TimeUnit.SECONDS); + installedKeyId = keys.getLast().keyId(); + } + /** A full immutable snapshot. Callers may update every one of the seven fields. */ + public void setServerStatus(ServerStatus status) { explicitStatus.set(Objects.requireNonNull(status)); requestStatusRefresh(); } + /** Wake local observation on join/leave/reload; unchanged snapshots never create network traffic. */ + public void requestStatusRefresh() { + if (!closing.get() && refreshQueued.compareAndSet(false, true)) executor.execute(() -> { refreshQueued.set(false); if (!closed && started) { try { if (statusChanged()) heartbeat(); } catch (Exception e) { nextStatusUpdate = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); diagnostics.accept("provider_status_unavailable: " + safeFailure(e)); } } }); + } + private ServerStatus currentStatus() { ServerStatus s = explicitStatus.get(); return s == null && statusSupplier != null ? statusSupplier.get() : s; } + private boolean statusChanged() { + if (System.nanoTime() < nextStatusUpdate) return false; + if (!scheduledCheckIns) return System.nanoTime() >= nextHeartbeat; + 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++) { + JsonObject body = new JsonObject(), profile = null; + if (installedKeyId != null && hostState.equals("serving")) { + profile = transport.hostProfile().toCompletableFuture().get(10, TimeUnit.SECONDS); + if (profile == null) throw new IOException("Transport profile unavailable"); + if (!profile.equals(lastProfile)) body.add("hostProfile", profile); + else if (profileRevision != null) body.addProperty("hostProfileRevision", profileRevision); + } else if (profileRevision != null) body.addProperty("hostProfileRevision", profileRevision); + if (installedKeyId != null) { + JsonArray installed = new JsonArray(); + for (JsonElement key : state.getAsJsonArray("ticketKeys")) installed.add(key.getAsJsonObject().get("keyId")); + body.add("installedKeyIds", installed); + } + if (state.has("keyRequestId")) body.add("keyRequestId", state.get("keyRequestId")); + 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); + body.addProperty("clockUnixMillis", snapshotClock); body.addProperty("checkInVersion", 1); + body.addProperty("state", hostState); body.addProperty("appliedStateRevision", appliedStateRevision); + body.addProperty("gameOutcomes", transport.supportsGameOutcomes() ? "available" : "unavailable"); + ServerStatus status = null; + try { status = currentStatus(); if (status != null) body.add("serverStatus", JSON.toJsonTree(status)); } + catch (RuntimeException failure) { diagnostics.accept("status_refresh_failed"); } + long requestStarted = System.nanoTime(); + JsonObject response = signed("heartbeat", "POST", body); + ProtocolExtensions.validate(response); + if (body.has("hostProfile")) { + if (!response.has("hostProfileRevision") || response.get("hostProfileRevision").isJsonNull()) throw new IOException("Profile acknowledgement missing"); + profileRevision = response.get("hostProfileRevision").getAsString(); lastProfile = profile.deepCopy(); + state.addProperty("profilePublishedAt", System.currentTimeMillis()); save(); + } + boolean again = false; + if (response.has("ticketKey")) { + JsonObject key = response.remove("ticketKey").getAsJsonObject(); + if (!state.has("keyRequestId") || !response.has("keyRequest") || + !state.get("keyRequestId").equals(response.getAsJsonObject("keyRequest").get("id")) || + !key.get("keyId").equals(response.getAsJsonObject("keyRequest").get("keyId"))) throw new IOException("Unbound admission key response"); + state.getAsJsonArray("ticketKeys").add(key); state.remove("keyRequestId"); save(); + installKeys(); lastProfile = null; again = true; + } else if (state.has("keyRequestId") && response.has("keyRequest") && + state.get("keyRequestId").equals(response.getAsJsonObject("keyRequest").get("id"))) { + // The provider confirms delivery but the one-time response was lost. + state.addProperty("keyRequestId", UUID.randomUUID().toString()); save(); again = true; + } + if (response.has("retirements") && !response.getAsJsonArray("retirements").isEmpty()) { + for (JsonElement retirement : response.getAsJsonArray("retirements")) for (JsonElement stored : state.getAsJsonArray("ticketKeys")) { + JsonObject retired = retirement.getAsJsonObject(), key = stored.getAsJsonObject(); + if (retired.get("keyId").equals(key.get("keyId"))) key.addProperty("retireAfter", Math.min( + key.has("retireAfter") ? key.get("retireAfter").getAsLong() : Long.MAX_VALUE, retired.get("retireAfter").getAsLong())); + } + save(); installKeys(); + } + if (response.has("checkIn")) { + CheckInSchedule schedule = CheckInSchedule.parse(response); + scheduledCheckIns = true; minUpdateIntervalMs = schedule.minUpdateIntervalMillis(); + long received = java.time.Instant.parse(response.get("receivedAt").getAsString()).toEpochMilli(); + long remaining = Math.min(schedule.afterMillis(), Math.max(0, response.getAsJsonObject("checkIn").get("nextCheckInAt").getAsLong() - Math.max(received, System.currentTimeMillis()))); + nextHeartbeat = Math.min(requestStarted + TimeUnit.MILLISECONDS.toNanos(schedule.afterMillis()), System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(remaining)); + } else { + scheduledCheckIns = false; + nextHeartbeat = requestStarted + TimeUnit.MILLISECONDS.toNanos(intervalMs); + } + nextStatusUpdate = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(scheduledCheckIns ? minUpdateIntervalMs : intervalMs); + lastReportedStatus = status; lastReportedHealth = health; lastHeartbeat = response.deepCopy(); + JsonObject desired = response.getAsJsonObject("desiredState"); + if (desired == null || !desired.has("revision") || !desired.has("state")) throw new IOException("Provider state missing"); + long revision = desired.getAsJsonPrimitive("revision").getAsBigDecimal().longValueExact(); String target = desired.get("state").getAsString(); + if (revision < appliedStateRevision || !Set.of("serving", "draining", "closed").contains(target)) throw new IOException("Unsupported provider state"); + if (revision > appliedStateRevision) { + ProviderTransport.ApplyResult applied = target.equals("serving") ? ProviderTransport.ApplyResult.APPLIED + : transport.applyState(target).toCompletableFuture().get(10, TimeUnit.SECONDS); + if (applied == ProviderTransport.ApplyResult.APPLIED) { + appliedStateRevision = revision; + if (!target.equals("serving")) { hostState = target; again = true; } + } else { + diagnostics.accept("provider_state_not_applied"); + nextHeartbeat = Math.min(nextHeartbeat, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + } + } + if (!again) return; + } + nextHeartbeat = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + } + private void flushEvents() throws Exception { + if (!state.has("pendingEvents")) state.add("pendingEvents", new JsonArray()); + JsonArray pending = state.getAsJsonArray("pendingEvents"); + List fresh = transport.pollEvents(); + if (fresh.size() > 100 || pending.size() + fresh.size() > 1000) throw new IOException("Transport event queue exceeds limit"); + for (JsonObject event : fresh) { + // Persist only the existing redacted telemetry fields, never native SDP or secret extensions. + JsonObject safe = new JsonObject(); + for (String field : List.of("stage", "ticketId", "occurredAt", "reason")) if (event.has(field)) safe.add(field, event.get(field)); + if (!safe.has("stage") || !safe.has("ticketId") || !safe.has("occurredAt")) throw new IOException("Malformed transport event"); + pending.add(safe); + } + if (pending.isEmpty()) return; + save(); + JsonArray batch = new JsonArray(); + for (JsonElement event : pending) if (batch.size() < 100) batch.add(event); + JsonObject body = new JsonObject(); body.add("events", batch); signed("outcomes", "POST", body); + for (JsonElement sent : batch) pending.remove(sent); save(); + } + /** Refresh through the ordinary heartbeat and return its readiness observation. */ + public CompletableFuture readiness() { return submit(() -> { heartbeat(); return lastHeartbeat.deepCopy(); }); } + /** Opaque optional extension metadata; the application decides what it means. */ + public CompletableFuture extensions() { return submit(() -> registrationExtensions.deepCopy()); } + /** Explicit application request to an advertised extension operation, never automatic execution. */ + public CompletableFuture extensionRequest(String namespace, String operation, String method, JsonObject body) { + return submit(() -> { + if (!Set.of("GET", "POST").contains(method)) throw new IOException("Unsupported extension method"); + JsonObject extension = ProtocolExtensions.copy(discovery).getAsJsonObject(namespace); + if (extension == null) throw new IOException("Extension unavailable"); + JsonObject operations = extension.getAsJsonObject("data").getAsJsonObject("operations"); + if (operations == null || !operations.has(operation)) throw new IOException("Extension operation unavailable"); + URI uri = trusted(URI.create(operations.get(operation).getAsString())); + long sequence = state.has("sequence") ? state.get("sequence").getAsLong() + 1 : 1; + state.addProperty("sequence", sequence); save(); + JsonObject response = exchange(uri, method, body == null ? null : JSON.toJson(body), true, UUID.randomUUID().toString(), null); + ProtocolExtensions.validate(response); return response; + }); + } + public CompletableFuture deregister() { return submit(() -> { + signed("deregister", "POST", new JsonObject()); transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); started = false; return null; + }); } + public CompletableFuture rotateTicketKey() { return submit(() -> { + installKeys(); + if (state.getAsJsonArray("ticketKeys").size() >= 8) throw new IOException("Wait for retiring admission epochs before rotating again"); + state.addProperty("keyRequestId", UUID.randomUUID().toString()); save(); heartbeat(); return redactedRegistration(); + }); } + public CompletableFuture rotateMachineKey() { return submit(() -> { + KeyPair replacement = ProviderCrypto.generate(); JsonObject jwk = ProviderCrypto.publicJwk(replacement.getPublic()); + state.addProperty("pendingPrivateKey", ProviderCrypto.base64(replacement.getPrivate().getEncoded())); state.add("pendingPublicKeyJwk", jwk); save(); + String intent = UUID.randomUUID().toString(); JsonObject body = new JsonObject(); body.add("publicKeyJwk", jwk); + body.addProperty("proof", ProviderCrypto.sign(replacement.getPrivate(), ProviderCrypto.array(ProviderCrypto.PROTOCOL, "rotate", origin, registration("instanceId"), registration("keyId"), ProviderCrypto.thumbprint(jwk), state.get("generation").getAsLong(), intent))); + JsonObject result = signed("rotate", "POST", body, intent); String oldKey = registration("keyId"); + state.add("privateKey", state.remove("pendingPrivateKey")); state.add("publicKeyJwk", state.remove("pendingPublicKeyJwk")); state.getAsJsonObject("registration").addProperty("keyId", result.get("keyId").getAsString()); save(); privateKey = replacement.getPrivate(); + JsonObject retire = new JsonObject(); retire.addProperty("keyId", oldKey); signed("retire", "POST", retire); return result; + }); } + public CompletableFuture drain() { return submit(() -> { drainAndReport(); started = false; return null; }); } + private void drainAndReport() throws Exception { + if (!hostState.equals("closed")) { transport.drain().toCompletableFuture().get(10, TimeUnit.SECONDS); hostState = "draining"; } + heartbeat(); + } + private JsonObject unsigned(String op, JsonObject body) throws Exception { return unsigned(op, body, null); } + private JsonObject unsigned(String op, JsonObject body, String bearerToken) throws Exception { return exchange(operation(op), "POST", JSON.toJson(body), false, null, bearerToken); } + private JsonObject signed(String op, String method, JsonObject body) throws Exception { return signed(op, method, body, UUID.randomUUID().toString()); } + private JsonObject signed(String op, String method, JsonObject body, String intent) throws Exception { + long sequence = state.has("sequence") ? state.get("sequence").getAsLong() + 1 : 1; state.addProperty("sequence", sequence); save(); + URI uri = operation(op); + return exchange(uri, method, body == null ? null : JSON.toJson(body), true, intent, null, op.equals("outcomes") ? 3 : 15, op.equals("outcomes") ? 1 : 3); + } + private URI operation(String op) throws IOException { if (!discovery.getAsJsonObject("operations").has(op)) throw new IOException("Missing provider operation: " + op); return trusted(URI.create(discovery.getAsJsonObject("operations").get(op).getAsString())); } + private URI trusted(URI uri) throws IOException { + URI authority = URI.create(uri.getScheme() + "://" + uri.getRawAuthority()); + if (!ProviderCrypto.origin(authority).equals(origin) || uri.getUserInfo() != null || uri.getFragment() != null) throw new IOException("Untrusted provider operation"); return uri; + } + private JsonObject exchange(URI uri, String method, String body, boolean signed, String intent, String bearerToken) throws Exception { + return exchange(uri, method, body, signed, intent, bearerToken, 15, 3); + } + private JsonObject exchange(URI uri, String method, String body, boolean signed, String intent, String bearerToken, int timeoutSeconds, int attempts) throws Exception { + trusted(uri); String raw = body == null ? "" : body; + for (int attempt = 0; attempt < attempts; attempt++) { + HttpRequest.Builder b = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(timeoutSeconds)).header("accept", "application/json").method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)); + if (body != null) b.header("content-type", "application/json"); + if (bearerToken != null) b.header("authorization", "Bearer " + bearerToken); + if (signed) { + long now = System.currentTimeMillis(), generation = state.get("generation").getAsLong(), sequence = state.get("sequence").getAsLong(); + String path = uri.getRawPath() + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery()); + b.header("nxs-instance-id", registration("instanceId")).header("nxs-key-id", registration("keyId")).header("nxs-timestamp", Long.toString(now)).header("nxs-signature-version", ProviderCrypto.SIGNATURE).header("nxs-generation", Long.toString(generation)).header("nxs-sequence", Long.toString(sequence)).header("idempotency-key", intent).header("nxs-signature", ProviderCrypto.sign(privateKey, ProviderCrypto.request(origin, method, path, now, registration("instanceId"), registration("keyId"), intent, generation, sequence, raw))); + } + HttpResponse response; + var responseFuture = http.sendAsync(b.build(), info -> new LimitedBodySubscriber(65536)); + try { response = responseFuture.get(timeoutSeconds + 1, TimeUnit.SECONDS); } + catch (ExecutionException | TimeoutException failure) { + responseFuture.cancel(true); + if (attempt == attempts - 1) throw new IOException("Provider transport unavailable", failure); + Thread.sleep((250L << attempt) + ThreadLocalRandom.current().nextLong(100)); continue; + } + String text = new String(response.body(), java.nio.charset.StandardCharsets.UTF_8); + int status = response.statusCode(); + if ((status == 429 || status == 503 || status == 502 || status == 504) && attempt < attempts - 1) { long delay = 250L << attempt; + try { delay = Math.max(delay, Long.parseLong(response.headers().firstValue("retry-after").orElse("0")) * 1000); } catch (NumberFormatException ignored) { } + if (delay > 10000) throw new ProviderException(status, "retry_later"); Thread.sleep(delay + ThreadLocalRandom.current().nextLong(100)); continue; + } + if (status / 100 != 2) { String code = "request_rejected"; try { JsonObject error = JsonParser.parseString(text).getAsJsonObject(); if (error.has("code") && error.get("code").getAsString().matches("[a-z0-9_]{1,80}")) code = error.get("code").getAsString(); } catch (RuntimeException ignored) {} throw new ProviderException(status, code); } + return JsonParser.parseString(text).getAsJsonObject(); + } + throw new IOException("Provider retry limit exceeded"); + } + private String registration(String field) { return state.getAsJsonObject("registration").get(field).getAsString(); } + private JsonObject redactedRegistration() { JsonObject copy = state.getAsJsonObject("registration").deepCopy(); copy.remove("ticketKey"); if (!registrationExtensions.isEmpty()) copy.add("extensions", registrationExtensions.deepCopy()); return copy; } + private void save() throws IOException { + try { store.write(state); } + catch (IOException failure) { diagnostics.accept("provider_persistence_failed"); stop(); throw failure; } + } + private static String safeFailure(Exception e) { return e instanceof ProviderException ? e.getMessage() : e.getClass().getSimpleName(); } + private CompletableFuture submit(Callable fn) { + CompletableFuture f = new CompletableFuture<>(); executor.execute(() -> { try { if (closed) throw new IOException("Provider is closed"); f.complete(fn.call()); } catch (Throwable e) { f.completeExceptionally(e); } }); return f; + } + public CompletionStage stop() { + if (!closing.compareAndSet(false, true)) return stopped; + executor.execute(() -> { try { if (started) { drainAndReport(); flushEvents(); } } catch (Exception e) { diagnostics.accept("provider_drain_unavailable"); } + finally { + closed = true; started = false; if (timer != null) timer.cancel(false); + try { transport.close().toCompletableFuture().get(10, TimeUnit.SECONDS); } + catch (Exception e) { diagnostics.accept("transport_close_failed"); } + try { store.close(); } catch (Exception e) { diagnostics.accept("provider_state_close_failed"); } + http.close(); executor.shutdown(); stopped.complete(null); + } + }); + return stopped; + } + @Override public void close() { stop(); } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderContract.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderContract.java new file mode 100644 index 00000000..8e2bca46 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderContract.java @@ -0,0 +1,30 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** Canonical schema is owned by docs/external-signalling; JVM and other implementations consume the same resource. */ +final class ProviderContract { + private static final JsonObject SCHEMA; + static { + try (var in = ProviderContract.class.getResourceAsStream("/nxs-v1.schema.json")) { + SCHEMA = JsonParser.parseString(new String(Objects.requireNonNull(in).readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); + } catch (Exception e) { throw new ExceptionInInitializerError(e); } + } + static void require(String document, JsonObject value) { + JsonObject schema = SCHEMA.getAsJsonObject("$defs").getAsJsonObject(document); + for (JsonElement field : schema.getAsJsonArray("required")) if (!value.has(field.getAsString()) || value.get(field.getAsString()).isJsonNull()) throw new IllegalArgumentException("Missing required provider field: " + field.getAsString()); + if (schema.has("properties")) for (var p : schema.getAsJsonObject("properties").entrySet()) { + JsonObject property = p.getValue().getAsJsonObject(); + if (property.has("const") && !property.get("const").equals(value.get(p.getKey()))) throw new IllegalArgumentException("Unsupported provider field: " + p.getKey()); + } + } + static java.util.List operations() { return SCHEMA.getAsJsonArray("x-operations").asList().stream().map(JsonElement::getAsString).toList(); } + static Object[] contextValues(JsonObject context) { + List values = new ArrayList<>(); + for (JsonElement key : SCHEMA.getAsJsonArray("x-context-order")) values.add(context.get(key.getAsString()).getAsString()); + if (SCHEMA.has("x-optional-context-order")) for (JsonElement key : SCHEMA.getAsJsonArray("x-optional-context-order")) if (context.has(key.getAsString())) values.add(context.get(key.getAsString()).getAsString()); + return values.toArray(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderCrypto.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderCrypto.java new file mode 100644 index 00000000..7a3cbd38 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderCrypto.java @@ -0,0 +1,80 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.math.BigInteger; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.interfaces.ECPublicKey; +import java.security.spec.*; +import java.util.*; + +/** Exact v0 canonical bytes. P1363 explicitly avoids the JVM's default DER ECDSA encoding. */ +public final class ProviderCrypto { + public static final String PROTOCOL = "nethernet-external-signalling-v1"; + public static final String SIGNATURE = "nxs-es384-v1"; + private ProviderCrypto() {} + public static KeyPair generate() throws GeneralSecurityException { + KeyPairGenerator g = KeyPairGenerator.getInstance("EC"); g.initialize(new ECGenParameterSpec("secp384r1")); return g.generateKeyPair(); + } + public static String base64(byte[] b) { return Base64.getUrlEncoder().withoutPadding().encodeToString(b); } + public static byte[] decode(String s) { + if (!s.matches("[A-Za-z0-9_-]+")) throw new IllegalArgumentException("Invalid base64url"); + byte[] b = Base64.getUrlDecoder().decode(s); + if (!base64(b).equals(s)) throw new IllegalArgumentException("Noncanonical base64url"); return b; + } + public static byte[] digest(String s) { + try { return MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)); } + catch (GeneralSecurityException e) { throw new IllegalStateException(e); } + } + public static JsonObject publicJwk(PublicKey key) { + ECPublicKey ec = (ECPublicKey) key; JsonObject j = new JsonObject(); + j.addProperty("crv", "P-384"); j.addProperty("kty", "EC"); j.addProperty("x", base64(coordinate(ec.getW().getAffineX()))); j.addProperty("y", base64(coordinate(ec.getW().getAffineY()))); return j; + } + private static byte[] coordinate(BigInteger n) { byte[] raw = n.toByteArray(), out = new byte[48]; System.arraycopy(raw, Math.max(0, raw.length - 48), out, Math.max(0, 48 - raw.length), Math.min(48, raw.length)); return out; } + public static PublicKey publicKey(JsonObject j) throws GeneralSecurityException { + validateJwk(j); AlgorithmParameters p = AlgorithmParameters.getInstance("EC"); p.init(new ECGenParameterSpec("secp384r1")); + return KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(new BigInteger(1, decode(j.get("x").getAsString())), new BigInteger(1, decode(j.get("y").getAsString()))), p.getParameterSpec(ECParameterSpec.class))); + } + public static PrivateKey privateKey(String encoded) throws GeneralSecurityException { return KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(decode(encoded))); } + private static void validateJwk(JsonObject j) { + if (j.has("d") || !"EC".equals(j.get("kty").getAsString()) || !"P-384".equals(j.get("crv").getAsString()) || !j.get("x").getAsString().matches("[A-Za-z0-9_-]{64}") || !j.get("y").getAsString().matches("[A-Za-z0-9_-]{64}")) throw new IllegalArgumentException("Invalid public P-384 JWK"); + } + public static String thumbprint(JsonObject j) { + validateJwk(j); return base64(digest("{\"crv\":\"P-384\",\"kty\":\"EC\",\"x\":\"" + j.get("x").getAsString() + "\",\"y\":\"" + j.get("y").getAsString() + "\"}")); + } + public static String sign(PrivateKey key, String payload) throws GeneralSecurityException { + Signature s = Signature.getInstance("SHA384withECDSAinP1363Format"); s.initSign(key); s.update(payload.getBytes(StandardCharsets.UTF_8)); return base64(s.sign()); + } + public static boolean verify(JsonObject key, String signature, String payload) { + try { byte[] raw = decode(signature); if (raw.length != 96) return false; + Signature s = Signature.getInstance("SHA384withECDSAinP1363Format"); s.initVerify(publicKey(key)); s.update(payload.getBytes(StandardCharsets.UTF_8)); return s.verify(raw); + } catch (GeneralSecurityException | RuntimeException e) { return false; } + } + public static String contextDigest(JsonObject c) { return base64(digest(array(ProviderContract.contextValues(c)))); } + public static String tagsDigest(Map tags) { + if (tags == null || tags.isEmpty()) return null; + StringJoiner entries = new StringJoiner(",", "[", "]"); + new TreeMap<>(tags).forEach((key, value) -> entries.add("[" + quote(key) + "," + quote(value) + "]")); + return base64(digest(entries.toString())); + } + public static String proof(JsonObject c, String nonce, String intent) { return array(PROTOCOL, "complete", c.get("audience").getAsString(), c.get("challengeId").getAsString(), c.get("nonce").getAsString(), c.get("thumbprint").getAsString(), c.get("contextDigest").getAsString(), c.get("expiresAt").getAsLong(), nonce, intent); } + public static boolean meetsDifficulty(byte[] bytes, int bits) { if (bits < 0 || bits > 24) return false; for (int i = 0; i < bits; i++) if ((bytes[i / 8] & (128 >> (i % 8))) != 0) return false; return true; } + public static String request(String audience, String method, String path, long timestamp, String instance, String key, String intent, long generation, long sequence, String body) { return array(PROTOCOL, SIGNATURE, audience, method, path, timestamp, instance, key, intent, generation, sequence, base64(digest(body))); } + public static String origin(URI u) { + if (u.getHost() == null || u.getUserInfo() != null || u.getFragment() != null || u.getQuery() != null || !(u.getPath().isEmpty() || u.getPath().equals("/"))) throw new IllegalArgumentException("Invalid provider origin"); + String host = u.getHost().toLowerCase(Locale.ROOT), scheme = u.getScheme().toLowerCase(Locale.ROOT); + if (!scheme.equals("https") && !(scheme.equals("http") && Set.of("localhost", "127.0.0.1", "[::1]").contains(host))) throw new IllegalArgumentException("HTTPS provider required"); + int port = u.getPort(); return scheme + "://" + host + (port < 0 || (scheme.equals("https") && port == 443) || (scheme.equals("http") && port == 80) ? "" : ":" + port); + } + public static String array(Object... values) { + StringJoiner out = new StringJoiner(",", "[", "]"); for (Object v : values) out.add(v instanceof String s ? quote(s) : String.valueOf(v)); return out.toString(); + } + public static String quote(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); switch (c) { + case '"' -> b.append("\\\""); case '\\' -> b.append("\\\\"); case '\b' -> b.append("\\b"); case '\f' -> b.append("\\f"); case '\n' -> b.append("\\n"); case '\r' -> b.append("\\r"); case '\t' -> b.append("\\t"); + default -> { if (c < 32 || (Character.isSurrogate(c) && !(Character.isHighSurrogate(c) && i + 1 < s.length() && Character.isLowSurrogate(s.charAt(i + 1))) && !(Character.isLowSurrogate(c) && i > 0 && Character.isHighSurrogate(s.charAt(i - 1))))) b.append(String.format("\\u%04x", (int)c)); else b.append(c); } + }} return b.append('"').toString(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderIdentity.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderIdentity.java new file mode 100644 index 00000000..189cc15b --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderIdentity.java @@ -0,0 +1,18 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.net.URI; +import java.security.KeyPair; + +/** Bootstrap controllers need the public key before issuing a key-bound attachment grant. */ +public final class ProviderIdentity { + private ProviderIdentity() {} + public static JsonObject initialize(ProviderStateStore store, URI provider) throws Exception { + String origin = ProviderCrypto.origin(provider); JsonObject state = store.read(); + if (state.has("provider") && !origin.equals(state.get("provider").getAsString())) throw new IllegalArgumentException("Provider state mismatch"); + if (!state.has("privateKey")) { + KeyPair pair = ProviderCrypto.generate(); state.addProperty("provider", origin); state.addProperty("privateKey", ProviderCrypto.base64(pair.getPrivate().getEncoded())); state.add("publicKeyJwk", ProviderCrypto.publicJwk(pair.getPublic())); state.addProperty("generation", 0); state.addProperty("sequence", 0); store.write(state); + } + return state.getAsJsonObject("publicKeyJwk").deepCopy(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderStateStore.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderStateStore.java new file mode 100644 index 00000000..a5573c02 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderStateStore.java @@ -0,0 +1,44 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.*; +import java.nio.file.*; +import java.nio.file.attribute.PosixFilePermissions; + +/** One logical instance owns this directory and key; never share or clone it across live instances. */ +public final class ProviderStateStore implements AutoCloseable { + private final Path directory, stateFile; + private final FileChannel lockChannel; + private final FileLock lock; + public ProviderStateStore(Path directory) throws IOException { + this.directory = directory.toAbsolutePath(); this.stateFile = this.directory.resolve("provider-state.json"); + Files.createDirectories(this.directory, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + if (Files.isSymbolicLink(this.directory) || Files.isSymbolicLink(stateFile)) throw new IOException("State paths must not be symbolic links"); + Files.setPosixFilePermissions(this.directory, PosixFilePermissions.fromString("rwx------")); + Path lockFile = this.directory.resolve("provider.lock"); + if (Files.isSymbolicLink(lockFile)) throw new IOException("Lock must not be a symbolic link"); + lockChannel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock acquired; + try { acquired = lockChannel.tryLock(); } catch (OverlappingFileLockException e) { lockChannel.close(); throw new IOException("State directory is already active", e); } + if (acquired == null) { lockChannel.close(); throw new IOException("State directory is already active"); } + lock = acquired; + } + public JsonObject read() throws IOException { + if (!Files.exists(stateFile)) return new JsonObject(); + if (Files.size(stateFile) > 262144) throw new IOException("State exceeds limit"); + Files.setPosixFilePermissions(stateFile, PosixFilePermissions.fromString("rw-------")); + return JsonParser.parseString(Files.readString(stateFile)).getAsJsonObject(); + } + public void write(JsonObject state) throws IOException { + Path tmp = Files.createTempFile(directory, "provider-state-", ".tmp", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + try { + byte[] bytes = new GsonBuilder().disableHtmlEscaping().create().toJson(state).getBytes(java.nio.charset.StandardCharsets.UTF_8); + try (FileChannel file = FileChannel.open(tmp, StandardOpenOption.WRITE)) { ByteBuffer b = ByteBuffer.wrap(bytes); while (b.hasRemaining()) file.write(b); file.force(true); } + Files.move(tmp, stateFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + try (FileChannel dir = FileChannel.open(directory, StandardOpenOption.READ)) { dir.force(true); } + } finally { Files.deleteIfExists(tmp); } + } + @Override public void close() throws IOException { try { lock.release(); } finally { lockChannel.close(); } } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java new file mode 100644 index 00000000..dcffb469 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderTransport.java @@ -0,0 +1,26 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.util.List; +import java.util.concurrent.CompletionStage; + +/** Transport boundary. Provider code performs no native allocation or game packet handling. */ +public interface ProviderTransport { + enum ApplyResult { PENDING, APPLIED, REJECTED } + /** Existing PublishHostProfileRequest, exported from actual bound native metadata. */ + CompletionStage hostProfile(); + /** Atomic snapshot; completion means every supplied key is persisted and usable. */ + CompletionStage installTicketKeys(List keys); + /** Apply serving/draining/closed background state before acknowledging its revision. */ + CompletionStage applyState(String state); + /** Whether this integration can observe the application join/rejection boundary. */ + default boolean supportsGameOutcomes() { return false; } + /** Bounded ticket-correlated transport and application observations. */ + List pollEvents(); + CompletionStage drain(); + CompletionStage close(); + record TicketKey(String keyId, String secret, long notBefore, long retireAfter) { + public TicketKey(String keyId, String secret) { this(keyId, secret, 0, Long.MAX_VALUE); } + @Override public String toString() { return "TicketKey[keyId=" + keyId + "]"; } + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ServerStatus.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ServerStatus.java new file mode 100644 index 00000000..4d7272a7 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ServerStatus.java @@ -0,0 +1,11 @@ +package org.cloudburstmc.netty.signalling; + +/** Complete atomic snapshot. Advertised maxPlayers is independent of routing capacity. */ +public record ServerStatus(String name, int protocol, String version, String level, int players, int maxPlayers, int gameType) { + public ServerStatus { + if (name == null || name.isEmpty() || name.codePointCount(0, name.length()) > 128 || version == null || version.isEmpty() || version.length() > 64 || + level == null || level.codePointCount(0, level.length()) > 128 || protocol < 1 || players < 0 || players > 1_000_000 || maxPlayers < 0 || maxPlayers > 1_000_000 || gameType < 0 || gameType > 2) + throw new IllegalArgumentException("Invalid complete server status snapshot"); + for (String value : java.util.List.of(name, version, level)) if (value.codePoints().anyMatch(c -> Character.getType(c) == Character.CONTROL || Character.getType(c) == Character.SURROGATE)) throw new IllegalArgumentException("Invalid status text"); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/EndpointAddress.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/EndpointAddress.java new file mode 100644 index 00000000..553dde3d --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/EndpointAddress.java @@ -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)); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java new file mode 100644 index 00000000..dfc88326 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/NativeProviderTransport.java @@ -0,0 +1,133 @@ +package org.cloudburstmc.netty.signalling.admission; + +import com.google.gson.*; +import dev.kastle.netty.channel.nethernet.admission.*; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.util.concurrent.ScheduledFuture; +import org.cloudburstmc.netty.signalling.ProviderTransport; +import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.security.SecureRandom; +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 { + public static final String CAPABILITY = "nethernet.stateless-admission.v1"; + private record Epoch(String id, long notBefore, long retireAfter) {} + private final NativeAdmissionServerChannel channel; + private final StatelessAdmissionValidator validator; + private final String incarnation; + private final Supplier> advertisedAddresses; + private final ScheduledFuture retireTask; + private List epochs = List.of(); + private boolean draining, closed; + + private NativeProviderTransport(NativeAdmissionServerChannel channel, StatelessAdmissionValidator validator, String incarnation, Supplier> advertisedAddresses) { + this.channel = channel; this.validator = validator; this.incarnation = incarnation; + 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. */ + public static CompletionStage open(ServerBootstrap bootstrap, InetSocketAddress bind, Path certificate, Path privateKey, AdmissionGate.Limits limits) { + return open(bootstrap, bind, bind, certificate, privateKey, limits); + } + /** Explicit advertised candidate supports wildcard/local binds and operator-provisioned NAT mappings. */ + public static CompletionStage 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 open(ServerBootstrap bootstrap, InetSocketAddress bind, Supplier> advertised, Path certificate, Path privateKey, AdmissionGate.Limits limits) { + CompletableFuture result = new CompletableFuture<>(); + try { + checkedEndpoints(advertised.get()); + NativeHostIdentity identity = NativeHostIdentity.load(certificate, privateKey); + byte[] nonce = new byte[16]; new SecureRandom().nextBytes(nonce); + String incarnation = HexFormat.of().formatHex(nonce); + var validator = new StatelessAdmissionValidator(audience(incarnation), 60_000); + var endpoint = new NativeAdmissionServerChannel(identity, validator, limits, true); + bootstrap.clone().channelFactory(() -> endpoint).bind(bind).addListener(future -> { + if (future.isSuccess()) result.complete(new NativeProviderTransport(endpoint, validator, incarnation, advertised)); + else { endpoint.close(); validator.clear(); result.completeExceptionally(future.cause()); } + }); + } catch (Exception failure) { result.completeExceptionally(failure); } + return result; + } + public static String audience(String incarnation) { + if (incarnation == null || !incarnation.matches("[0-9a-f]{32}")) throw new IllegalArgumentException("Invalid endpoint incarnation"); + return "nxs-stateless-host-v1/" + incarnation; + } + public NativeAdmissionServerChannel channel() { return channel; } + + @Override public synchronized CompletionStage hostProfile() { + if (closed || draining || !channel.isActive()) return CompletableFuture.failedFuture(new IllegalStateException("Native endpoint unavailable")); + long now = System.currentTimeMillis(); String keyId = null; + Set installed = validator.keyIds(); + // 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")); + List 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 checkedEndpoints(List endpoints) { + List 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 installTicketKeys(List keys) { + if (closed) return CompletableFuture.failedFuture(new IllegalStateException("Native endpoint closed")); + try { + if (keys == null || keys.size() > 8) throw new IllegalArgumentException("At most eight admission epochs"); + validator.installKeys(keys.stream().map(k -> new StatelessAdmissionValidator.TicketKey(k.keyId(), k.secret(), k.notBefore(), k.retireAfter())).toList()); + epochs = keys.stream().map(k -> new Epoch(k.keyId(), k.notBefore(), k.retireAfter())).toList(); + validator.retireKeys(System.currentTimeMillis()); + return CompletableFuture.completedFuture(null); + } catch (Exception invalid) { return CompletableFuture.failedFuture(invalid); } + } + @Override public CompletionStage applyState(String state) { + if (state == null) return CompletableFuture.completedFuture(ApplyResult.REJECTED); + return switch (state) { + case "serving" -> CompletableFuture.completedFuture(ApplyResult.APPLIED); + case "draining" -> drain().thenApply(ignored -> ApplyResult.APPLIED); + case "closed" -> close().thenApply(ignored -> ApplyResult.APPLIED); + default -> CompletableFuture.completedFuture(ApplyResult.REJECTED); + }; + } + @Override public List pollEvents() { + return channel.pollEvents().stream().map(event -> { + JsonObject result = new JsonObject(); result.addProperty("ticketId", event.ticketId()); result.addProperty("stage", event.stage()); + result.addProperty("reason", event.reason()); result.addProperty("occurredAt", Instant.ofEpochMilli(event.occurredAt()).toString()); + return result; + }).toList(); + } + @Override public synchronized CompletionStage drain() { + draining = true; channel.drainAdmissions(); return CompletableFuture.completedFuture(null); + } + @Override public synchronized CompletionStage close() { + if (!closed) { + closed = true; draining = true; retireTask.cancel(false); validator.clear(); epochs = List.of(); channel.close(); + } + return channel.termination(); + } +} diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java new file mode 100644 index 00000000..0ce41a31 --- /dev/null +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidator.java @@ -0,0 +1,105 @@ +package org.cloudburstmc.netty.signalling.admission; + +import dev.kastle.netty.channel.nethernet.admission.AdmissionValidator; +import dev.kastle.netty.channel.nethernet.admission.AdmissionRequest; +import dev.kastle.netty.channel.nethernet.admission.VerifiedAdmission; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +/** NXS1 token validation using locally installed keys and the incoming ICE username. */ +public final class StatelessAdmissionValidator implements AdmissionValidator { + public record TicketKey(String keyId, String secret, long notBefore, long retireAfter) { + public TicketKey(String keyId, String secret) { this(keyId, secret, 0, Long.MAX_VALUE); } + @Override public String toString() { return "TicketKey[keyId=" + keyId + "]"; } + } + private record Material(byte[] encryption, byte[] secret, long notBefore, long retireAfter) { + void erase() { Arrays.fill(encryption, (byte)0); Arrays.fill(secret, (byte)0); } + } + private static final Base64.Encoder BASE64 = Base64.getEncoder().withoutPadding(); + private final String audience; + private final long maxTtlMs; + private volatile Map keys = Map.of(); + + public StatelessAdmissionValidator(String audience, long maxTtlMs) { + if (audience == null || audience.isEmpty() || audience.length() > 512 || audience.indexOf(0) >= 0 || maxTtlMs <= 0 || maxTtlMs > 120_000) throw new IllegalArgumentException("Admission context"); + this.audience = audience; + this.maxTtlMs = maxTtlMs; + } + + /** Validates everything before atomically replacing a bounded snapshot. */ + public synchronized void installKeys(List snapshot) { + if (snapshot.size() > 8) throw new IllegalArgumentException("At most eight admission epochs"); + Map next = new HashMap<>(); + for (TicketKey key : snapshot) { + if (key.keyId() == null || !key.keyId().matches("[A-Z0-9]{4}") || key.secret() == null || key.secret().length() < 32 || key.secret().length() > 256 || next.containsKey(key.keyId()) || key.notBefore() < 0 || key.retireAfter() <= key.notBefore()) throw new IllegalArgumentException("Invalid admission key snapshot"); + byte[] secret = utf8(key.secret()); + next.put(key.keyId(), new Material(hmac("HmacSHA256", secret, utf8("nxs-stateless-aead-v1\0" + audience)), secret, key.notBefore(), key.retireAfter())); + } + Map previous = keys; keys = Map.copyOf(next); + previous.values().forEach(Material::erase); + } + + public synchronized void retireKeys(long nowMillis) { + Map retained = new HashMap<>(); + for (var entry : keys.entrySet()) { + if (entry.getValue().retireAfter() <= nowMillis) entry.getValue().erase(); + else retained.put(entry.getKey(), entry.getValue()); + } + keys = Map.copyOf(retained); + } + public boolean ready() { return !keys.isEmpty(); } + public Set keyIds() { return keys.keySet(); } + public synchronized void clear() { keys.values().forEach(Material::erase); keys = Map.of(); } + + @Override public synchronized VerifiedAdmission validate(AdmissionRequest request, long nowMillis) { + if (request == null) return null; + byte[] plaintext = null; + try { + String token = request.localUfrag(); + if (token.length() < 8 || !token.startsWith("NXS1")) return null; + String keyId = token.substring(4, 8); + Material key = keys.get(keyId); + if (key == null || nowMillis < key.notBefore() || nowMillis >= key.retireAfter()) return null; + String encoded = token.substring(8); + byte[] envelope = Base64.getDecoder().decode(encoded); + if (envelope.length < 117 || envelope.length > 186 || !BASE64.encodeToString(envelope).equals(encoded)) return null; + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.encryption(), "AES"), new GCMParameterSpec(128, Arrays.copyOf(envelope, 12))); + cipher.updateAAD(utf8("nxs-stateless-admission-v1\0" + token.substring(0, 8) + "\0" + audience + "\0" + request.remoteUfrag())); + plaintext = cipher.doFinal(Arrays.copyOfRange(envelope, 12, envelope.length)); + if (plaintext.length < 89) return null; + ByteBuffer body = ByteBuffer.wrap(plaintext); + long expiresAt = Integer.toUnsignedLong(body.getInt()) * 1000; + if (expiresAt <= nowMillis || expiresAt - nowMillis > maxTtlMs) return null; + byte[] fingerprint = new byte[32]; body.get(fingerprint); + int sctp = Short.toUnsignedInt(body.getShort()), max = body.getInt(); + byte[] identity = new byte[16]; body.get(identity); + String networkId = Long.toUnsignedString(body.getLong()); + int length = Byte.toUnsignedInt(body.get()); + if (length < 22 || length > 91 || body.remaining() != length) return null; + String remotePassword = new String(plaintext, 67, length, StandardCharsets.US_ASCII); + if (sctp < 1 || max < 1 || max > 262144 || !remotePassword.matches("[A-Za-z0-9+/]{22,91}")) return null; + String localPassword = BASE64.encodeToString(Arrays.copyOf(hmac("HmacSHA256", key.secret(), utf8("nxs-stateless-ice-v1\0" + audience + "\0" + token)), 24)); + return new VerifiedAdmission(tokenId(token), token, localPassword, request.remoteUfrag(), remotePassword, + "sha-256 " + HexFormat.ofDelimiter(":").withUpperCase().formatHex(fingerprint), sctp, max, expiresAt, + networkId, HexFormat.of().formatHex(identity), keyId); + } catch (Exception invalid) { return null; } + finally { if (plaintext != null) Arrays.fill(plaintext, (byte) 0); } + } + + public static String tokenId(String token) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(utf8(token)), 0, 16); } + catch (Exception impossible) { throw new IllegalStateException(impossible); } + } + private static byte[] utf8(String text) { return text.getBytes(StandardCharsets.UTF_8); } + private static byte[] hmac(String algorithm, byte[] key, byte[] data) { + try { Mac mac = Mac.getInstance(algorithm); mac.init(new SecretKeySpec(key, algorithm)); return mac.doFinal(data); } + catch (Exception impossible) { throw new IllegalStateException(impossible); } + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java new file mode 100644 index 00000000..314c1983 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/CheckInScheduleTest.java @@ -0,0 +1,32 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class CheckInScheduleTest { + private JsonObject response(long delay) { + long now = 1800000000000L; + JsonObject response = new JsonObject(), schedule = new JsonObject(); + response.addProperty("receivedAt", java.time.Instant.ofEpochMilli(now).toString()); + schedule.addProperty("version", 1); schedule.addProperty("afterMillis", delay); + schedule.addProperty("nextCheckInAt", now + delay); schedule.addProperty("leaseExpiresAt", now + delay + 30000); + schedule.addProperty("minUpdateIntervalMillis", 1000); + response.add("checkIn", schedule); return response; + } + @Test void acceptsChangedPolicyWithoutHardCodedIdleThresholds() throws Exception { + assertEquals(900000, CheckInSchedule.parse(response(900000)).afterMillis()); + assertEquals(3600000, CheckInSchedule.parse(response(3600000)).afterMillis()); + assertEquals(45000, CheckInSchedule.parse(response(45000)).afterMillis()); + } + @Test void rejectsUnboundedFractionalAndInconsistentSchedules() { + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(response(0))); + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(response(86400001))); + JsonObject fractional = response(900000); fractional.getAsJsonObject("checkIn").addProperty("afterMillis", 900000.5); + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(fractional)); + JsonObject wrongDeadline = response(900000); wrongDeadline.getAsJsonObject("checkIn").addProperty("leaseExpiresAt", 1); + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(wrongDeadline)); + JsonObject wrongVersion = response(900000); wrongVersion.getAsJsonObject("checkIn").addProperty("version", 2); + assertThrows(java.io.IOException.class, () -> CheckInSchedule.parse(wrongVersion)); + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ExtensionFixtureFile.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ExtensionFixtureFile.java new file mode 100644 index 00000000..7ce6a942 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ExtensionFixtureFile.java @@ -0,0 +1,25 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Set; + +/** Explicit, short-lived fixture handoff; never part of durable instance identity or stdout. */ +public final class ExtensionFixtureFile { + private ExtensionFixtureFile() {} + public static void write(Path path, JsonObject extensions) throws IOException { + JsonObject document = new JsonObject(); document.add("extensions", extensions); ProtocolExtensions.validate(document); + try (FileChannel file = FileChannel.open(path, Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")))) { + ByteBuffer bytes = StandardCharsets.UTF_8.encode(extensions.toString()); + while (bytes.hasRemaining()) file.write(bytes); + file.force(true); + } + } +} 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 new file mode 100644 index 00000000..95be1fd1 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java @@ -0,0 +1,153 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import com.sun.net.httpserver.*; +import java.net.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; + +/** Standalone conformance provider: no product code, accounts, hostname rules or database. */ +public final class IndependentProviderStub implements AutoCloseable { + final HttpServer server; + final String origin; + final Map challenges = new HashMap<>(), keys = new HashMap<>(), placements = new HashMap<>(); + JsonObject registration; volatile JsonObject lastHeartbeat; + volatile int failHeartbeats; + volatile boolean failOutcomes; + volatile int outcomeAttempts; + volatile boolean loseCompletionResponse; + volatile long checkInMillis; + volatile int controlPolls; + final java.util.List events = new java.util.concurrent.CopyOnWriteArrayList<>(); + long generation, sequence; + volatile boolean poolOnlyRegistration; + volatile int registrations, heartbeats, acknowledgements; + volatile String challengeAuthorization; + volatile String selectedMode; + volatile int challengeDifficulty = -1; + volatile JsonObject extensionMetadata; + volatile int extensionRequests, keyAcknowledgements; + boolean draining; + volatile String desiredState = "serving"; + volatile long desiredRevision = 1, appliedRevision; + String keyRequestId; JsonObject requestedKey; + int epoch = 1, profileRevision; + final List operationsSeen = new java.util.concurrent.CopyOnWriteArrayList<>(); + public IndependentProviderStub() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); origin = "http://127.0.0.1:" + server.getAddress().getPort(); + server.createContext("/", this::handle); server.start(); + } + private synchronized void handle(HttpExchange e) throws IOException { + int status = 200; JsonObject response; + try { response = dispatch(e); } catch (Failure f) { status = f.status; response = new JsonObject(); response.addProperty("code", f.getMessage()); } + catch (Exception f) { status = 400; response = new JsonObject(); response.addProperty("code", "invalid_request"); } + byte[] bytes = response.toString().getBytes(StandardCharsets.UTF_8); e.getResponseHeaders().add("content-type", "application/json"); e.sendResponseHeaders(status, bytes.length); try (OutputStream out = e.getResponseBody()) { out.write(bytes); } + } + private JsonObject dispatch(HttpExchange e) throws Exception { + String path = e.getRequestURI().getPath(), raw = new String(e.getRequestBody().readNBytes(65537), StandardCharsets.UTF_8); + if (raw.length() > 65536) throw new Failure(400, "payload_limit"); + 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("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", "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")) { + boolean recovery = body.has("registrationId"); + if (!ProviderCrypto.PROTOCOL.equals(body.get("protocol").getAsString()) || !"nxs-admission-v1".equals(body.get("profile").getAsString())) throw new Failure(400, "unsupported_profile"); + if (recovery && (registration == null || !registration.get("registrationId").equals(body.get("registrationId")))) throw new Failure(403, "recovery_unavailable"); + JsonObject key = recovery ? keys.get(registration.get("keyId").getAsString()) : body.getAsJsonObject("publicKeyJwk"); + String authorization = recovery ? "recovery" : body.getAsJsonObject("authorization").get("scheme").getAsString(); + if (!recovery && body.get("mode").getAsString().equals("attach-instance") && !authorization.equals("bearer-token")) throw new Failure(403, "bearer_token_required"); + if (authorization.equals("bearer-token")) { + challengeAuthorization = e.getRequestHeaders().getFirst("Authorization"); + 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()); 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()); + return c; + } + if (path.equals("/example/complete")) { + String id = body.get("challengeId").getAsString(); JsonObject c = challenges.get(id); + if (c == null) throw new Failure(409, "challenge_consumed"); + String proof = ProviderCrypto.proof(c, body.get("proofNonce").getAsString(), body.get("idempotencyKey").getAsString()); + if (!ProviderCrypto.verify(keys.get(id), body.get("signature").getAsString(), proof) || !ProviderCrypto.meetsDifficulty(ProviderCrypto.digest(proof), c.getAsJsonObject("pow").get("difficulty").getAsInt())) throw new Failure(401, "proof_invalid"); + challenges.remove(id); generation++; sequence = 0; draining = false; appliedRevision = 0; profileRevision = 0; + if (c.getAsJsonObject("context").get("mode").getAsString().equals("recover")) { JsonObject r = registration.deepCopy(); r.remove("ticketKey"); if (poolOnlyRegistration) { r.remove("serviceId"); r.remove("publicAddress"); } r.addProperty("leaseGeneration", generation); return r; } + if (registration != null) throw new Failure(409, "already_registered"); registrations++; + registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); if (!poolOnlyRegistration) registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); if (!poolOnlyRegistration) registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", generation); registration.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); if (loseCompletionResponse) { loseCompletionResponse = false; e.close(); throw new Failure(503, "completion_response_lost"); } return registration.deepCopy(); + } + if (path.equals("/example/heartbeat") && failHeartbeats-- > 0) throw new Failure(503, "fixture_transient"); + authenticate(e, raw); + JsonObject ok = new JsonObject(); ok.addProperty("accepted", true); + switch (path) { + case "/example/heartbeat" -> { + lastHeartbeat = body; heartbeats++; + if (body.has("installedKeyIds")) keyAcknowledgements++; + if (body.has("hostProfile")) { + JsonObject profile = body.getAsJsonObject("hostProfile"); + if (keyAcknowledgements == 0 || !"nethernet.stateless-admission.v1".equals(profile.getAsJsonObject("statelessAdmission").get("capability").getAsString()) + || !profile.get("dtlsFingerprint").getAsString().matches("sha-256 [0-9A-F]{2}(?::[0-9A-F]{2}){31}")) throw new Failure(400, "invalid_host_profile"); + profileRevision++; + } + if (body.has("keyRequestId")) { + String wanted = body.get("keyRequestId").getAsString(); + if (!wanted.equals(keyRequestId)) { keyRequestId = wanted; requestedKey = ticket(); requestedKey.addProperty("keyId", String.format("T%03d", ++epoch)); ok.add("ticketKey", requestedKey.deepCopy()); } + JsonObject request = new JsonObject(); request.addProperty("id", keyRequestId); request.add("keyId", requestedKey.get("keyId")); ok.add("keyRequest", request); + } + draining = !body.get("state").getAsString().equals("serving"); + long applied = body.get("appliedStateRevision").getAsLong(); + if (applied > appliedRevision) { appliedRevision = applied; acknowledgements++; } + JsonObject desired = new JsonObject(); desired.addProperty("revision", desiredRevision); desired.addProperty("state", desiredState); ok.add("desiredState", desired); + ok.addProperty("hostProfileRevision", "example-profile-" + profileRevision); + ok.addProperty("routable", profileRevision > 0 && keyAcknowledgements > 0 && !draining); + JsonObject ready = new JsonObject(); ready.addProperty("routable", ok.get("routable").getAsBoolean()); ready.add("reasons", new JsonArray()); ok.add("readiness", ready); + if (extensionMetadata != null) ok.add("extensions", extensionMetadata.deepCopy()); + if (checkInMillis > 0 && body.has("checkInVersion")) { + long now = System.currentTimeMillis(); JsonObject schedule = new JsonObject(); + schedule.addProperty("version", 1); schedule.addProperty("afterMillis", checkInMillis); + schedule.addProperty("nextCheckInAt", now + checkInMillis); schedule.addProperty("leaseExpiresAt", now + checkInMillis + 30000); + schedule.addProperty("minUpdateIntervalMillis", 1000); + ok.add("checkIn", schedule); ok.addProperty("receivedAt", java.time.Instant.ofEpochMilli(now).toString()); + } + } + case "/example/extension" -> { extensionRequests++; } + case "/example/deregister" -> { draining = true; } + case "/example/outcomes" -> { outcomeAttempts++; if (failOutcomes) throw new Failure(503, "fixture_outcome_unavailable"); for (JsonElement event : body.getAsJsonArray("events")) events.add(event.getAsJsonObject()); } + case "/example/rotate" -> { + String old = e.getRequestHeaders().getFirst("nxs-key-id"), intent = e.getRequestHeaders().getFirst("idempotency-key"); JsonObject key = body.getAsJsonObject("publicKeyJwk"); + if (!ProviderCrypto.verify(key, body.get("proof").getAsString(), ProviderCrypto.array(ProviderCrypto.PROTOCOL, "rotate", origin, "example-machine-1", old, ProviderCrypto.thumbprint(key), generation, intent))) throw new Failure(401, "replacement_proof_invalid"); + String id = "example-key-" + UUID.randomUUID(); keys.put(id, key); registration.addProperty("keyId", id); ok.addProperty("keyId", id); + } + case "/example/retire" -> keys.remove(body.get("keyId").getAsString()); + default -> throw new Failure(422, "unknown_operation"); + } + return ok; + } + private void authenticate(HttpExchange e, String raw) throws Failure { + try { + Headers h = e.getRequestHeaders(); String key = h.getFirst("nxs-key-id"), instance = h.getFirst("nxs-instance-id"); long timestamp = Long.parseLong(h.getFirst("nxs-timestamp")), gen = Long.parseLong(h.getFirst("nxs-generation")), seq = Long.parseLong(h.getFirst("nxs-sequence")); + if (!ProviderCrypto.SIGNATURE.equals(h.getFirst("nxs-signature-version")) || !"example-machine-1".equals(instance) || Math.abs(System.currentTimeMillis() - timestamp) > 60000 || gen != generation || seq <= sequence) throw new Failure(401, "auth_invalid"); + if (!ProviderCrypto.verify(keys.get(key), h.getFirst("nxs-signature"), ProviderCrypto.request(origin, e.getRequestMethod(), e.getRequestURI().toASCIIString(), timestamp, instance, key, h.getFirst("idempotency-key"), gen, seq, raw))) throw new Failure(401, "signature_invalid"); sequence = seq; + } catch (RuntimeException f) { throw new Failure(401, "auth_invalid"); } + } + private static JsonArray strings(String... strings) { JsonArray a = new JsonArray(); for (String s : strings) a.add(s); return a; } + private static JsonObject authorizationScheme(String scheme, String... modes) { JsonObject value = new JsonObject(); value.addProperty("scheme", scheme); value.add("modes", strings(modes)); return value; } + private static JsonObject ticket() { JsonObject key = new JsonObject(); key.addProperty("keyId", "T001"); key.addProperty("secret", "independent-stub-only-secret-32-bytes-minimum"); return key; } + private static final class Failure extends Exception { final int status; Failure(int status, String message) { super(message); this.status = status; } } + @Override public void close() { server.stop(0); } + public static void main(String[] args) throws Exception { var stub = new IndependentProviderStub(); System.out.println(stub.origin); Runtime.getRuntime().addShutdownHook(new Thread(stub::close)); new CountDownLatch(1).await(); } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProtocolExtensionsTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProtocolExtensionsTest.java new file mode 100644 index 00000000..81801196 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProtocolExtensionsTest.java @@ -0,0 +1,34 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ProtocolExtensionsTest { + private static JsonObject document(String envelope) { + return JsonParser.parseString("{\"extensions\":{\"org.example.feature\":" + envelope + "}}").getAsJsonObject(); + } + @Test void ignoresUnknownOptionalVersionsWithoutGrantingCoreCapabilities() { + JsonObject input = document("{\"version\":17,\"critical\":false,\"data\":{\"anything\":true}}"); + assertDoesNotThrow(() -> ProtocolExtensions.validate(input)); + JsonObject copy = ProtocolExtensions.copy(input); copy.remove("org.example.feature"); + assertEquals(1, input.getAsJsonObject("extensions").size()); + } + @Test void rejectsRequiredOrMalformedExtensions() { + for (String envelope : new String[]{ + "{\"version\":1,\"critical\":true,\"data\":{}}", + "{\"version\":1.5,\"critical\":false,\"data\":{}}", + "{\"version\":1,\"critical\":\"false\",\"data\":{}}", + "{\"version\":1,\"critical\":false,\"data\":[]}"}) + assertThrows(IllegalArgumentException.class, () -> ProtocolExtensions.validate(document(envelope))); + } + @Test void boundsEncodedSizeAndCount() { + JsonObject value = document("{\"version\":1,\"critical\":false,\"data\":{}}"); + JsonObject extension = value.getAsJsonObject("extensions").getAsJsonObject("org.example.feature"); + extension.getAsJsonObject("data").addProperty("text", "x".repeat(ProtocolExtensions.MAX_BYTES)); + assertThrows(IllegalArgumentException.class, () -> ProtocolExtensions.validate(value)); + extension.getAsJsonObject("data").remove("text"); + for (int i = 0; i < 17; i++) value.getAsJsonObject("extensions").add("org.example.feature" + i, extension.deepCopy()); + assertThrows(IllegalArgumentException.class, () -> ProtocolExtensions.validate(value)); + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java new file mode 100644 index 00000000..bba1571c --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java @@ -0,0 +1,50 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import java.net.URI; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; + +/** Loopback-only Java-to-Worker workflow. It intentionally proves no native gameplay. */ +public final class ProviderBench { + public static void main(String[] args) throws Exception { + URI provider = URI.create(System.getProperty("providerOrigin", "http://127.0.0.1:8787")); + if (!Set.of("127.0.0.1", "localhost", "[::1]").contains(provider.getHost())) throw new IllegalArgumentException("Fixture transport is loopback only"); + Path state = Path.of(System.getProperty("providerState")); + if (System.getProperty("providerMode", "once").equals("identity")) { try (var store = new ProviderStateStore(state)) { System.out.println(ProviderIdentity.initialize(store, provider)); } return; } + String token = System.getProperty("providerToken"); + ProviderTransport transport = new ProviderTransport() { + String keyId; final String incarnation = UUID.randomUUID().toString().replace("-", ""); + public CompletionStage installTicketKeys(List keys) { keyId = keys.getLast().keyId(); return CompletableFuture.completedFuture(null); } + public CompletionStage hostProfile() { + JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", keyId); p.addProperty("dtlsFingerprint", "sha-256 " + String.join(":", Collections.nCopies(32, "11"))); p.addProperty("sctpPort", 5000); p.addProperty("maxMessageSize", 262144); + JsonObject c = new JsonObject(); c.addProperty("address", "127.0.0.1"); c.addProperty("port", 19133); c.addProperty("foundation", "fixture"); c.addProperty("component", 1); c.addProperty("priority", 100); c.addProperty("protocol", "udp"); c.addProperty("type", "host"); JsonArray candidates = new JsonArray(); candidates.add(c); p.add("candidates", candidates); JsonObject capability = new JsonObject(); capability.addProperty("capability", "nethernet.stateless-admission.v1"); capability.addProperty("incarnation", incarnation); p.add("statelessAdmission", capability); return CompletableFuture.completedFuture(p); + } + public CompletionStage applyState(String state) { return CompletableFuture.completedFuture(ApplyResult.REJECTED); } + public List pollEvents() { return List.of(); } + public CompletionStage drain() { return CompletableFuture.completedFuture(null); } + public CompletionStage close() { return CompletableFuture.completedFuture(null); } + }; + String registrationMode = System.getProperty("providerRegistrationMode", token == null ? ProviderClient.NEW_SERVICE : ProviderClient.ATTACH_INSTANCE); + String authorization = token == null ? ProviderClient.ANONYMOUS_PROOF_OF_WORK : ProviderClient.BEARER_TOKEN; + Map tags = new TreeMap<>(); + JsonObject configuredTags = JsonParser.parseString(System.getProperty("providerTags", "{}")).getAsJsonObject(); + for (var entry : configuredTags.entrySet()) tags.put(entry.getKey(), entry.getValue().getAsString()); + String region = System.getProperty("providerRegion", registrationMode.equals(ProviderClient.ATTACH_INSTANCE) ? "EU" : null); + String pool = System.getProperty("providerPool", registrationMode.equals(ProviderClient.ATTACH_INSTANCE) ? "proxy" : null); + var config = new ProviderClient.Configuration(provider, "nxs-admission-v1", "Java conformance backend", registrationMode, authorization, token, region, pool, tags); + var client = new ProviderClient(config, new ProviderStateStore(state), transport, () -> new ServerStatus("Java bench", 1234, "fixture-only", "Fixture", 2, 50, 0), () -> new ProviderClient.Health(true, 100, 0.02, "nethernet", "java-conformance"), System.err::println); + try { + JsonObject registration = client.start().get(30, TimeUnit.SECONDS); + String extensionsFile = System.getProperty("providerExtensionsFile"); + if (extensionsFile != null) ExtensionFixtureFile.write(Path.of(extensionsFile), client.extensions().get(10, TimeUnit.SECONDS)); + System.out.println("instance=" + registration.get("instanceId").getAsString() + " service=" + (registration.has("serviceId") ? registration.get("serviceId").getAsString() : "unassigned")); + JsonObject readiness = client.readiness().get(10, TimeUnit.SECONDS); readiness.remove("extensions"); System.out.println(readiness); + long hold = Long.parseLong(System.getProperty("providerHoldSeconds", "0")); + String stopFile = System.getProperty("providerStopFile"); + if (stopFile != null) { long until = System.currentTimeMillis() + 180000; while (!Files.exists(Path.of(stopFile)) && System.currentTimeMillis() < until) Thread.sleep(100); } + else if (hold > 0) Thread.sleep(hold * 1000); + } finally { client.stop().toCompletableFuture().get(20, TimeUnit.SECONDS); } + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java new file mode 100644 index 00000000..2b2a4da1 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java @@ -0,0 +1,246 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.net.URI; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; + +class ProviderClientTest { + @Test void poolAttachmentsAndRemovedPublicEndpointsPreserveRuntimeIdentity(@TempDir Path path) throws Exception { + for (boolean standalone : List.of(false, true)) { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.poolOnlyRegistration = !standalone; + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Fleet host", + standalone ? ProviderClient.NEW_SERVICE : ProviderClient.ATTACH_INSTANCE, ProviderClient.BEARER_TOKEN, + "independent-provider-token", standalone ? null : "EU", standalone ? null : "proxy", Map.of()); + JsonObject first = null; + for (int generation = 1; generation <= 2; generation++) { + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path.resolve(standalone ? "standalone" : "pool")), new FakeTransport(), () -> null, + () -> new ProviderClient.Health(true, 20, 0, "nethernet", "fixture"), message -> {}); + try { + JsonObject current = client.start().get(20, TimeUnit.SECONDS); + assertEquals(standalone && generation == 1, current.has("serviceId")); + assertEquals(standalone && generation == 1, current.has("publicAddress")); + assertEquals(generation, current.get("leaseGeneration").getAsInt()); + if (first == null) first = current; + else for (String field : List.of("instanceId", "registrationId")) assertEquals(first.get(field), current.get(field)); + assertFalse(stub.lastHeartbeat.has("playerCount"), "Missing runtime telemetry is unknown, not zero"); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + stub.poolOnlyRegistration = true; + } + assertEquals(1, stub.registrations); + } + } + } + + @Test void runtimeCountsWakeCheckInsWithoutChangingPublicTotalsAndRemainCountedDuringDrain(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.checkInMillis = 900000; + AtomicReference sample = new AtomicReference<>(new ProviderClient.PlayerCount(3, System.currentTimeMillis())); + var transport = new FakeTransport(); transport.stateless = true; + ProviderClient client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Counts"), + new ProviderStateStore(path), transport, () -> new ServerStatus("Global listing", 1234, "fixture", "world", 25000, 30000, 0), + () -> new ProviderClient.Health(true, 20, .9, "nethernet", "fixture", sample.get()), message -> {}); + try { + client.start().get(20, TimeUnit.SECONDS); + assertEquals(3, stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt()); + assertEquals(sample.get().sampledAt(), stub.lastHeartbeat.getAsJsonObject("playerCount").get("sampledAt").getAsLong()); + assertEquals(20, stub.lastHeartbeat.get("capacity").getAsInt()); + assertEquals(25000, stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt()); + int before = stub.heartbeats; + sample.set(new ProviderClient.PlayerCount(3, System.currentTimeMillis())); client.requestStatusRefresh(); + Thread.sleep(1200); assertEquals(before, stub.heartbeats, "Timestamp-only changes use the ordinary schedule"); + sample.set(new ProviderClient.PlayerCount(4, System.currentTimeMillis())); client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt() == 4); + assertEquals(25000, stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt()); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + assertTrue(stub.draining); + assertEquals(4, stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt()); + } + } + + @Test void usesProviderNeutralBearerAuthorizationWithoutPowOrPersistingTheToken(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Hosted customer", + ProviderClient.NEW_SERVICE, ProviderClient.BEARER_TOKEN, "independent-provider-token", "EU", "customers", Map.of("plan", "premium")); + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path), new FakeTransport(), () -> null, + () -> new ProviderClient.Health(true, 10, 0, "nethernet", "fixture"), message -> {}); + try { + JsonObject registration = client.start().get(20, TimeUnit.SECONDS); + assertEquals("example-machine-1", registration.get("instanceId").getAsString()); + assertEquals("Bearer independent-provider-token", stub.challengeAuthorization); + assertEquals(0, stub.challengeDifficulty); + assertFalse(java.nio.file.Files.readString(path.resolve("provider-state.json")).contains("independent-provider-token")); + assertFalse(config.toString().contains("independent-provider-token")); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + + @Test void optionalExtensionsRemainOpaqueAndExplicit(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.extensionMetadata = JsonParser.parseString("{\"org.example.operator\":{\"version\":1,\"critical\":false,\"data\":{\"message\":\"optional\",\"operations\":{\"inspect\":\"" + stub.origin + "/example/extension\"}}}}").getAsJsonObject(); + ProviderClient client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"), + new ProviderStateStore(path), new FakeTransport(), () -> null, + () -> new ProviderClient.Health(true, 10, 0, "nethernet", "fixture"), message -> {}); + try { + JsonObject result = client.start().get(20, TimeUnit.SECONDS); + assertFalse(result.has("ticketKey")); + assertEquals(stub.extensionMetadata, result.getAsJsonObject("extensions")); + assertFalse(java.nio.file.Files.readString(path.resolve("provider-state.json")).contains("org.example.operator")); + assertEquals(0, stub.extensionRequests); + assertTrue(client.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); + client.extensionRequest("org.example.operator", "inspect", "POST", new JsonObject()).get(10, TimeUnit.SECONDS); + assertEquals(1, stub.extensionRequests); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + @Test void followsProviderScheduleWithoutIdlePollingAndWakesForPlayers(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.checkInMillis = 900000; + AtomicInteger players = new AtomicInteger(0); FakeTransport transport = new FakeTransport(); transport.stateless = true; + ProviderClient client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Scheduled"), + new ProviderStateStore(path), transport, () -> new ServerStatus("Scheduled", 1234, "fixture", "world", players.get(), 40, 0), + () -> new ProviderClient.Health(true, 40, players.get() / 40.0, "nethernet", "fixture"), message -> {}); + try { + client.start().get(20, TimeUnit.SECONDS); + assertEquals(0, stub.controlPolls); + Thread.sleep(2200); + assertEquals(1, stub.heartbeats); assertEquals(0, stub.controlPolls); + for (int i = 0; i < 100; i++) client.requestStatusRefresh(); + Thread.sleep(1200); assertEquals(1, stub.heartbeats, "Unchanged local refreshes must not send requests"); + stub.checkInMillis = 1000; players.set(1); client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 1); + int busyBefore = stub.heartbeats; + eventually(() -> stub.heartbeats > busyBefore && stub.controlPolls == 0); + stub.checkInMillis = 3600000; players.set(0); client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 0); + Thread.sleep(2200); int before = stub.heartbeats; int polls = stub.controlPolls; + Thread.sleep(2200); assertEquals(before, stub.heartbeats); assertEquals(polls, stub.controlPolls); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + assertTrue(stub.draining, "Orderly shutdown still reports drain while the timer is asleep"); + int beforeRestart = stub.heartbeats; + FakeTransport replacement = new FakeTransport(); replacement.stateless = true; + stub.checkInMillis = 900000; + ProviderClient resumed = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Scheduled"), + new ProviderStateStore(path), replacement, () -> new ServerStatus("Restarted", 1234, "fixture", "world", 0, 40, 0), + () -> new ProviderClient.Health(true, 40, 0, "nethernet", "fixture"), message -> {}); + try { + resumed.start().get(20, TimeUnit.SECONDS); + assertEquals(2, stub.generation); + assertEquals(beforeRestart + 1, stub.heartbeats, "Startup must publish immediately despite the previous one-hour schedule"); + assertEquals("Restarted", stub.lastHeartbeat.getAsJsonObject("serverStatus").get("name").getAsString()); + } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + + } + } + static final class FakeTransport implements ProviderTransport { + final CompletableFuture closed = new CompletableFuture<>(); + final java.util.Queue events = new java.util.concurrent.ConcurrentLinkedQueue<>(); + volatile int installed, applied, admissions, drains; + boolean stateless = true; String ticketKeyId = "T001"; + volatile ApplyResult result = ApplyResult.APPLIED; + public CompletionStage hostProfile() { JsonObject p = new JsonObject(); p.addProperty("credentialKeyId", ticketKeyId); p.addProperty("dtlsFingerprint", "sha-256 " + String.join(":", Collections.nCopies(32, "11"))); p.addProperty("sctpPort", 5000); p.addProperty("maxMessageSize", 262144); + JsonObject c = new JsonObject(); c.addProperty("foundation", "fixture"); c.addProperty("component", 1); c.addProperty("protocol", "udp"); c.addProperty("priority", 100); c.addProperty("address", "127.0.0.1"); c.addProperty("port", 19133); c.addProperty("type", "host"); JsonArray candidates = new JsonArray(); candidates.add(c); p.add("candidates", candidates); + if (stateless) { JsonObject cap = new JsonObject(); cap.addProperty("capability", "nethernet.stateless-admission.v1"); cap.addProperty("incarnation", "0123456789abcdef0123456789abcdef"); p.add("statelessAdmission", cap); } return CompletableFuture.completedFuture(p); } + public CompletionStage installTicketKeys(List keys) { installed = keys.size(); ticketKeyId = keys.getLast().keyId(); return CompletableFuture.completedFuture(null); } + public CompletionStage applyState(String state) { + applied++; String kind = state; + if (kind.equals("join-admission")) admissions++; + if (kind.equals("draining")) drains++; + return CompletableFuture.completedFuture(kind.equals("join-admission") ? result : ApplyResult.APPLIED); + } + public List pollEvents() { List batch = new ArrayList<>(); for (JsonObject event; (event = events.poll()) != null;) batch.add(event); return batch; } + public CompletionStage drain() { return CompletableFuture.completedFuture(null); } + public CompletionStage close() { closed.complete(null); return closed; } + } + @Test void portableRegistrationRefreshRotationAndRestart(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + AtomicInteger players = new AtomicInteger(2); FakeTransport host = new FakeTransport(); + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"); + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path), host, () -> new ServerStatus("Example", 1234, "preview-fixture", "", players.get(), 40, 0), () -> new ProviderClient.Health(true, 100, 0.1, "nethernet", "fixture"), message -> {}); + JsonObject registration = client.start().get(20, TimeUnit.SECONDS); + assertEquals("example-machine-1", registration.get("instanceId").getAsString()); assertEquals(1, stub.registrations); assertEquals(1, host.installed); + assertEquals(100, stub.lastHeartbeat.get("capacity").getAsInt()); assertEquals(40, stub.lastHeartbeat.getAsJsonObject("serverStatus").get("maxPlayers").getAsInt()); + players.set(7); eventually(() -> stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 7); + client.setServerStatus(new ServerStatus("Renamed", 1234, "preview-fixture", "World", 8, 30, 2)); + eventually(() -> "Renamed".equals(stub.lastHeartbeat.getAsJsonObject("serverStatus").get("name").getAsString())); + assertTrue(client.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); + client.rotateMachineKey().get(10, TimeUnit.SECONDS); client.drain().get(10, TimeUnit.SECONDS); assertTrue(stub.draining); + client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); + FakeTransport restarted = new FakeTransport(); ProviderClient resumed = new ProviderClient(config, new ProviderStateStore(path), restarted, () -> new ServerStatus("Restarted", 1234, "preview-fixture", "", 1, 50, 1), () -> new ProviderClient.Health(true, 100, 0, "nethernet", "fixture"), message -> {}); + try { assertEquals("example-machine-1", resumed.start().get(20, TimeUnit.SECONDS).get("instanceId").getAsString()); assertEquals(1, stub.registrations); assertEquals(2, stub.generation); } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + private static void eventually(java.util.function.BooleanSupplier condition) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(8); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(40); + assertTrue(condition.getAsBoolean(), "Timed out waiting for provider lifecycle"); + } + @Test void failedRefreshRetriesAndRejectsUnknownStateWithoutAcknowledgingIt(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + AtomicInteger players = new AtomicInteger(2); FakeTransport host = new FakeTransport(); + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"); + var health = (java.util.function.Supplier) () -> new ProviderClient.Health(true, 100, 0.1, "nethernet", "fixture"); + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path), host, () -> { + if (players.get() < 0) throw new IllegalStateException("query unavailable"); + return new ServerStatus("Example", 1234, "fixture", "", players.get(), 40, 0); + }, health, message -> {}); + client.start().get(20, TimeUnit.SECONDS); + players.set(-1); eventually(() -> !stub.lastHeartbeat.has("serverStatus")); + int before = stub.heartbeats; players.set(5); stub.failHeartbeats = 1; + for (int i = 0; i < 500; i++) client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.has("serverStatus") && stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt() == 5); + assertTrue(stub.heartbeats - before <= 2, "Burst must coalesce within heartbeat cadence"); + int beforeAck = stub.acknowledgements; + stub.desiredState = "future-state"; stub.desiredRevision = 2; + assertThrows(ExecutionException.class, () -> client.readiness().get(10, TimeUnit.SECONDS)); + assertEquals(0, host.admissions, "NXS has no per-join provider state"); + assertTrue(stub.appliedRevision < 2, "Unknown state cannot be acknowledged"); + stub.desiredState = "draining"; + client.readiness().get(10, TimeUnit.SECONDS); + assertTrue(host.drains > 0); assertEquals(2, stub.appliedRevision); + assertTrue(stub.acknowledgements > beforeAck); + client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); + } + } + + @Test void outcomeOutageBacksOffWhileHeartbeatsContinue(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + FakeTransport host = new FakeTransport(); stub.failOutcomes = true; + var client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"), + new ProviderStateStore(path), host, () -> null, () -> new ProviderClient.Health(true, 10, 0, "nethernet", "fixture"), message -> {}); + try { + client.start().get(20, TimeUnit.SECONDS); + host.events.add(JsonParser.parseString("{\"ticketId\":\"fixture-ticket\",\"stage\":\"ticket.failed\",\"occurredAt\":\"2026-09-07T00:00:00Z\"}").getAsJsonObject()); + eventually(() -> stub.outcomeAttempts == 1); + int before = stub.heartbeats; + eventually(() -> stub.heartbeats >= before + 2); + assertEquals(1, stub.outcomeAttempts, "Outcome failure must back off independently of heartbeat"); + JsonObject saved = JsonParser.parseString(java.nio.file.Files.readString(path.resolve("provider-state.json"))).getAsJsonObject(); + assertEquals(1, saved.getAsJsonArray("pendingEvents").size()); + assertTrue(saved.get("profilePublishedAt").getAsLong() > 0); + } finally { stub.failOutcomes = false; client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + assertEquals(1, stub.events.size(), "Shutdown retries the durable outcome without losing it"); + } + } + + @Test void localPersistenceFailureStopsPublicationAndClosesTransport(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + FakeTransport host = new FakeTransport(); + var client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example"), new ProviderStateStore(path), host, () -> null, () -> new ProviderClient.Health(true, 10, 0, "nethernet", "fixture"), message -> {}); + client.start().get(20, TimeUnit.SECONDS); + java.nio.file.Files.move(path.resolve("provider-state.json"), path.resolve("saved-state.json")); + java.nio.file.Files.createDirectory(path.resolve("provider-state.json")); + assertThrows(ExecutionException.class, () -> client.readiness().get(10, TimeUnit.SECONDS)); + client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); + assertTrue(host.closed.isDone()); + } + } + +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderInteropTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderInteropTest.java new file mode 100644 index 00000000..6f8bfdf4 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderInteropTest.java @@ -0,0 +1,32 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.security.*; +import java.security.spec.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProviderInteropTest { + static JsonObject fixture(String name) throws Exception { try (var in = ProviderInteropTest.class.getResourceAsStream("/" + name)) { return JsonParser.parseString(new String(Objects.requireNonNull(in).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); } } + @Test void verifiesJavaScriptCanonicalBytesAndRejectsMutations() throws Exception { + JsonObject f = fixture("nxs-v1.fixtures.json"), c = f.getAsJsonObject("challenge"), pub = f.getAsJsonObject("publicKeyJwk"); + assertEquals(c.get("thumbprint").getAsString(), ProviderCrypto.thumbprint(pub)); + assertEquals(c.get("contextDigest").getAsString(), ProviderCrypto.contextDigest(c.getAsJsonObject("context"))); + assertEquals(f.getAsJsonObject("proof").get("payload").getAsString(), ProviderCrypto.proof(c, f.get("proofNonce").getAsString(), f.get("idempotencyKey").getAsString())); + for (String field : List.of("proof", "request")) { JsonObject v = f.getAsJsonObject(field); assertTrue(ProviderCrypto.verify(pub, v.get("signature").getAsString(), v.get("payload").getAsString())); assertFalse(ProviderCrypto.verify(pub, v.get("signature").getAsString(), v.get("payload").getAsString() + " ")); } + JsonObject request = f.getAsJsonObject("request"), i = request.getAsJsonObject("input"); + assertEquals(request.get("payload").getAsString(), ProviderCrypto.request(i.get("audience").getAsString(), i.get("method").getAsString(), i.get("path").getAsString(), i.get("timestamp").getAsLong(), i.get("instanceId").getAsString(), i.get("keyId").getAsString(), i.get("idempotencyKey").getAsString(), i.get("generation").getAsLong(), i.get("sequence").getAsLong(), i.get("body").getAsString())); + JsonObject optional = pub.deepCopy(); optional.addProperty("ext", false); assertEquals(ProviderCrypto.thumbprint(pub), ProviderCrypto.thumbprint(optional)); + assertThrows(IllegalArgumentException.class, () -> ProviderCrypto.thumbprint(f.getAsJsonObject("privateKeyJwk"))); + KeyPair keys = ProviderCrypto.generate(); String signature = ProviderCrypto.sign(keys.getPrivate(), "test"); assertEquals(96, ProviderCrypto.decode(signature).length); assertTrue(ProviderCrypto.verify(ProviderCrypto.publicJwk(keys.getPublic()), signature, "test")); + Signature der = Signature.getInstance("SHA384withECDSA"); der.initSign(keys.getPrivate()); der.update("test".getBytes()); assertFalse(ProviderCrypto.verify(ProviderCrypto.publicJwk(keys.getPublic()), ProviderCrypto.base64(der.sign()), "test")); + } + @Test void durablePrivateStateAndDuplicateDirectoryFencing(@TempDir Path dir) throws Exception { + try (ProviderStateStore s = new ProviderStateStore(dir)) { JsonObject data = new JsonObject(); data.addProperty("privateKey", "fixture-only"); s.write(data); assertEquals(data, s.read()); assertThrows(java.io.IOException.class, () -> new ProviderStateStore(dir)); assertEquals("rw-------", java.nio.file.attribute.PosixFilePermissions.toString(Files.getPosixFilePermissions(dir.resolve("provider-state.json")))); } + try (ProviderStateStore s = new ProviderStateStore(dir)) { assertEquals("fixture-only", s.read().get("privateKey").getAsString()); } + } + @Test void exactStatusBounds() { assertThrows(IllegalArgumentException.class, () -> new ServerStatus("name", 0, "version", "", 0, 1, 0)); assertThrows(IllegalArgumentException.class, () -> new ServerStatus("name\n", 1, "version", "", 0, 1, 0)); assertDoesNotThrow(() -> new ServerStatus("name", 1, "version", "", 20, 1, 0)); } +} 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 new file mode 100644 index 00000000..f80e703f --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderJourneysTest.java @@ -0,0 +1,149 @@ +package org.cloudburstmc.netty.signalling; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.net.URI; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +class ProviderJourneysTest { + private static ProviderClient client(IndependentProviderStub stub, Path directory, ProviderClient.Configuration configuration, + ProviderClientTest.FakeTransport transport) throws Exception { + return new ProviderClient(configuration, new ProviderStateStore(directory), transport, + () -> new ServerStatus("Independent host", 1000, "conformance", "world", 1, 20, 0), + () -> new ProviderClient.Health(true, 100, .01, "nethernet", "fixture"), message -> {}); + } + + @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, + 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()); + var transport = new ProviderClientTest.FakeTransport(); + ProviderClient instance = client(stub, directory.resolve(journey), configuration, transport); + try { + JsonObject result = instance.start().get(20, TimeUnit.SECONDS); + assertEquals("nethernet-external-signalling-v1", result.get("protocol").getAsString()); + assertEquals("nxs-admission-v1", result.get("profile").getAsString()); + assertFalse(result.has("extensions"), "A provider needs no product extension"); + assertEquals(bearer ? 0 : 2, stub.challengeDifficulty); + assertTrue(stub.keyAcknowledgements > 0); + assertTrue(instance.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); + if (attach) assertEquals("london", result.getAsJsonObject("placement").getAsJsonObject("tags").get("location").getAsString()); + JsonObject event = new JsonObject(); event.addProperty("stage", "ticket.data_channels_open"); + event.addProperty("ticketId", "opaque-correlation"); event.addProperty("occurredAt", java.time.Instant.now().toString()); + event.addProperty("reason", "connected"); event.addProperty("privatePayload", "must-not-be-persisted"); transport.events.add(event); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (stub.events.isEmpty() && System.nanoTime() < deadline) Thread.sleep(25); + assertEquals(1, stub.events.size()); assertFalse(stub.events.getFirst().has("privatePayload")); + assertEquals(0, transport.admissions, "Control-plane delivery cannot stage individual clients"); + instance.deregister().get(10, TimeUnit.SECONDS); assertTrue(stub.draining); + } finally { instance.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + } + + @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"); + ProviderClient first = client(stub, directory, configuration, new ProviderClientTest.FakeTransport()); + JsonObject registration; + try { registration = first.start().get(20, TimeUnit.SECONDS); } + finally { first.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + JsonObject previous; + try (ProviderStateStore store = new ProviderStateStore(directory)) { + previous = store.read(); JsonObject legacy = previous.deepCopy(); + legacy.remove("protocol"); legacy.remove("profile"); + legacy.getAsJsonObject("registration").addProperty("protocol", "legacy-protocol-fixture"); + legacy.getAsJsonObject("registration").addProperty("profile", "legacy-profile-fixture"); + store.write(legacy); + } + ProviderClient resumed = client(stub, directory, configuration, new ProviderClientTest.FakeTransport()); + try { + JsonObject migrated = resumed.start().get(20, TimeUnit.SECONDS); + for (String field : new String[]{"instanceId", "serviceId", "registrationId", "keyId"}) + assertEquals(registration.get(field), migrated.get(field)); + assertEquals(1, stub.registrations); assertEquals(2, stub.generation); + } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + try (ProviderStateStore store = new ProviderStateStore(directory)) { + JsonObject current = store.read(); + assertEquals(previous.get("privateKey"), current.get("privateKey")); + assertEquals(previous.get("publicKeyJwk"), current.get("publicKeyJwk")); + assertEquals("nxs-admission-v1", current.get("profile").getAsString()); + } + } + } + + @Test void requiredUnknownExtensionFailsBeforeCredentialTransmission(@TempDir Path directory) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.extensionMetadata = JsonParser.parseString("{\"org.example.required\":{\"version\":1,\"critical\":true,\"data\":{}}}").getAsJsonObject(); + var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Example", + ProviderClient.NEW_SERVICE, ProviderClient.BEARER_TOKEN, "independent-provider-token", null, null, Map.of()); + ProviderClient instance = client(stub, directory, configuration, new ProviderClientTest.FakeTransport()); + try { + assertThrows(java.util.concurrent.ExecutionException.class, () -> instance.start().get(20, TimeUnit.SECONDS)); + assertNull(stub.challengeAuthorization); assertEquals(0, stub.registrations); + } finally { instance.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + } + } + + @Test void recoversCommittedRegistrationWhenCompletionResponseIsLost(@TempDir Path directory) throws Exception { + // Exercise bearer attachment too: a recovery challenge intentionally has neither enrollment + // placement nor bearer authorization, while the recovered registration retains both bindings. + for (boolean attach : new boolean[]{false, true}) try (IndependentProviderStub stub = new IndependentProviderStub()) { + Path statePath = directory.resolve(attach ? "attached" : "standalone"); + var configuration = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Lost completion", + attach ? ProviderClient.ATTACH_INSTANCE : ProviderClient.NEW_SERVICE, + attach ? ProviderClient.BEARER_TOKEN : ProviderClient.ANONYMOUS_PROOF_OF_WORK, + attach ? "independent-provider-token" : null, attach ? "EU" : null, attach ? "proxy" : null, + attach ? Map.of("location", "london") : Map.of()); + stub.loseCompletionResponse = true; + ProviderClient first = client(stub, statePath, configuration, new ProviderClientTest.FakeTransport()); + try { assertThrows(java.util.concurrent.ExecutionException.class, () -> first.start().get(20, TimeUnit.SECONDS)); } + finally { first.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + JsonObject before; + try (ProviderStateStore state = new ProviderStateStore(statePath)) { + before = state.read(); assertTrue(before.has("challenge")); assertFalse(before.has("registration")); + } + assertEquals(1, stub.registrations, "The provider committed despite the lost HTTP response"); + ProviderClient resumed = client(stub, statePath, configuration, new ProviderClientTest.FakeTransport()); + try { + JsonObject registration = resumed.start().get(20, TimeUnit.SECONDS); + assertEquals(stub.registration.get("registrationId"), registration.get("registrationId")); + assertEquals(1, stub.registrations); assertEquals(2, stub.generation); + assertTrue(resumed.readiness().get(10, TimeUnit.SECONDS).get("routable").getAsBoolean()); + assertTrue(stub.keyAcknowledgements > 0, "Lost one-time key material is freshly provisioned"); + } finally { resumed.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + try (ProviderStateStore state = new ProviderStateStore(statePath)) { + JsonObject after = state.read(); assertFalse(after.has("challenge")); + assertEquals(before.get("privateKey"), after.get("privateKey")); + } + } + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java new file mode 100644 index 00000000..9c74b57b --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionGateTest.java @@ -0,0 +1,93 @@ +package org.cloudburstmc.netty.signalling.admission; + +import dev.kastle.netty.channel.nethernet.admission.*; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class AdmissionGateTest extends AdmissionFixture { + final InetSocketAddress other = new InetSocketAddress("127.0.0.1", 23451); + AdmissionGate gate() { return new AdmissionGate(new AdmissionGate.Limits(2, 2, 1, 1000), validator()); } + AdmissionRequest elsewhere() { return new AdmissionRequest(token, remote, other); } + + @Test void invalidTokensNeverReserveCapacity() { + var gate = gate(); + for (int i = 0; i < 1000; i++) assertNull(gate.reserve(request("invalidToken", remote), now, 0)); + assertEquals(0, gate.stats().sessions()); assertEquals(0, gate.stats().claims()); + assertEquals(1000, gate.stats().invalid()); + } + @Test void concurrentAttemptsShareOneTokenReservation() throws Exception { + var gate = gate(); + try (var executor = Executors.newFixedThreadPool(8)) { + List> calls = new ArrayList<>(); + for (int i = 0; i < 64; i++) calls.add(() -> gate.reserve(request(), now, 0)); + int reserved = 0; + for (var result : executor.invokeAll(calls)) if (result.get() != null) reserved++; + assertEquals(1, reserved); + } + assertEquals(1, gate.stats().sessions()); assertEquals(1, gate.stats().pending()); + assertNull(gate.reserve(elsewhere(), now, 0)); + } + @Test void nativeVerificationFailureDoesNotConsumeToken() { + var gate = gate(); + var forged = gate.reserve(request(), now, 0); + assertNotNull(forged); assertEquals(0, gate.stats().accepted()); + gate.invalidNativeRequest(); assertTrue(gate.finish(forged)); + assertEquals(0, gate.stats().claims()); assertEquals(0, gate.stats().sessions()); + var legitimate = gate.reserve(elsewhere(), now, 1); + assertNotNull(legitimate); assertTrue(gate.ready(legitimate)); + assertFalse(gate.ready(legitimate)); assertEquals(1, gate.stats().accepted()); + } + @Test void acceptedTokensCannotAllocateAgainAndActiveSessionsOutliveTokenExpiry() { + var gate = gate(); var r = gate.reserve(request(), now, 0); + assertTrue(gate.ready(r)); gate.connected(r); + assertNull(gate.reserve(elsewhere(), now, 0)); + assertEquals(1, gate.stats().replayRejected()); + assertTrue(gate.sweep(now + 120_000, 120_000_000_000L).isEmpty()); + assertNotNull(gate.admission(r)); + assertTrue(gate.finish(r)); assertNull(gate.admission(r)); + assertNull(gate.reserve(request(), now, 0)); + assertEquals(1, gate.stats().claims()); + gate.sweep(now + 120_000, 120_000_000_000L); assertEquals(0, gate.stats().claims()); + } + @Test void timeoutAndShutdownKeepCapacityUntilNativeTeardownCompletes() { + var gate = gate(); var r = gate.reserve(request(), now, 0); assertTrue(gate.ready(r)); + assertEquals(List.of(r), gate.sweep(now + 1000, 1_000_000_000L)); + assertTrue(gate.sweep(now + 2000, 2_000_000_000L).isEmpty()); + assertNull(gate.admission(r)); assertEquals(1, gate.stats().sessions()); + assertTrue(gate.finish(r)); assertEquals(0, gate.stats().sessions()); + gate = gate(); r = gate.reserve(request(), now, 0); + assertEquals(List.of(r), gate.close()); + assertFalse(gate.ready(r)); assertNull(gate.admission(r)); + assertEquals(1, gate.stats().pending()); assertNull(gate.reserve(request(), now, 0)); + assertTrue(gate.finish(r)); assertEquals(0, gate.stats().pending()); assertEquals(0, gate.stats().claims()); + } + @Test void drainingRejectsNewReservations() { + var gate = gate(); gate.drain(); + assertNull(gate.reserve(request(), now, 0)); + assertEquals(0, gate.stats().claims()); assertEquals(1, gate.stats().capacityRejected()); + } + @Test void pendingWarningsAreAggregatedAndRateLimited() throws Exception { + assertEquals(1024, AdmissionGate.Limits.defaults().pending()); + var trusted = validator().validate(request(), now); + var v = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); + v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); + var gate = new AdmissionGate(new AdmissionGate.Limits(2, 4, 1, 1000), v); + var first = TestSignallingProvider.answer(trusted.remoteDescription(), trusted.remoteFingerprint(), 49199, now + 30_000, TestSignallingProvider.AUDIENCE, false); + var next = TestSignallingProvider.answer(trusted.remoteDescription(), trusted.remoteFingerprint(), 49199, now + 30_000, TestSignallingProvider.AUDIENCE, false); + var r = gate.reserve(request(first.token(), remote), now, 0); + var nextRequest = new AdmissionRequest(next.token(), remote, other); + assertNull(gate.reserve(request("invalidToken", remote), now, 0)); + assertNull(gate.pollPendingLimitWarning(0)); + for (int i = 0; i < 3; i++) assertNull(gate.reserve(nextRequest, now, 0)); + assertEquals(new AdmissionGate.PendingLimitWarning(1, 1, 3), gate.pollPendingLimitWarning(0)); + for (int i = 0; i < 2; i++) assertNull(gate.reserve(nextRequest, now, 0)); + assertNull(gate.pollPendingLimitWarning(4_999_999_999L)); + assertTrue(gate.ready(r)); + assertEquals(new AdmissionGate.PendingLimitWarning(1, 1, 2), gate.pollPendingLimitWarning(5_000_000_000L)); + assertNotNull(gate.reserve(nextRequest, now, 0)); assertEquals(1, gate.stats().pending()); + assertEquals(5, gate.stats().capacityRejected()); + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java new file mode 100644 index 00000000..fcff52b0 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/AdmissionPrimitiveProbe.java @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Adapted from teamziax/libdatachannel-java at 40f2c329dcb63a762a701b987dc9995d76fd18c7. +// The original file license is preserved; see LICENSES/MPL-2.0.txt. +package org.cloudburstmc.netty.signalling.admission; + +import dev.kastle.netty.channel.nethernet.admission.*; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; +import tel.schich.libdatachannel.*; +import java.net.*; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +/** Standalone wire probe using the same admission adapter as the running host. */ +public final class AdmissionPrimitiveProbe { + static final InetAddress LOOPBACK = InetAddress.getLoopbackAddress(); + static final int PORT = 49184; + static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); } + + public static void main(String[] args) throws Exception { + var identity = NativeHostIdentity.load(Path.of(args[0]), Path.of(args[1])); + for (int passwordLength : new int[]{24, 32, 91}) run(identity, passwordLength, false); + run(identity, 24, true); + } + + static void run(NativeHostIdentity identity, int passwordLength, boolean wrongFingerprint) throws Exception { + var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); + var endpoint = new NativeAdmissionServerChannel(identity, validator, new AdmissionGate.Limits(4, 8, 2, 10_000)); + var group = new DefaultEventLoopGroup(1); + var messages = new CountDownLatch(2); + var failure = new AtomicReference(); + var received = new AtomicInteger(); + try (var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(LOOPBACK), Runnable::run)) { + new ServerBootstrap().group(group).channelFactory(() -> endpoint) + .childHandler(new ChannelInitializer() { + @Override protected void initChannel(AdmittedNetherNetChildChannel child) { + child.pipeline().addLast(new SimpleChannelInboundHandler() { + boolean reliable = true; + @Override public void userEventTriggered(ChannelHandlerContext ctx, Object event) { + if (event instanceof NetherNetPacket.Delivery delivery) reliable = delivery.reliable(); + } + @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf message) { + int bit = reliable ? 1 : 2; + check(message.readableBytes() == 1 && message.readByte() == bit, "channel identity and payload"); + received.getAndUpdate(mask -> mask | bit); messages.countDown(); + } + @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable error) { failure.set(error); ctx.close(); } + }); + } + }).bind(LOOPBACK, PORT).sync(); + for (boolean reliable : new boolean[]{true, false}) { + var channel = client.createDataChannel(reliable ? "ReliableDataChannel" : "UnreliableDataChannel", + DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(!reliable, !reliable, 0, 0))); + channel.onOpen.register(dc -> dc.sendMessage(ByteBuffer.allocateDirect(2).put((byte)0).put((byte)(reliable ? 1 : 2)).flip())); + } + client.setLocalDescription("offer", "clientFixtureUf", "p".repeat(passwordLength)); + var answer = TestSignallingProvider.answer(client.localDescription(), identity.fingerprint(), PORT, + System.currentTimeMillis() + 30_000, TestSignallingProvider.AUDIENCE, wrongFingerprint); + check(answer.token().length() == 8 + (int)Math.ceil((95 + passwordLength) * 4.0 / 3), "token byte budget"); + check(endpoint.nativeStats()[2] == 0, "no host peer before an incoming request"); + client.setRemoteDescription(answer.sdp(), SessionDescriptionType.ANSWER); + if (wrongFingerprint) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); + boolean rejected = false; + while (!rejected && System.nanoTime() < deadline) { + rejected = endpoint.pollEvents().stream().anyMatch(event -> event.stage().equals("ticket.failed")); + if (!rejected) Thread.sleep(10); + } + check(rejected && received.get() == 0, "DTLS rejects a client certificate that does not match its token"); + } else { + check(messages.await(10, TimeUnit.SECONDS), "both channels deliver messages"); + check(failure.get() == null && received.get() == 3, "both channel payloads match"); + } + check(endpoint.creationAttempts() == 1 && endpoint.nativeStats()[5] == 1, "one peer and one admission notification"); + System.out.println("native-admission PASS ufragChars=" + answer.token().length() + " wrongClientFingerprint=" + wrongFingerprint + " channels=" + received.get() + " admissionNotifications=1"); + } finally { + endpoint.close().awaitUninterruptibly(); + endpoint.termination().toCompletableFuture().get(6, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/EndpointAddressTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/EndpointAddressTest.java new file mode 100644 index 00000000..6fccce04 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/EndpointAddressTest.java @@ -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); + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionBench.java new file mode 100644 index 00000000..c37b387f --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/NativeAdmissionBench.java @@ -0,0 +1,107 @@ +package org.cloudburstmc.netty.signalling.admission; + +import com.google.gson.*; +import dev.kastle.netty.channel.nethernet.admission.*; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; +import org.cloudburstmc.netty.signalling.ProviderTransport; +import tel.schich.libdatachannel.*; +import java.io.*; +import java.net.*; +import java.nio.ByteBuffer; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +/** Separate-process loopback bench. Host input is one background key snapshot, before any offer exists. */ +public final class NativeAdmissionBench { + private static final Gson JSON = new Gson(); + private static synchronized void emit(String kind, Object value) { + JsonObject event = new JsonObject(); event.addProperty("kind", kind); event.add("value", JSON.toJsonTree(value)); + System.out.println(JSON.toJson(event)); System.out.flush(); + } + public static void main(String[] args) throws Exception { + if (args[0].equals("host")) host(args); else if (args[0].equals("client")) client(); + else throw new IllegalArgumentException("host or client"); + } + private static void host(String[] args) throws Exception { + // No stdin reader, HTTP client, lookup store or offer input remains after background setup. + JsonObject key; + try (var input = new BufferedReader(new InputStreamReader(System.in))) { + key = JsonParser.parseString(input.readLine()).getAsJsonObject(); + if (input.readLine() != null) throw new IllegalArgumentException("Only one background key snapshot permitted"); + } + var group = new DefaultEventLoopGroup(2); AtomicInteger delivered = new AtomicInteger(); + AtomicReference failure = new AtomicReference<>(); + ServerBootstrap bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInitializer() { + @Override protected void initChannel(AdmittedNetherNetChildChannel child) { + child.pipeline().addLast(new SimpleChannelInboundHandler() { + boolean reliable = true; + @Override public void userEventTriggered(ChannelHandlerContext ctx, Object event) { + if (event instanceof NetherNetPacket.Delivery delivery) reliable = delivery.reliable(); + } + @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf data) { + delivered.getAndUpdate(mask -> mask | (reliable ? 1 : 2)); + ctx.writeAndFlush(new NetherNetPacket(data.retainedDuplicate(), reliable)); + } + @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable error) { failure.set(error); ctx.close(); } + }); + } + }); + NativeProviderTransport host = null; + try { + host = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1", Integer.parseInt(args[1])), Path.of(args[2]), Path.of(args[3]), new AdmissionGate.Limits(4,8,2,10_000)).toCompletableFuture().get(5, TimeUnit.SECONDS); + host.installTicketKeys(List.of(new ProviderTransport.TicketKey(key.get("keyId").getAsString(), key.get("secret").getAsString()))).toCompletableFuture().get(); + key = null; + emit("profile", host.hostProfile().toCompletableFuture().get()); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(100); + Path stop = Path.of(args[4]); + while (!Files.exists(stop)) { + if (System.nanoTime() > deadline) throw new IllegalStateException("Bench host deadline"); + if (failure.get() != null) throw new IllegalStateException("Host pipeline failure", failure.get()); + var endpoint = host.channel(); + emit("stats", Map.of("admission", endpoint.admissionStats(), "native", endpoint.nativeStats(), "nativeCreationAttempts", PeerConnection.nativeCreationAttempts(), "hostCreations", endpoint.creationAttempts(), "deliveredChannels", delivered.get())); + for (var event : endpoint.pollEvents()) emit("stage", Map.of("stage", event.stage(), "ticketId", event.ticketId(), + "occurredAt", java.time.Instant.ofEpochMilli(event.occurredAt()).toString(), "reason", event.reason(), + "validationToCreationNanos", event.validationToCreationNanos())); + Thread.sleep(50); + } + host.close().toCompletableFuture().get(6, TimeUnit.SECONDS); + try (var reuse = new DatagramSocket(new InetSocketAddress("127.0.0.1", Integer.parseInt(args[1])))) { + emit("closed", Map.of("udpReleased", reuse.getLocalPort() == Integer.parseInt(args[1]))); + } + } finally { if (host != null) host.close().toCompletableFuture().get(6, TimeUnit.SECONDS); group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } + } + private static void client() throws Exception { + var configuration = PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(InetAddress.getByName("127.0.0.1")); + PeerConnection peer = PeerConnection.createPeer(configuration, Runnable::run); + try (var input = new BufferedReader(new InputStreamReader(System.in))) { + CountDownLatch echoes = new CountDownLatch(2); AtomicReference failure = new AtomicReference<>(); + for (boolean reliable : new boolean[]{true, false}) { + var channel = peer.createDataChannel(reliable ? "ReliableDataChannel" : "UnreliableDataChannel", DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(!reliable,!reliable,0,0))); + byte[] payload = new byte[reliable ? 20013 : 7]; Arrays.fill(payload, (byte)(reliable ? 31 : 47)); + var decoder = new NetherNetFrameDecoder(); + channel.onMessage.register(DataChannelCallback.Message.handleBinary((dc, bytes) -> { + try { + byte[] frame = new byte[bytes.remaining()]; bytes.get(frame); byte[] message = decoder.decode(frame,reliable); + if (message != null) { if (!Arrays.equals(payload,message)) throw new IllegalStateException("Echo payload mismatch"); echoes.countDown(); } + } catch (Throwable error) { failure.set(error); } + })); + channel.onOpen.register(dc -> { + int chunks = (payload.length+9998)/9999; + for (int i=0;i(List.of(v6, v4, v4)); + NativeProviderTransport host = null; + try { + var bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInitializer() { + @Override protected void initChannel(Channel channel) {} + }); + host = NativeProviderTransport.open(bootstrap, new InetSocketAddress("::", port), advertised::get, + id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get(10, TimeUnit.SECONDS); + host.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey("K001", TestSignallingProvider.SECRET))).toCompletableFuture().get(); + var profile = host.hostProfile().toCompletableFuture().get(); + assertEquals(2, profile.getAsJsonArray("candidates").size()); + String incarnation = profile.getAsJsonObject("statelessAdmission").get("incarnation").getAsString(); + String audience = NativeProviderTransport.audience(incarnation); + var endpoint = host.channel(); + for (var destination : List.of(v4, v6)) { + var other = destination.equals(v4) ? v6 : v4; + try (var socket = new DatagramSocket(new InetSocketAddress(destination.getAddress(), 0)); + var duplicate = new DatagramSocket(new InetSocketAddress(other.getAddress(), 0)); + var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(destination.getAddress()), Runnable::run)) { + client.createDataChannel("ReliableDataChannel"); + String ufrag = destination.equals(v4) ? "dualStackClient4" : "dualStackClient6"; + client.setLocalDescription("offer", ufrag, "p".repeat(32)); + var answer = TestSignallingProvider.answer(client.localDescription(), id.fingerprint(), port, + System.currentTimeMillis() + 30_000, audience, false); + byte[] request = nominatedBinding(answer.token() + ":" + ufrag, answer.password()); + socket.setSoTimeout(2000); + socket.send(new DatagramPacket(request, request.length, destination)); + byte[] bytes = new byte[2048]; var response = new DatagramPacket(bytes, bytes.length); + socket.receive(response); + assertEquals(destination.getAddress(), response.getAddress()); assertEquals(port, response.getPort()); + assertEquals(0x0101, Short.toUnsignedInt(ByteBuffer.wrap(bytes).getShort())); + assertArrayEquals(Arrays.copyOfRange(request, 8, 20), Arrays.copyOfRange(bytes, 8, 20)); + long creations = endpoint.creationAttempts(); + duplicate.setSoTimeout(250); + duplicate.send(new DatagramPacket(request, request.length, other)); + assertThrows(SocketTimeoutException.class, () -> duplicate.receive(new DatagramPacket(new byte[2048], 2048))); + assertEquals(creations, endpoint.creationAttempts(), "An alternate family cannot allocate a second peer with the same ticket"); + } + } + assertEquals(2, endpoint.creationAttempts()); + advertised.set(List.of(v4)); + var changed = host.hostProfile().toCompletableFuture().get(); + assertEquals(1, changed.getAsJsonArray("candidates").size()); + assertEquals(incarnation, changed.getAsJsonObject("statelessAdmission").get("incarnation").getAsString()); + advertised.set(List.of()); + assertTrue(host.hostProfile().toCompletableFuture().isCompletedExceptionally()); + } finally { + if (host != null) host.close().toCompletableFuture().get(10, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + try (var socket = new DatagramSocket(new InetSocketAddress("::", port))) { assertEquals(port, socket.getLocalPort()); } + } + + @Test @Timeout(40) void clientsOfEitherFamilyOpenBothDataChannelsFromTheSameCandidateList() throws Exception { + var id = identity(); var group = new DefaultEventLoopGroup(1); int port = 49187; + NativeProviderTransport host = null; + try { + var bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInitializer() { + @Override protected void initChannel(Channel channel) {} + }); + host = NativeProviderTransport.open(bootstrap, new InetSocketAddress("::", port), + () -> List.of(new InetSocketAddress("::1", port), new InetSocketAddress("127.0.0.1", port)), + id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get(10, TimeUnit.SECONDS); + host.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey("K001", TestSignallingProvider.SECRET))).toCompletableFuture().get(); + String incarnation = host.hostProfile().toCompletableFuture().get().getAsJsonObject("statelessAdmission").get("incarnation").getAsString(); + for (String ip : List.of("127.0.0.1", "::1")) { + try (var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(InetAddress.getByName(ip)), Runnable::run)) { + AtomicInteger opened = new AtomicInteger(); + client.createDataChannel("ReliableDataChannel").onOpen.register(dc -> opened.incrementAndGet()); + client.createDataChannel("UnreliableDataChannel", DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(true, true, 0, 0))) + .onOpen.register(dc -> opened.incrementAndGet()); + client.setLocalDescription("offer", ip.equals("::1") ? "clientWithIpv6" : "clientWithIpv4", "p".repeat(32)); + var answer = TestSignallingProvider.answer(client.localDescription(), id.fingerprint(), port, + System.currentTimeMillis() + 30_000, NativeProviderTransport.audience(incarnation), false); + String sdp = answer.sdp().replace("a=candidate:1 1 UDP 2130706431 127.0.0.1", "a=candidate:2 1 UDP 2130706175 127.0.0.1") + .replace("a=end-of-candidates", "a=candidate:1 1 UDP 2130706431 ::1 " + port + " typ host\r\na=end-of-candidates"); + client.setRemoteDescription(sdp, SessionDescriptionType.ANSWER); + await(() -> opened.get() == 2); + assertTrue(client.closeAndAwait(Duration.ofSeconds(5))); + } + } + assertEquals(2, host.channel().creationAttempts()); + } finally { + if (host != null) host.close().toCompletableFuture().get(10, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } + + @TempDir Path directory; + NativeHostIdentity identity() throws Exception { + Path cert = directory.resolve("host.crt"), key = directory.resolve("host.key"); + Process p = new ProcessBuilder("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", "-keyout", key.toString(), "-out", cert.toString(), "-days", "1", "-subj", "/CN=public-test-only").redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start(); + assertTrue(p.waitFor(10, TimeUnit.SECONDS));assertEquals(0,p.exitValue()); + return NativeHostIdentity.load(cert,key); + } + static void await(BooleanSupplier check) throws Exception { + long end = System.nanoTime()+TimeUnit.SECONDS.toNanos(12); + while (!check.getAsBoolean() && System.nanoTime() NativeProviderTransport.open(bootstrap, bind, bind, + id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get()); + host = NativeProviderTransport.open(bootstrap, bind, advertised, id.certificate(), id.privateKey(), + AdmissionGate.Limits.defaults()).toCompletableFuture().get(10, TimeUnit.SECONDS); + host.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey( + "K001", TestSignallingProvider.SECRET, 0, Long.MAX_VALUE))).toCompletableFuture().get(); + var profile = host.hostProfile().toCompletableFuture().get(); + var candidate = profile.getAsJsonArray("candidates").get(0).getAsJsonObject(); + assertEquals("127.0.0.1", candidate.get("address").getAsString()); + assertEquals(49189, candidate.get("port").getAsInt()); + assertEquals("nethernet.stateless-admission.v1", profile.getAsJsonObject("statelessAdmission").get("capability").getAsString()); + try (var socket = new DatagramSocket()) { + byte[] packet = new byte[40]; socket.send(new DatagramPacket(packet, packet.length, advertised)); + } + var endpoint = host.channel(); await(() -> endpoint.nativeStats()[0] > 0); + assertEquals(0, endpoint.nativeStats()[5], "Malformed UDP stays native"); + assertEquals(0, endpoint.creationAttempts()); + } finally { + if (host != null) host.close().toCompletableFuture().get(10, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + try (var socket = new DatagramSocket(bind)) { assertEquals(49189, socket.getLocalPort()); } + } + @Test @Timeout(20) void firstAuthenticatedDatagramGetsMatchingResponseWithoutRetry() throws Exception { + var id = identity(); var loopback = InetAddress.getByName("127.0.0.1"); int port = 49199; + var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); + var group = new DefaultEventLoopGroup(1); + AtomicInteger validations = new AtomicInteger(); + AdmissionValidator delayed = (metadata, now) -> { + validations.incrementAndGet(); + try { Thread.sleep(200); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return null; } + return validator.validate(metadata, now); + }; + var endpoint = new NativeAdmissionServerChannel(id, delayed, new AdmissionGate.Limits(2, 4, 1, 10_000)); + try (var socket = new DatagramSocket(new InetSocketAddress(loopback, 0)); + var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(loopback), Runnable::run)) { + new ServerBootstrap().group(group).channelFactory(() -> endpoint) + .childHandler(new ChannelInboundHandlerAdapter()).bind(loopback, port).sync(); + client.createDataChannel("ReliableDataChannel"); + client.setLocalDescription("offer", "singleCheckClient", "p".repeat(32)); + var answer = TestSignallingProvider.answer(client.localDescription(), id.fingerprint(), port, + System.currentTimeMillis() + 30_000, TestSignallingProvider.AUDIENCE, false); + // Never apply the answer to the native client: only this socket sends one check. + byte[] request = nominatedBinding(answer.token() + ":singleCheckClient", answer.password()); + socket.setSoTimeout(2000); + long started = System.nanoTime(); + socket.send(new DatagramPacket(request, request.length, loopback, port)); + byte[] bytes = new byte[2048]; var response = new DatagramPacket(bytes, bytes.length); + socket.receive(response); + double elapsedMs = (System.nanoTime() - started) / 1_000_000.0; + assertEquals(loopback, response.getAddress()); assertEquals(port, response.getPort()); + assertEquals(0x0101, Short.toUnsignedInt(ByteBuffer.wrap(bytes).getShort())); + assertArrayEquals(Arrays.copyOfRange(request, 8, 20), Arrays.copyOfRange(bytes, 8, 20)); + assertEquals(1, endpoint.creationAttempts()); assertEquals(1, endpoint.nativeStats()[3]); + assertEquals(1, validations.get()); assertEquals(1, endpoint.nativeStats()[5]); + // Authenticated retransmissions on the established tuple never return to admission. + for (int i = 0; i < 40; i++) socket.send(new DatagramPacket(request, request.length, loopback, port)); + await(() -> endpoint.nativeStats()[0] >= 41); + assertEquals(1, validations.get()); assertEquals(1, endpoint.nativeStats()[5]); + System.out.printf(Locale.ROOT, "first-stun PASS requestsSent=1 matchingSuccess=true responseMs=%.3f%n", elapsedMs); + } finally { + if (endpoint.isRegistered()) endpoint.close().awaitUninterruptibly(); else endpoint.unsafe().closeForcibly(); + endpoint.termination().toCompletableFuture().get(6, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } + @Test @Timeout(20) void expiredDecisionCreatesNoPeerAndReleasesItsReservation() throws Exception { + var id = identity(); var loopback = InetAddress.getByName("127.0.0.1"); int port = 49200; + var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE, 60_000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", TestSignallingProvider.SECRET))); + AdmissionValidator delayed = (metadata, now) -> { + var admission = validator.validate(metadata, now); + try { Thread.sleep(2500); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return null; } + return admission; + }; + var group = new DefaultEventLoopGroup(1); + var endpoint = new NativeAdmissionServerChannel(id, delayed, new AdmissionGate.Limits(2, 4, 1, 10_000)); + try (var socket = new DatagramSocket(new InetSocketAddress(loopback, 0)); + var client = PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(loopback), Runnable::run)) { + new ServerBootstrap().group(group).channelFactory(() -> endpoint) + .childHandler(new ChannelInboundHandlerAdapter()).bind(loopback, port).sync(); + client.createDataChannel("ReliableDataChannel"); + client.setLocalDescription("offer", "expiredDecisionClient", "p".repeat(32)); + var answer = TestSignallingProvider.answer(client.localDescription(), id.fingerprint(), port, + System.currentTimeMillis() + 2000, TestSignallingProvider.AUDIENCE, false); + byte[] request = nominatedBinding(answer.token() + ":expiredDecisionClient", answer.password()); + long before = PeerConnection.nativeCreationAttempts(); + socket.send(new DatagramPacket(request, request.length, loopback, port)); + await(() -> endpoint.admissionStats().invalid() > 0); + assertEquals(before, PeerConnection.nativeCreationAttempts()); assertEquals(0, endpoint.creationAttempts()); + assertEquals(0, endpoint.nativeStats()[2]); assertEquals(0, endpoint.nativeStats()[3]); + assertEquals(0, endpoint.admissionStats().claims()); assertEquals(0, endpoint.admissionStats().sessions()); + } finally { + if (endpoint.isRegistered()) endpoint.close().awaitUninterruptibly(); else endpoint.unsafe().closeForcibly(); + endpoint.termination().toCompletableFuture().get(6, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } + @Test @Timeout(60) void tokenLengthBoundsAndClientFingerprintAreEnforcedByRealTransport() throws Exception { + var id = identity(); + for (int passwordLength : new int[]{24, 32, 91}) AdmissionPrimitiveProbe.run(id, passwordLength, false); + AdmissionPrimitiveProbe.run(id, 24, true); + } + private static byte[] nominatedBinding(String username, String password) throws Exception { + byte[] minimal = AdmissionFixture.binding(username, password); + int integrityOffset = minimal.length - 24; + ByteBuffer packet = ByteBuffer.allocate(minimal.length + 24); + packet.put(minimal, 0, integrityOffset); + packet.putShort((short)0x24).putShort((short)4).putInt(1853693695); + packet.putShort((short)0x802a).putShort((short)8).putLong(42); + packet.putShort((short)0x25).putShort((short)0); + int signedLength = packet.position(); + packet.putShort((short)8).putShort((short)20); + packet.putShort(2, (short)(packet.capacity() - 20)); + byte[] transaction = new byte[12]; new java.security.SecureRandom().nextBytes(transaction); + System.arraycopy(transaction, 0, packet.array(), 8, transaction.length); + var mac = javax.crypto.Mac.getInstance("HmacSHA1"); + mac.init(new javax.crypto.spec.SecretKeySpec(password.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA1")); + packet.put(mac.doFinal(Arrays.copyOf(packet.array(), signedLength))); + return packet.array(); + } + @Test @Timeout(45) void noControlLazyJoinBothChannelsReplayAndCleanup() throws Exception { + var id = identity(); var loopback = InetAddress.getByName("127.0.0.1"); int port = 49190; + var validator = new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE,60_000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001",TestSignallingProvider.SECRET))); + var group = new DefaultEventLoopGroup(2); + var rakGroup = new NioEventLoopGroup(1); + Channel rak = new ServerBootstrap().group(rakGroup).channelFactory(RakChannelFactory.server(NioDatagramChannel.class)) + .childHandler(new ChannelInboundHandlerAdapter()).bind("127.0.0.1",49191).sync().channel(); + var endpoint = new NativeAdmissionServerChannel(id,validator,new AdmissionGate.Limits(4,8,2,10_000)); + AtomicInteger inboundMask = new AtomicInteger(); AtomicReference child = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + ServerBootstrap bootstrap = new ServerBootstrap().group(group).channelFactory(() -> endpoint).childHandler(new ChannelInitializer() { + @Override protected void initChannel(AdmittedNetherNetChildChannel ch) { + child.set(ch); + ch.pipeline().addLast(new SimpleChannelInboundHandler() { + boolean reliable = true; + @Override public void userEventTriggered(ChannelHandlerContext ctx,Object event) { if(event instanceof NetherNetPacket.Delivery d) reliable=d.reliable(); } + @Override protected void channelRead0(ChannelHandlerContext ctx,ByteBuf data) { + inboundMask.getAndUpdate(mask -> mask | (reliable?1:2)); + // Nonzero reader index catches the old transport offset bug. + ByteBuf echo=ctx.alloc().buffer(data.readableBytes()+3).writeZero(3).writeBytes(data);echo.skipBytes(3); + ctx.writeAndFlush(new NetherNetPacket(echo,reliable)); + } + @Override public void exceptionCaught(ChannelHandlerContext ctx,Throwable error) { failure.compareAndSet(null,error);ctx.close(); } + }); + } + }); + try { + bootstrap.bind(new InetSocketAddress(loopback,port)).sync();rakPing(49191); + assertEquals(0,endpoint.nativeStats()[2]);assertEquals(0,endpoint.admissionStats().claims()); + long beforeInvalid=PeerConnection.nativeCreationAttempts(); + try(var noise=new DatagramSocket()) { byte[] packet=new byte[40];noise.send(new DatagramPacket(packet,packet.length,loopback,port)); } + await(()->endpoint.nativeStats()[0]>0); + assertEquals(0, endpoint.nativeStats()[5], "Malformed UDP stays native"); + assertEquals(beforeInvalid,PeerConnection.nativeCreationAttempts());assertEquals(0,endpoint.nativeStats()[3]); + assertThrows(IllegalStateException.class,()->new IceUdpMuxListener(loopback,port,Runnable::run,request -> CompletableFuture.completedFuture(null))); + try(PeerConnection client=PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(loopback),Runnable::run)) { + CountDownLatch echoed = new CountDownLatch(2);List channels=new ArrayList<>(); + for(int index=0;index<2;index++) { + boolean reliable=index==0;String label=reliable?"ReliableDataChannel":"UnreliableDataChannel"; + DataChannel dc=client.createDataChannel(label,DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(!reliable,!reliable,0,0))); + channels.add(dc);var decoder=new NetherNetFrameDecoder();byte[] payload=new byte[reliable?20013:7];Arrays.fill(payload,(byte)(reliable?11:22)); + dc.onMessage.register(DataChannelCallback.Message.handleBinary((d,buffer)->{ + byte[] frame=new byte[buffer.remaining()];buffer.get(frame); + try { byte[] message=decoder.decode(frame,reliable);if(message!=null) {assertArrayEquals(payload,message);echoed.countDown();} } + catch(Throwable error){failure.compareAndSet(null,error);} + })); + dc.onOpen.register(d->{ + int chunks=(payload.length+9998)/9999; + for(int i=0;i rejectedPackets=List.of( + StatelessAdmissionValidatorTest.binding(expired.token()+":clientFixtureUf",expired.password()), + StatelessAdmissionValidatorTest.binding(wrongHost.token()+":clientFixtureUf",wrongHost.password()), + StatelessAdmissionValidatorTest.binding(altered+":clientFixtureUf",answer.password()), + StatelessAdmissionValidatorTest.binding(answer.token()+":clientFixtureUf","wrong-stun-integrity-password"), + StatelessAdmissionValidatorTest.binding(answer.token()+":differentClientUfrag",answer.password())); + long beforeNegatives=PeerConnection.nativeCreationAttempts(); + try(var invalid=new DatagramSocket()) { + for(byte[] packet:rejectedPackets) { + long rejectedBefore = endpoint.admissionStats().invalid(); + invalid.send(new DatagramPacket(packet,packet.length,loopback,port)); + await(() -> endpoint.admissionStats().invalid() > rejectedBefore); + } + } + assertEquals(beforeNegatives,PeerConnection.nativeCreationAttempts()); + assertEquals(0,endpoint.admissionStats().claims());assertEquals(0,endpoint.nativeStats()[2]);assertEquals(0,endpoint.nativeStats()[3]); + + // Issuing an answer changes NO host state. Host has only its profile and key snapshot. + assertEquals(0,endpoint.admissionStats().claims());assertEquals(0,endpoint.creationAttempts()); + long beforeJoin=PeerConnection.nativeCreationAttempts(); + client.setRemoteDescription(answer.sdp(),SessionDescriptionType.ANSWER); + assertTrue(echoed.await(12,TimeUnit.SECONDS), "both channels echo through Netty"); + assertNull(failure.get());assertEquals(3,inboundMask.get()); + assertEquals(1,endpoint.creationAttempts());assertEquals(beforeJoin+1,PeerConnection.nativeCreationAttempts());rakPing(49191); + assertEquals(1,endpoint.nativeStats()[2]);assertEquals(1,endpoint.nativeStats()[3]); + try(var replay=new DatagramSocket()) { + byte[] packet=StatelessAdmissionValidatorTest.binding(answer.token()+":clientFixtureUf",answer.password()); + replay.send(new DatagramPacket(packet,packet.length,loopback,port)); + await(()->endpoint.admissionStats().replayRejected()>0); + } + assertEquals(1,endpoint.creationAttempts());assertEquals(1,endpoint.nativeStats()[3]); + assertTrue(endpoint.pollEvents().stream().allMatch(e->e.validationToCreationNanos()>0)); + child.get().close().sync(); + await(()->endpoint.admissionStats().sessions()==0); + assertEquals(0,child.get().queuedFrames());assertEquals(0,child.get().retainedAssemblyBytes()); + } + endpoint.close().sync();endpoint.termination().toCompletableFuture().get(5,TimeUnit.SECONDS); + try(var reuse=new DatagramSocket(new InetSocketAddress(loopback,port))) { assertEquals(port,reuse.getLocalPort()); } + System.out.println("native-adapter PASS fixedUdp=49190 hostCreations=1 channels=3 replayRejected=true perJoinControl=0 cleanup=true raknetPong=49191"); + } finally { endpoint.close().awaitUninterruptibly();rak.close().awaitUninterruptibly();group.shutdownGracefully(0,2,TimeUnit.SECONDS).sync();rakGroup.shutdownGracefully(0,2,TimeUnit.SECONDS).sync(); } + } + @Test @Timeout(30) void providerBoundaryPublishesFreshBootIdentityWithoutClientState() throws Exception { + var id = identity(); var group = new DefaultEventLoopGroup(1); + ServerBootstrap bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInboundHandlerAdapter()); + NativeProviderTransport transport = null; + try { + long creations = PeerConnection.nativeCreationAttempts(); + transport = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1",49196), id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get(5,TimeUnit.SECONDS); + assertTrue(transport.hostProfile().toCompletableFuture().isCompletedExceptionally()); + transport.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey("K001",TestSignallingProvider.SECRET))).toCompletableFuture().get(); + var first = transport.hostProfile().toCompletableFuture().get(); + assertEquals(id.fingerprint(),first.get("dtlsFingerprint").getAsString()); + assertEquals(49196,first.getAsJsonArray("candidates").get(0).getAsJsonObject().get("port").getAsInt()); + String incarnation = first.getAsJsonObject("statelessAdmission").get("incarnation").getAsString(); + assertTrue(incarnation.matches("[0-9a-f]{32}")); + var command = new com.google.gson.JsonObject();command.addProperty("kind","join-admission"); + assertEquals(org.cloudburstmc.netty.signalling.ProviderTransport.ApplyResult.REJECTED,transport.applyState("join-admission").toCompletableFuture().get()); + assertEquals(0,transport.channel().admissionStats().claims());assertEquals(0,transport.channel().nativeStats()[2]); + assertEquals(creations,PeerConnection.nativeCreationAttempts()); + transport.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey("K001",TestSignallingProvider.SECRET,0,System.currentTimeMillis()+60_000), + new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey("K002","next-background-key-of-at-least-32-bytes"))).toCompletableFuture().get(); + assertEquals("K002",transport.hostProfile().toCompletableFuture().get().get("credentialKeyId").getAsString()); + transport.drain().toCompletableFuture().get();assertTrue(transport.hostProfile().toCompletableFuture().isCompletedExceptionally()); + transport.close().toCompletableFuture().get(5,TimeUnit.SECONDS); + transport = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1",49196), id.certificate(), id.privateKey(), AdmissionGate.Limits.defaults()).toCompletableFuture().get(5,TimeUnit.SECONDS); + transport.installTicketKeys(List.of(new org.cloudburstmc.netty.signalling.ProviderTransport.TicketKey("K001",TestSignallingProvider.SECRET))).toCompletableFuture().get(); + String restarted = transport.hostProfile().toCompletableFuture().get().getAsJsonObject("statelessAdmission").get("incarnation").getAsString(); + assertNotEquals(incarnation,restarted);assertNotEquals(NativeProviderTransport.audience(incarnation),NativeProviderTransport.audience(restarted)); + assertEquals(creations,PeerConnection.nativeCreationAttempts()); + } finally { if (transport != null) transport.close().toCompletableFuture().get(5,TimeUnit.SECONDS);group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } + } + + @Test @Timeout(40) void simultaneousClientsRespectNativeCapacityUntilActualTeardown() throws Exception { + var id=identity();var group=new DefaultEventLoopGroup(2);var clients=new ArrayList(); + var children=new CopyOnWriteArrayList(); + var validator=new StatelessAdmissionValidator(TestSignallingProvider.AUDIENCE,60000); + validator.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001",TestSignallingProvider.SECRET))); + var endpoint=new NativeAdmissionServerChannel(id,validator,new AdmissionGate.Limits(2,4,2,10000)); + try { + new ServerBootstrap().group(group).channelFactory(()->endpoint).childHandler(new ChannelInitializer() { + @Override protected void initChannel(AdmittedNetherNetChildChannel child) { children.add(child); } + }).bind("127.0.0.1",49198).sync(); + var answers=new ArrayList();var opens=new AtomicIntegerArray(3); + for(int i=0;i<3;i++) { + final int index=i; + var client=PeerConnection.createPeer(PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true).withBindAddress(InetAddress.getByName("127.0.0.1")),Runnable::run); + clients.add(client); + for(boolean reliable:new boolean[]{true,false}) { + var dc=client.createDataChannel(reliable?"ReliableDataChannel":"UnreliableDataChannel",DataChannelInitSettings.DEFAULT.withReliability(new DataChannelReliability(!reliable,!reliable,0,0))); + dc.onOpen.register(ignored->opens.incrementAndGet(index)); + } + client.setLocalDescription("offer","multiClient"+i,"p".repeat(32)); + answers.add(TestSignallingProvider.answer(client.localDescription(),id.fingerprint(),49198,System.currentTimeMillis()+30000,TestSignallingProvider.AUDIENCE,false)); + } + long before=PeerConnection.nativeCreationAttempts(); + clients.get(0).setRemoteDescription(answers.get(0).sdp(),SessionDescriptionType.ANSWER); + clients.get(1).setRemoteDescription(answers.get(1).sdp(),SessionDescriptionType.ANSWER); + await(()->opens.get(0)==2 && opens.get(1)==2); + assertEquals(2,endpoint.liveNativePeers());assertEquals(2,endpoint.nativeStats()[2]); + clients.get(2).setRemoteDescription(answers.get(2).sdp(),SessionDescriptionType.ANSWER); + await(()->endpoint.admissionStats().capacityRejected()>0); + assertEquals(2,endpoint.creationAttempts());assertEquals(before+2,PeerConnection.nativeCreationAttempts());assertEquals(2,endpoint.admissionStats().claims()); + var closing=children.get(0);closing.close().sync();closing.nativeTermination().toCompletableFuture().get(5,TimeUnit.SECONDS); + // Third client's normal ICE retries can claim the released slot; used tokens remain tombstoned. + await(()->{assertTrue(endpoint.nativeStats()[2]<=2);assertTrue(endpoint.liveNativePeers()<=2);return opens.get(2)==2;}); + assertEquals(3,endpoint.creationAttempts());assertEquals(before+3,PeerConnection.nativeCreationAttempts()); + assertEquals(2,endpoint.liveNativePeers());assertEquals(2,endpoint.nativeStats()[2]);assertEquals(3,endpoint.admissionStats().claims()); + System.out.println("native-capacity PASS simultaneousClients=2 thirdRetriesAfterTeardown=true maxNativePeers=2"); + } finally { + for(var client:clients) assertTrue(client.closeAndAwait(Duration.ofSeconds(5))); + endpoint.close().awaitUninterruptibly();endpoint.termination().toCompletableFuture().get(6,TimeUnit.SECONDS); + group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); + } + } + +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java new file mode 100644 index 00000000..8937785f --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java @@ -0,0 +1,82 @@ +package org.cloudburstmc.netty.signalling.admission; + +import com.google.gson.*; +import dev.kastle.netty.channel.nethernet.admission.*; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; +import org.cloudburstmc.netty.signalling.*; +import tel.schich.libdatachannel.PeerConnection; +import java.net.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** Real provider/native composition; loopback fixture identity, never gameplay evidence. */ +public final class ProviderNativeBench { + private static final Gson JSON = new Gson(); + private static synchronized void emit(String kind, Object value) { + JsonObject event = new JsonObject(); event.addProperty("kind", kind); event.add("value", JSON.toJsonTree(value)); + System.out.println(event); System.out.flush(); + } + public static void main(String[] args) throws Exception { + URI origin = URI.create(args[0]); + if (!Set.of("localhost", "127.0.0.1", "[::1]").contains(origin.getHost())) throw new IllegalArgumentException("Loopback bench only"); + Path state = Path.of(args[1]), stop = Path.of(args[3]); + int port = Integer.parseInt(args[2]); + var group = new DefaultEventLoopGroup(2); + AtomicInteger delivered = new AtomicInteger(); + ServerBootstrap bootstrap = new ServerBootstrap().group(group).childHandler(new ChannelInitializer() { + @Override protected void initChannel(AdmittedNetherNetChildChannel child) { + child.pipeline().addLast(new SimpleChannelInboundHandler() { + boolean reliable = true; + @Override public void userEventTriggered(ChannelHandlerContext ctx, Object event) { + if (event instanceof NetherNetPacket.Delivery delivery) reliable = delivery.reliable(); + } + @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf data) { + delivered.getAndUpdate(mask -> mask | (reliable ? 1 : 2)); + ctx.writeAndFlush(new NetherNetPacket(data.retainedDuplicate(), reliable)); + } + }); + } + }); + NativeProviderTransport nativeHost = null; ProviderClient provider = null; + try { + nativeHost = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1", port), + state.resolve("host-cert.pem"), state.resolve("host-key.pem"), new AdmissionGate.Limits(4, 8, 2, 10_000)).toCompletableFuture().get(10, TimeUnit.SECONDS); + provider = new ProviderClient(new ProviderClient.Configuration(origin, "nxs-admission-v1", "Provider native integration"), + new ProviderStateStore(state), nativeHost, + () -> new ServerStatus("Automatic native server", 1234, "fixture-only", "Integration", 0, 4, 0), + () -> new ProviderClient.Health(true, 4, 0, "nethernet", "provider-native-bench"), System.err::println); + JsonObject registration = provider.start().get(45, TimeUnit.SECONDS); + if (args.length > 4) ExtensionFixtureFile.write(Path.of(args[4]), provider.extensions().get(10, TimeUnit.SECONDS)); + // Emit assigned IDs only; optional metadata and credentials are excluded. + var assignedIds = new java.util.LinkedHashMap(); + assignedIds.put("instanceId", registration.get("instanceId").getAsString()); + if (registration.has("serviceId")) assignedIds.put("serviceId", registration.get("serviceId").getAsString()); + emit("registered", assignedIds); + emit("profile", nativeHost.hostProfile().toCompletableFuture().get()); + JsonObject readiness = provider.readiness().get(10, TimeUnit.SECONDS); readiness.remove("extensions"); emit("readiness", readiness); + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(3); + boolean updated = false; + while (!Files.exists(stop)) { + if (System.nanoTime() > deadline) throw new IllegalStateException("Provider native bench deadline"); + if (!updated && Files.exists(state.resolve("update-status"))) { + provider.setServerStatus(new ServerStatus("Updated native server", 1235, "fixture-updated", "Updated level", 1, 8, 1)); updated = true; + } + var endpoint = nativeHost.channel(); + emit("stats", Map.of("admission", endpoint.admissionStats(), "native", endpoint.nativeStats(), "nativeCreationAttempts", PeerConnection.nativeCreationAttempts(), "hostCreations", endpoint.creationAttempts(), "deliveredChannels", delivered.get())); + Thread.sleep(100); + } + provider.stop().toCompletableFuture().get(20, TimeUnit.SECONDS); provider = null; + try (var reuse = new DatagramSocket(new InetSocketAddress("127.0.0.1", port))) { + emit("closed", Map.of("udpReleased", reuse.getLocalPort() == port)); + } + } finally { + if (provider != null) provider.stop().toCompletableFuture().get(20, TimeUnit.SECONDS); + if (nativeHost != null) nativeHost.close().toCompletableFuture().get(10, TimeUnit.SECONDS); + group.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java new file mode 100644 index 00000000..0ced7742 --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/StatelessAdmissionValidatorTest.java @@ -0,0 +1,115 @@ +package org.cloudburstmc.netty.signalling.admission; + +import com.google.gson.*; +import dev.kastle.netty.channel.nethernet.admission.*; +import org.junit.jupiter.api.Test; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.InputStreamReader; +import java.nio.ByteBuffer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class AdmissionFixture { + static JsonObject fixture(String name) { + try (var in = new InputStreamReader(Objects.requireNonNull(StatelessAdmissionValidatorTest.class.getResourceAsStream("/nxs/" + name)), StandardCharsets.UTF_8)) { + return JsonParser.parseReader(in).getAsJsonObject(); + } catch (Exception e) { throw new AssertionError(e); } + } + final JsonObject f = fixture("stateless-admission-v1.fixtures.json"); + final String token = f.getAsJsonObject("expected").get("localUfrag").getAsString(); + final String password = f.getAsJsonObject("expected").get("icePwd").getAsString(); + final String remote = f.get("clientIceUfrag").getAsString(); + final long now = f.get("now").getAsLong(); + StatelessAdmissionValidator validator(String audience) { + var v = new StatelessAdmissionValidator(audience, f.get("maxTtlMs").getAsLong()); + v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", f.getAsJsonObject("context").get("secret").getAsString()))); + return v; + } + StatelessAdmissionValidator validator() { return validator(f.getAsJsonObject("context").get("audience").getAsString()); } + AdmissionRequest request() { return request(token, remote); } + static AdmissionRequest request(String local, String remote) { return new AdmissionRequest(local, remote, new InetSocketAddress("127.0.0.1", 23450)); } + static byte[] binding(String username, String password) { + try { + byte[] u = username.getBytes(StandardCharsets.US_ASCII); + int offset = 24 + ((u.length + 3) & ~3); + ByteBuffer b = ByteBuffer.allocate(offset + 24); + b.putShort((short)1).putShort((short)(b.capacity()-20)).putInt(0x2112a442).put(new byte[12]); + b.putShort((short)6).putShort((short)u.length).put(u);b.position(offset); + b.putShort((short)8).putShort((short)20); + Mac mac = Mac.getInstance("HmacSHA1");mac.init(new SecretKeySpec(password.getBytes(StandardCharsets.UTF_8),"HmacSHA1")); + b.put(mac.doFinal(Arrays.copyOf(b.array(), offset)));return b.array(); + } catch (Exception e) { throw new AssertionError(e); } + } +} + +class StatelessAdmissionValidatorTest extends AdmissionFixture { + @Test void canonicalJavaScriptTokenMatchesJavaClaims() { + var a = validator().validate(request(), now); + assertNotNull(a); + var c = f.getAsJsonObject("claims"); + assertEquals(c.get("clientIcePwd").getAsString(), a.remotePassword()); + assertEquals(c.get("clientSctpPort").getAsInt(), a.remoteSctpPort()); + assertEquals(c.get("networkId").getAsString(), a.networkId()); + assertEquals(c.get("callerContextHashHex").getAsString(), a.callerContextHash()); + assertEquals(password, a.localPassword()); + assertEquals(c.get("clientFingerprintHex").getAsString(), a.remoteFingerprint().substring(8).replace(":", "").toLowerCase(Locale.ROOT)); + assertFalse(a.toString().contains(token)); + assertFalse(a.toString().contains(password)); + } + @Test void negativeAdmissionHasNoTrustedOutput() { + var v = validator(); + assertNull(v.validate(request(), now + 60_000)); + assertNull(v.validate(request(), now - 60_000)); + for (String audience : List.of("sig_fixture/gs_two/profile_boot_001", "sig_fixture/gs_one/profile_boot_002")) + assertNull(validator(audience).validate(request(), now)); + String altered = token.substring(0, 90) + (token.charAt(90)=='A'?'B':'A') + token.substring(91); + assertNull(v.validate(request(altered, remote), now)); + assertNull(v.validate(request(token, "clientOtherUfrag"), now)); + assertThrows(IllegalArgumentException.class, () -> request(token + "=", remote)); + v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", "a-different-secret-that-has-32-characters"))); + assertNull(v.validate(request(), now)); + v.clear(); assertFalse(v.ready()); + assertNull(v.validate(request(), now)); + } + @Test void callbackMetadataAndClaimsDoNotPrintCredentials() { + assertFalse(request().toString().contains(token)); + assertFalse(request().toString().contains(remote)); + assertNotNull(validator().validate(request(), now)); // Native code owns STUN integrity verification. + } + @Test void keyUpdatesAreBoundedAtomicAndRedacted() { + var v = validator(); + var duplicate = new StatelessAdmissionValidator.TicketKey("K002", "a-valid-background-key-of-at-least-32-bytes"); + assertThrows(IllegalArgumentException.class, () -> v.installKeys(List.of(duplicate, duplicate))); + assertEquals(Set.of("K001"), v.keyIds()); + assertFalse(duplicate.toString().contains(duplicate.secret())); + assertThrows(IllegalArgumentException.class, () -> v.installKeys(Collections.nCopies(9, duplicate))); + } + @Test void fixturesHavePinnedHashesAndCanonicalFrames() throws Exception { + var provenance = fixture("provenance.json"); + assertEquals("urn:nethernet:external-signalling:v1", provenance.get("specification").getAsString()); + for (var entry : provenance.getAsJsonObject("files").entrySet()) { + try (var in = Objects.requireNonNull(getClass().getResourceAsStream("/nxs/" + entry.getKey()))) { + assertEquals(entry.getValue().getAsString(), HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(in.readAllBytes()))); + } + } + for (var entry : fixture("cloudburst-protocol-vectors.v1.json").getAsJsonArray("nethernetFrames")) { + var frame = entry.getAsJsonObject(); var decoder = new NetherNetFrameDecoder(); + byte[] actual = decoder.decode(HexFormat.of().parseHex(frame.get("frameHex").getAsString()), true); + if (frame.getAsJsonObject("decoded").get("complete").getAsBoolean()) + assertArrayEquals(HexFormat.of().parseHex(frame.get("payloadHex").getAsString()), actual); + else { assertNull(actual); decoder.clear(); assertEquals(0, decoder.retainedBytes()); } + } + } + @Test void backgroundKeyValidityBoundsDoNotExtendTokens() { + var v = validator(); + String secret = f.getAsJsonObject("context").get("secret").getAsString(); + v.installKeys(List.of(new StatelessAdmissionValidator.TicketKey("K001", secret, now + 1, now + 20_000))); + assertNull(v.validate(request(), now)); + assertNotNull(v.validate(request(), now + 1)); + assertNull(v.validate(request(), now + 20_000)); + } + +} diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/TestSignallingProvider.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/TestSignallingProvider.java new file mode 100644 index 00000000..20453d2b --- /dev/null +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/TestSignallingProvider.java @@ -0,0 +1,37 @@ +package org.cloudburstmc.netty.signalling.admission; + +import javax.crypto.*; +import javax.crypto.spec.*; +import java.nio.*; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.*; + +/** Test signalling source. Offer and issued token are NEVER delivered to the host. */ +final class TestSignallingProvider { + static final String AUDIENCE = "sig_fixture/gs_one/test_boot_001", SECRET = "stateless-fixture-secret-32-bytes-minimum"; + record Answer(String sdp, String token, String password) { @Override public String toString() { return "Answer[redacted]"; } } + static String field(String sdp, String name) { return sdp.lines().filter(s -> s.startsWith("a=" + name + ":")).findFirst().orElseThrow().substring(name.length() + 3).trim(); } + static byte[] utf8(String text) { return text.getBytes(StandardCharsets.UTF_8); } + static byte[] hmac(byte[] key, String data) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key,"HmacSHA256")); return mac.doFinal(utf8(data)); } + static Answer answer(String offer, String fingerprint, int port, long expiry, String audience, boolean wrongClientFingerprint) throws Exception { + String ufrag = field(offer, "ice-ufrag"), pwd = field(offer, "ice-pwd"); + byte[] fp = HexFormat.of().parseHex(field(offer, "fingerprint").substring(8).replace(":", "")); + if (wrongClientFingerprint) fp[0] ^= 1; + ByteBuffer claims = ByteBuffer.allocate(67 + pwd.length()); + claims.putInt((int)(expiry / 1000)).put(fp).putShort((short)5000).putInt(262144).put(new byte[16]).putLong(42).put((byte)pwd.length()).put(utf8(pwd)); + byte[] nonce = new byte[12];new SecureRandom().nextBytes(nonce); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(hmac(utf8(SECRET),"nxs-stateless-aead-v1\0"+audience),"AES"),new GCMParameterSpec(128,nonce)); + cipher.updateAAD(utf8("nxs-stateless-admission-v1\0NXS1K001\0"+audience+"\0"+ufrag)); + byte[] sealed = cipher.doFinal(claims.array());Arrays.fill(claims.array(),(byte)0); + var base64 = Base64.getEncoder().withoutPadding(); + String token = "NXS1K001" + base64.encodeToString(ByteBuffer.allocate(12+sealed.length).put(nonce).put(sealed).array()); + String password = base64.encodeToString(Arrays.copyOf(hmac(utf8(SECRET),"nxs-stateless-ice-v1\0"+audience+"\0"+token),24)); + String sdp = "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n" + + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=mid:0\r\na=setup:active\r\n" + + "a=ice-ufrag:"+token+"\r\na=ice-pwd:"+password+"\r\na=fingerprint:"+fingerprint+"\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"+ + "a=candidate:1 1 UDP 2130706431 127.0.0.1 "+port+" typ host\r\na=end-of-candidates\r\n"; + return new Answer(sdp,token,password); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92ffd9cb..336f358f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,16 @@ [versions] netty = "4.1.101.Final" junit = "5.9.2" +gson = "2.13.2" +libdatachannel = "0.24.1.1" +jose4j = "0.9.6" [libraries] +netty-codec-http = { group = "io.netty", name = "netty-codec-http", version.ref = "netty" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +libdatachannel-java = { group = "tel.schich", name = "libdatachannel-java", version.ref = "libdatachannel" } +jose4j = { group = "org.bitbucket.b_c", name = "jose4j", version.ref = "jose4j" } netty-common = { group = "io.netty", name = "netty-common", version.ref = "netty" } netty-buffer = { group = "io.netty", name = "netty-buffer", version.ref = "netty" } netty-codec = { group = "io.netty", name = "netty-codec", version.ref = "netty" } diff --git a/native-dependencies.properties b/native-dependencies.properties new file mode 100644 index 00000000..39e54cb2 --- /dev/null +++ b/native-dependencies.properties @@ -0,0 +1,10 @@ +# Maintained fork integration pins; branch names never select build inputs. +java.repository=teamziax/libdatachannel-java +java.commit=d855d4f3e9995b7ad926e6c0d23d0095666b2570 +nativeJavaGroup=io.github.teamziax +nativeJavaVersion=0.24.5.0-dev.d855d4f3e9995b7ad926e6c0d23d0095666b2570 +datachannel.repository=teamziax/libdatachannel +datachannel.commit=070e9ba5327dfac1d59ca4fab9bb991daed8faee +juice.repository=teamziax/libjuice +juice.commit=5498f67aff2092aa2bb868f74971ae7c076e5735 +platform=linux-x86_64 diff --git a/scripts/bootstrap-native-admission.sh b/scripts/bootstrap-native-admission.sh new file mode 100755 index 00000000..2abc7be3 --- /dev/null +++ b/scripts/bootstrap-native-admission.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +network_root=$(cd "$(dirname "$0")/.." && pwd) +read_pin() { sed -n "s/^$1=//p" "$network_root/native-dependencies.properties"; } +java_revision=$(read_pin 'java.commit') +datachannel_revision=$(read_pin 'datachannel.commit') +juice_revision=$(read_pin 'juice.commit') +output=${1:-"$network_root/.native-deps/maven"} +output=$(realpath -m "$output") +java_checkout=${NATIVE_JAVA_CHECKOUT:-"$network_root/.native-deps/libdatachannel-java-$java_revision"} +if [[ ! -d "$java_checkout/.git" ]]; then + if [[ -n "${NATIVE_JAVA_CHECKOUT:-}" ]]; then + echo 'Supplied native Java checkout is missing' >&2 + exit 1 + fi + mkdir -p "$(dirname "$java_checkout")" + git clone --no-checkout "https://github.com/$(read_pin 'java.repository').git" "$java_checkout" + git -C "$java_checkout" checkout --detach "$java_revision" + git -C "$java_checkout" submodule update --init --recursive +fi +[[ $(git -C "$java_checkout" rev-parse HEAD) == "$java_revision" ]] || { echo 'Native Java revision mismatch' >&2; exit 1; } +[[ $(git -C "$java_checkout/jni/libdatachannel" rev-parse HEAD) == "$datachannel_revision" ]] || { echo 'Native transport revision mismatch' >&2; exit 1; } +[[ $(git -C "$java_checkout/jni/libdatachannel/deps/libjuice" rev-parse HEAD) == "$juice_revision" ]] || { echo 'Native ICE revision mismatch' >&2; exit 1; } +bash "$java_checkout/scripts/package-development.sh" "$output" +python3 - "$output" "$network_root/native-dependencies.properties" <<'PY' +import hashlib, json, pathlib, sys +root, manifest = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) +pins = dict(line.split('=', 1) for line in manifest.read_text().splitlines() if line and not line.startswith('#')) +folder = root / pins['nativeJavaGroup'].replace('.', '/') / 'libdatachannel-java' / pins['nativeJavaVersion'] +provenance = json.loads((folder / 'provenance.json').read_text()) +for field, pin in [('bindingRevision','java.commit'),('libdatachannelRevision','datachannel.commit'),('libjuiceRevision','juice.commit')]: + if provenance[field] != pins[pin]: raise SystemExit('Native provenance mismatch: ' + field) +for name, expected in provenance['sha256'].items(): + if hashlib.sha256((folder / name).read_bytes()).hexdigest() != expected: raise SystemExit('Native artifact hash mismatch: ' + name) +print('Verified native artifacts: ' + str(folder)) +PY diff --git a/settings.gradle.kts b/settings.gradle.kts index 6b2129ce..669f3c45 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,3 +21,6 @@ plugins { } include("transport-raknet") + +include("transport-nethernet") +include("external-signalling") diff --git a/transport-nethernet/README.md b/transport-nethernet/README.md new file mode 100644 index 00000000..78c510cc --- /dev/null +++ b/transport-nethernet/README.md @@ -0,0 +1,62 @@ +# netty-transport-nethernet + +## Downloads + +### Releases ![Maven Central Version](https://img.shields.io/maven-central/v/dev.kastle.netty/netty-transport-nethernet?label=Maven%20Central&color=%233fb950) + +The library is published to Maven Central. See the [latest release](https://github.com/Kas-tle/NetworkCompatible/releases/latest) for the latest version. + +### Snapshots [![](https://jitpack.io/v/dev.kastle/NetworkCompatible.svg)](https://jitpack.io/#dev.kastle/NetworkCompatible) + +Snapshots are available from [jitpack](https://jitpack.io/#dev.kastle/NetworkCompatible). Note the package group for jitpack is `dev.kastle.NetworkCompatible` witht the name `netty-transport-nethernet`. + +## Usage + +> [!IMPORTANT] +> This library uses [libdatachannel-java](https://github.com/pschichtel/libdatachannel-java) and needs its platform-specific native library at runtime. The main artifact contains no natives, so you have to add the classifier(s) for the platforms you ship yourself. + +```kotlin +val nativePlatforms = listOf( + "windows-x86_64", + "x86_64", // linux x86_64 + "aarch64", // linux aarch64 + "macos-x86_64", + "macos-arm64" +) + +dependencies { + implementation("dev.kastle.netty:netty-transport-nethernet:$netherNetVersion") + nativePlatforms.forEach { platform -> + runtimeOnly("tel.schich:libdatachannel-java:$libdatachannelVersion:$platform") + } +} +``` + +### Examples + +These projects use this library to provide Nethernet support. You can see their source code for examples of how to use this library: + +- [Kas-tle/ProxyPass](https://github.com/Kas-tle/ProxyPass): Uses server and client to debug game packets over various connection types. +- [MCXboxBroadcast/Broadcaster](https://github.com/MCXboxBroadcast/Broadcaster): Uses server to allow Bedrock clients to transfer to other Bedrock servers via Xbox Live. +- [ViaVersion/ViaFabricPlus](https://github.com/ViaVersion/ViaFabricPlus): Uses client to connect to LAN games and Realms. +- [ViaVersion/ViaProxy](https://github.com/ViaVersion/ViaProxy): Uses client to connect to LAN games and Realms. + +## Packet Flow + +### Client + +--- + + + + + + +### Server + +--- + + + + + \ No newline at end of file diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts new file mode 100644 index 00000000..6727bc69 --- /dev/null +++ b/transport-nethernet/build.gradle.kts @@ -0,0 +1,32 @@ +description = "NetherNet transport for Netty" + +dependencies { + api(libs.bundles.netty) + api(libs.netty.codec.http) + api(libs.expiringmap) + api("${rootProject.property("nativeJavaGroup")}:libdatachannel-java:${rootProject.property("nativeJavaVersion")}") + testRuntimeOnly("${rootProject.property("nativeJavaGroup")}:libdatachannel-java:${rootProject.property("nativeJavaVersion")}:x86_64") + + implementation(libs.gson) + implementation(libs.jose4j) + + testImplementation(libs.bundles.junit) + testRuntimeOnly(libs.junit.platform.launcher) +} + +configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + withJavadocJar() + withSourcesJar() +} + +tasks.jar { + manifest.attributes["Automatic-Module-Name"] = "dev.kastle.netty.transport.nethernet" +} + +tasks.register("runDiscovery") { + mainClass.set("dev.kastle.netty.util.nethernet.NetherNetScanner") + classpath = sourceSets["main"].runtimeClasspath +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java new file mode 100644 index 00000000..079aac26 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -0,0 +1,296 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; +import io.netty.buffer.ByteBuf; +import io.netty.channel.AbstractChannel; +import io.netty.channel.Channel; +import io.netty.channel.ChannelConfig; +import io.netty.channel.ChannelMetadata; +import io.netty.channel.ChannelOutboundBuffer; +import io.netty.channel.EventLoop; +import io.netty.util.ReferenceCountUtil; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import tel.schich.libdatachannel.DataChannel; +import tel.schich.libdatachannel.DataChannelCallback; +import tel.schich.libdatachannel.PeerConnection; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public abstract class NetherNetChannel extends AbstractChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetChannel.class); + protected static final ChannelMetadata METADATA = new ChannelMetadata(false); + + protected DefaultNetherChannelConfig config; + protected volatile PeerConnection peerConnection; + protected volatile SocketAddress remoteAddress; + protected volatile SocketAddress localAddress; + + protected DataChannel reliableChannel; + protected DataChannel unreliableChannel; + + protected final Queue pendingWrites = new ConcurrentLinkedQueue<>(); + + protected volatile boolean open = true; + + protected NetherNetChannel(Channel parent, InetSocketAddress remote, InetSocketAddress local) { + super(parent); + this.remoteAddress = remote; + this.localAddress = local; + } + + public void setDataChannels(DataChannel reliable, DataChannel unreliable) { + this.reliableChannel = reliable; + this.unreliableChannel = unreliable; + + this.reliableChannel.onOpen.register(channel -> eventLoop().execute(this::onDataChannelStateChange)); + this.reliableChannel.onClosed.register(channel -> eventLoop().execute(this::onDataChannelStateChange)); + + this.reliableChannel.onMessage.register(DataChannelCallback.Message.handleBinary(new DataChannelCallback.BinaryMessage() { + private final ByteBuf assemblyBuf = config.getAllocator().buffer(); + private int currentSegmentCount = -1; + + @Override + public void onBinary(DataChannel channel, ByteBuffer data) { + if (!data.hasRemaining()) + return; + + int segments = data.get() & 0xFF; + + if (currentSegmentCount == -1) { + currentSegmentCount = segments; + } else { + if (segments != currentSegmentCount - 1) { + assemblyBuf.clear(); + currentSegmentCount = -1; + return; + } + currentSegmentCount = segments; + } + + if (data.hasRemaining()) { + byte[] payload = new byte[data.remaining()]; + data.get(payload); + assemblyBuf.writeBytes(payload); + } + + if (segments == 0) { + try { + if (assemblyBuf.isReadable()) { + ByteBuf packet = assemblyBuf.copy(); + assemblyBuf.skipBytes(assemblyBuf.readableBytes()); + + eventLoop().execute(() -> { + pipeline().fireChannelRead(packet); + pipeline().fireChannelReadComplete(); + }); + } + } catch (Exception e) { + log.error("Error processing packet", e); + } finally { + assemblyBuf.clear(); + currentSegmentCount = -1; + } + } + } + })); + + if (reliableChannel.isOpen()) { + eventLoop().execute(this::onDataChannelStateChange); + } + } + + private void onDataChannelStateChange() { + if (isActive()) { + if (!pendingWrites.isEmpty()) { + pipeline().fireChannelWritabilityChanged(); + unsafe().flush(); + } + } else if (reliableChannel != null && reliableChannel.isClosed()) { + close(); + } + } + + @Override + protected void doWrite(ChannelOutboundBuffer in) throws Exception { + if (!isActive()) { + Object msg; + while ((msg = in.current()) != null) { + ReferenceCountUtil.retain(msg); + pendingWrites.add(msg); + in.remove(); + } + return; + } + + while (!pendingWrites.isEmpty()) { + Object msg = pendingWrites.poll(); + try { + writeInternal(msg); + } finally { + ReferenceCountUtil.release(msg); + } + } + + Object msg; + while ((msg = in.current()) != null) { + writeInternal(msg); + in.remove(); + } + } + + private void writeInternal(Object msg) { + if (!(msg instanceof ByteBuf)) + return; + + ByteBuf payload = (ByteBuf) msg; + + ByteBuf framed = payload.retainedDuplicate(); + + int totalLength = framed.readableBytes(); + int maxPayload = NetherNetConstants.MAX_SCTP_MESSAGE_SIZE - 1; + + int segments = (totalLength / maxPayload); + if (totalLength % maxPayload != 0) + segments++; + + try { + int offset = 0; + for (int i = 0; i < segments; i++) { + int remaining = segments - 1 - i; + int chunkSize = Math.min(maxPayload, framed.readableBytes() - offset); + + ByteBuffer chunk = ByteBuffer.allocateDirect(1 + chunkSize); + chunk.put((byte) remaining); + + framed.getBytes(offset, chunk); + chunk.position(chunk.limit()); + chunk.flip(); + + reliableChannel.sendMessage(chunk); + offset += chunkSize; + } + } catch (Exception e) { + pipeline().fireExceptionCaught(e); + } finally { + framed.release(); + } + } + + @Override + protected void doRegister() throws Exception { + } + + @Override + protected void doDeregister() throws Exception { + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + throw new UnsupportedOperationException("NetherNetChannel cannot be bound directly"); + } + + @Override + protected void doDisconnect() throws Exception { + doClose(); + } + + @Override + protected void doClose() throws Exception { + this.open = false; + closeWebRTC(); + + Object msg; + while ((msg = pendingWrites.poll()) != null) { + ReferenceCountUtil.release(msg); + } + } + + /** + * Closes the data channels and peer connection, dropping their listeners first. + */ + protected void closeWebRTC() { + if (reliableChannel != null) { + deregisterAll(reliableChannel); + reliableChannel.close(); + reliableChannel = null; + } + if (unreliableChannel != null) { + deregisterAll(unreliableChannel); + unreliableChannel.close(); + unreliableChannel = null; + } + if (peerConnection != null) { + deregisterAll(peerConnection); + peerConnection.close(); + peerConnection = null; + } + } + + static void deregisterAll(PeerConnection peer) { + peer.onLocalDescription.deregisterAll(); + peer.onLocalCandidate.deregisterAll(); + peer.onStateChange.deregisterAll(); + peer.onIceStateChange.deregisterAll(); + peer.onGatheringStateChange.deregisterAll(); + peer.onSignalingStateChange.deregisterAll(); + peer.onDataChannel.deregisterAll(); + peer.onTrack.deregisterAll(); + } + + static void deregisterAll(DataChannel channel) { + channel.onOpen.deregisterAll(); + channel.onClosed.deregisterAll(); + channel.onError.deregisterAll(); + channel.onMessage.deregisterAll(); + channel.onBufferedAmountLow.deregisterAll(); + channel.onAvailable.deregisterAll(); + } + + @Override + protected void doBeginRead() throws Exception { + } + + @Override + protected boolean isCompatible(EventLoop loop) { + return true; + } + + @Override + protected SocketAddress localAddress0() { + return this.localAddress; + } + + @Override + protected SocketAddress remoteAddress0() { + return this.remoteAddress; + } + + @Override + public ChannelConfig config() { + return this.config; + } + + @Override + public boolean isOpen() { + return this.open; + } + + @Override + public boolean isActive() { + return isOpen() && this.reliableChannel != null && this.reliableChannel.isOpen(); + } + + @Override + public ChannelMetadata metadata() { + return METADATA; + } + + public void setRemoteAddress(SocketAddress remoteAddress) { + this.remoteAddress = remoteAddress; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java new file mode 100644 index 00000000..c74d04c7 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -0,0 +1,42 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFactory; + +import java.util.function.Supplier; + +public class NetherNetChannelFactory implements ChannelFactory { + + private final Supplier channelCreator; + + private NetherNetChannelFactory(Supplier channelCreator) { + this.channelCreator = channelCreator; + } + + @Override + public T newChannel() { + return channelCreator.get(); + } + + /** + * Creates a NetherNet Server Channel Factory. + * + * @param signaling The NetherNetServerSignaling instance for signaling. + * @return A ChannelFactory for NetherNetServerChannel. + */ + public static ChannelFactory server(NetherNetServerSignaling signaling) { + return new NetherNetChannelFactory<>(() -> new NetherNetServerChannel(signaling)); + } + + /** + * Creates a NetherNet Client Channel Factory. + * + * @param signaling The NetherNetClientSignaling instance for signaling. + * @return A ChannelFactory for NetherNetClientChannel. + */ + public static ChannelFactory client(NetherNetClientSignaling signaling) { + return new NetherNetChannelFactory<>(() -> new NetherNetClientChannel(signaling)); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java new file mode 100644 index 00000000..afc2e176 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java @@ -0,0 +1,32 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; +import io.netty.channel.Channel; +import io.netty.channel.ChannelPromise; +import tel.schich.libdatachannel.PeerConnection; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; + +public class NetherNetChildChannel extends NetherNetChannel { + public NetherNetChildChannel(Channel parent, PeerConnection peerConnection, InetSocketAddress remote, InetSocketAddress local) { + super(parent, remote, local); + this.peerConnection = peerConnection; + this.config = new DefaultNetherChannelConfig(this); + } + + @Override + protected AbstractUnsafe newUnsafe() { + return new AbstractUnsafe() { + @Override + public void connect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) { + promise.setFailure(new UnsupportedOperationException("Child channel cannot connect")); + } + }; + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + throw new UnsupportedOperationException("Child channel cannot be bound"); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java new file mode 100644 index 00000000..88ca62ed --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -0,0 +1,309 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherClientChannelConfig; +import dev.kastle.netty.channel.nethernet.config.NetherChannelOption; +import dev.kastle.netty.channel.nethernet.config.NetherNetAddress; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo; +import io.netty.channel.ChannelPromise; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import tel.schich.libdatachannel.DataChannel; +import tel.schich.libdatachannel.DataChannelInitSettings; +import tel.schich.libdatachannel.DataChannelReliability; +import tel.schich.libdatachannel.PeerConnection; +import tel.schich.libdatachannel.PeerConnectionConfiguration; +import tel.schich.libdatachannel.PeerState; +import tel.schich.libdatachannel.SessionDescriptionType; + +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.channels.ClosedChannelException; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +public class NetherNetClientChannel extends NetherNetChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetClientChannel.class); + + private final NetherNetClientSignaling signaling; + + private volatile long connectionId; // Session ID (Long) + private volatile String targetNetworkId; // Peer ID (String, for Realms) + + private volatile boolean handshakeComplete = false; + + private ChannelPromise connectPromise; + + private volatile ScheduledFuture handshakeTimeoutTask; + + private int retryCount = 0; + + /** + * Creates a NetherNetClientChannel. + * + * @param signaling The NetherNetClientSignaling instance for signaling. + */ + public NetherNetClientChannel(NetherNetClientSignaling signaling) { + super(null, null, null); + this.signaling = signaling; + this.connectionId = this.cycleConnectionId(); + this.config = new DefaultNetherClientChannelConfig(this); + } + + public void setTargetNetworkId(String id) { + this.targetNetworkId = id; + } + + @Override + public boolean isActive() { + return super.isActive() && handshakeComplete; + } + + @Override + protected void doClose() throws Exception { + super.doClose(); + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); + } + if (signaling != null) { + signaling.removeSignalHandler(this.connectionId); + signaling.close(); + } + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ClosedChannelException()); + } + } + + @Override + protected AbstractUnsafe newUnsafe() { + return new NetherNetClientUnsafe(); + } + + private class NetherNetClientUnsafe extends AbstractUnsafe { + @Override + public void connect(SocketAddress remote, SocketAddress local, ChannelPromise promise) { + if (!promise.setUncancellable() || !ensureOpen(promise)) return; + NetherNetClientChannel.this.connectPromise = promise; + + if (remote instanceof NetherNetAddress) { + String targetId = ((NetherNetAddress) remote).getNetworkId(); + NetherNetClientChannel.this.setTargetNetworkId(targetId); + NetherNetClientChannel.this.remoteAddress = remote; + } else if (remote instanceof InetSocketAddress) { + NetherNetClientChannel.this.remoteAddress = (InetSocketAddress) remote; + NetherNetClientChannel.this.setTargetNetworkId("0"); // "0" triggers auto-discovery in signaling + } else { + promise.setFailure(new IllegalArgumentException("Unsupported address: " + remote.getClass())); + return; + } + + eventLoop().execute(() -> startHandshake()); + } + } + + private void startHandshake() { + if (!isOpen() || handshakeComplete) return; + + log.debug("Starting Handshake with Connection ID: {}", Long.toUnsignedString(this.connectionId)); + + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + + signaling.setNotFoundHandler(reason -> { + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Target Network ID " + this.targetNetworkId + " not found or offline.")); + } + close(); + }); + + int handshakeTimeout = this.config().getOption(NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS); + handshakeTimeoutTask = eventLoop().schedule(() -> { + resetAndRetryHandshake(); + }, handshakeTimeout, TimeUnit.MILLISECONDS); + + signaling.setSignalHandler(this.connectionId, this::handleSignal); + + signaling.connect(remoteAddress).thenAcceptAsync(iceServers -> { + if (handshakeComplete) return; + try { + // If this is a retry, peerConnection might be null, so we recreate it + if (peerConnection == null) { + initWebRTC(iceServers); + createAndSendOffer(); + } + } catch (Exception e) { + ConnectException ce = new ConnectException("Failed to start WebRTC handshake: " + e.getMessage()); + ce.initCause(e); + if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(ce); + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + close(); + } + }, eventLoop()).exceptionally(e -> { + ConnectException ce = new ConnectException("Signaling connection failed: " + e.getMessage()); + ce.initCause(e); + if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(ce); + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + close(); + return null; + }); + } + + private void resetAndRetryHandshake() { + if (!isOpen()) return; + if (connectPromise != null && connectPromise.isDone() && !connectPromise.isSuccess()) return; + if (handshakeComplete) return; + + // fail exceptionally if max retries reached + int maxRetries = this.config().getOption(NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS); + if (retryCount >= maxRetries) { + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Connection timed out after " + retryCount + " retries")); + } + close(); + return; + } + + retryCount++; + closeWebRTC(); + + signaling.removeSignalHandler(this.connectionId); + this.cycleConnectionId(); + startHandshake(); + } + + private void initWebRTC(List iceServers) { + PeerConnectionConfiguration rtcConfig = this.config.getOption(NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG) + .withDisableAutoNegotiation(true) + .withIceServers(iceServers.stream().map(IceServerInfo::toUris).flatMap(List::stream).toList()); + + peerConnection = PeerConnection.createPeer(rtcConfig); + + // Registering is what arms the native callback, so it must happen before anything can fire it + peerConnection.onLocalCandidate.register((peer, candidate, mediaId) -> { + try { + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate) + ); + } catch (Exception e) { + log.error("Failed to send ICE candidate", e); + eventLoop().execute(() -> resetAndRetryHandshake()); + } + }); + + peerConnection.onStateChange.register((peer, state) -> { + if (state == PeerState.RTC_FAILED) { + // Fast fail trigger: retry immediately instead of waiting for timeout + log.warn("PeerConnection entered FAILED state, resetting and retrying handshake."); + eventLoop().execute(() -> resetAndRetryHandshake()); + } else { + log.trace("PeerConnection state changed to {}", state); + } + }); + + setupDataChannels(); + } + + private void createAndSendOffer() { + if (peerConnection == null) return; + + // Not null for autodetection, that path releases an unset string in JNI and crashes the JVM + peerConnection.setLocalDescription("offer"); + try { + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalConnectRequest(connectionId, peerConnection.localDescription()) + ); + } catch (Exception e) { + log.error("Failed to send Connect Request", e); + eventLoop().execute(() -> resetAndRetryHandshake()); + } + } + + private void handleSignal(String signal) { + String[] parts = signal.split(" ", 3); + if (parts.length < 2) return; // Allow length 2 for ERROR packets without payload + String type = parts[0]; + String idStr = parts[1].trim(); + String data = parts.length > 2 ? parts[2] : ""; + + // Verify this signal belongs to the current attempt + try { + long signalId = Long.parseUnsignedLong(idStr); + if (signalId != this.connectionId) { + log.debug("Ignored stale signal for ID {}", idStr); + return; + } + } catch (NumberFormatException e) { + return; + } + + eventLoop().execute(() -> { + if (peerConnection == null) return; + if (!isOpen() || handshakeComplete) return; + + switch (type) { + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_RESPONSE -> { + try { + peerConnection.setRemoteDescription(data, SessionDescriptionType.ANSWER); + } catch (Exception e) { + log.debug("Failed to apply answer for {}: {}", Long.toUnsignedString(connectionId), e.toString()); + } + } + case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { + try { + peerConnection.addRemoteCandidate(data); + } catch (Exception e) { + log.debug("Failed to apply ICE candidate for {}: {}", Long.toUnsignedString(connectionId), e.toString()); + } + } + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_ERROR -> { + log.error("Received SIGNAL_CONNECT_ERROR for {}.", Long.toUnsignedString(this.connectionId)); + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Remote peer sent connect error.")); + } + close(); + } + default -> { + log.debug("Received unknown signal type: {}", type); + } + } + }); + } + + private void setupDataChannels() { + DataChannelInitSettings reliableInit = DataChannelInitSettings.DEFAULT; + + DataChannelInitSettings unreliableInit = DataChannelInitSettings.DEFAULT + .withReliability(new DataChannelReliability(true, true, 0L, 0)); + + DataChannel reliable = peerConnection.createDataChannel(NetherNetConstants.RELIABLE_CHANNEL_LABEL, reliableInit); + DataChannel unreliable = peerConnection.createDataChannel(NetherNetConstants.UNRELIABLE_CHANNEL_LABEL, unreliableInit); + + reliable.onOpen.register(channel -> eventLoop().execute(() -> { + if (handshakeComplete) return; + + log.debug("NetherNet Connection Established!"); + handshakeComplete = true; + + // Cancel timeout now that we are done + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); + } + + setDataChannels(reliable, unreliable); + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.trySuccess(); + } + pipeline().fireChannelActive(); + })); + } + + private long cycleConnectionId() { + this.connectionId = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE); + return this.connectionId; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java new file mode 100644 index 00000000..9a38665e --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -0,0 +1,182 @@ +package dev.kastle.netty.channel.nethernet; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.MessageDigest; + +public class NetherNetConstants { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetConstants.class); + + public static final int DISCOVERY_PORT = 7551; + public static final long APPLICATION_ID = 0xDEADBEEFL; + + // Packet IDs + public static final int ID_DISCOVERY_REQUEST = 0x00; + public static final int ID_DISCOVERY_RESPONSE = 0x01; + public static final int ID_DISCOVERY_MESSAGE = 0x02; + + // WebRTC Negotiation Message Types + public static final String RTC_NEGOTIATION_CONNECT_REQUEST = "CONNECTREQUEST"; + public static final String RTC_NEGOTIATION_CONNECT_RESPONSE = "CONNECTRESPONSE"; + public static final String RTC_NEGOTIATION_CANDIDATE_ADD = "CANDIDATEADD"; + public static final String RTC_NEGOTIATION_CONNECT_ERROR = "CONNECTERROR"; + + // Signaling User Agent String + public static final String SIGNALING_USER_AGENT = "libHttpClient/1.0.0.0"; + + // Xbox Signaling Message Types + public static final int XBOX_SIGNAL_NOT_FOUND = 0; + public static final int XBOX_SIGNAL_SIGNAL = 1; + public static final int XBOX_SIGNAL_CREDENTIALS = 2; + public static final int XBOX_SIGNAL_ACCEPTED = 3; + public static final int XBOX_SIGNAL_ACK = 4; + + // Xbox JSON-RPC Signaling Method Names + public static final String XBOX_RPC_METHOD_TURN_AUTH = "Signaling_TurnAuth_v1_0"; + public static final String XBOX_RPC_METHOD_SEND_MESSAGE = "Signaling_SendClientMessage_v1_0"; + public static final String XBOX_RPC_METHOD_RECEIVE_MESSAGE = "Signaling_ReceiveMessage_v1_0"; + public static final String XBOX_RPC_METHOD_PING = "System_Ping_v1_0"; + public static final String XBOX_RPC_METHOD_PONG = "System_Pong_v1_0"; + public static final String XBOX_RPC_INNER_METHOD_WEBRTC = "Signaling_WebRtc_v1_0"; + public static final String XBOX_RPC_INNER_METHOD_DELIVERY = "Signaling_DeliveryNotification_V1_0"; + + // SCTP Constants + public static final int MAX_SCTP_MESSAGE_SIZE = 10000; + public static final int MAX_ADVERTISED_MESSAGE_SIZE = 256 * 1024; // 256 KB + + public static final String RELIABLE_CHANNEL_LABEL = "ReliableDataChannel"; + public static final String UNRELIABLE_CHANNEL_LABEL = "UnreliableDataChannel"; + + private static final byte[] KEY_BYTES; + + static { + try { + ByteBuf buf = Unpooled.buffer(8); + buf.writeLongLE(APPLICATION_ID); + byte[] input = new byte[8]; + buf.readBytes(input); + buf.release(); + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + KEY_BYTES = digest.digest(input); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Encrypts a discovery packet using AES encryption and HMAC-SHA256 for integrity. + * + * @param packet The ByteBuf containing the discovery packet to encrypt. + * @return The encrypted byte array ready for transmission. + * @throws Exception if encryption fails. + */ + public static byte[] encryptDiscoveryPacket(ByteBuf packet) throws Exception { + int len = packet.readableBytes() + 2; + ByteBuf payload = Unpooled.buffer(len); + payload.writeShortLE(len); + payload.writeBytes(packet); + + byte[] payloadBytes = new byte[payload.readableBytes()]; + payload.readBytes(payloadBytes); + payload.release(); + + SecretKeySpec secretKey = new SecretKeySpec(KEY_BYTES, "AES"); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + byte[] encrypted = cipher.doFinal(payloadBytes); + + Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); + SecretKeySpec secret_key = new SecretKeySpec(KEY_BYTES, "HmacSHA256"); + sha256_HMAC.init(secret_key); + byte[] signature = sha256_HMAC.doFinal(payloadBytes); + + ByteBuf result = Unpooled.buffer(signature.length + encrypted.length); + result.writeBytes(signature); + result.writeBytes(encrypted); + + byte[] out = new byte[result.readableBytes()]; + result.readBytes(out); + result.release(); + return out; + } + + /** + * Decrypts a discovery packet and verifies its integrity. + * + * @param input The ByteBuf containing the received discovery packet. + * @return A ByteBuf with the decrypted payload, or null if verification fails. + * @throws Exception if decryption fails. + */ + public static ByteBuf decryptDiscoveryPacket(ByteBuf input) throws Exception { + if (input.readableBytes() < 32) { + log.debug("Discovery packet too short to contain valid signature"); + return null; + }; + + byte[] signature = new byte[32]; + input.readBytes(signature); + + byte[] encrypted = new byte[input.readableBytes()]; + input.readBytes(encrypted); + + SecretKeySpec secretKey = new SecretKeySpec(KEY_BYTES, "AES"); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + byte[] payloadBytes = cipher.doFinal(encrypted); + + Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); + SecretKeySpec secret_key = new SecretKeySpec(KEY_BYTES, "HmacSHA256"); + sha256_HMAC.init(secret_key); + byte[] calculatedSignature = sha256_HMAC.doFinal(payloadBytes); + + if (!MessageDigest.isEqual(signature, calculatedSignature)) { + log.debug("Invalid discovery packet signature"); + return null; + } + + ByteBuf payload = Unpooled.wrappedBuffer(payloadBytes); + payload.readUnsignedShortLE(); // Length prefix + + return payload; + } + + /** + * Builds a signaling message for a CONNECTREQUEST. + * + * @param connectionId The unique connection ID. + * @param sdp The SDP payload. + * @return The formatted signaling message. + */ + public static String buildSignalConnectRequest(long connectionId, String sdp) { + return RTC_NEGOTIATION_CONNECT_REQUEST + " " + Long.toUnsignedString(connectionId) + " " + sdp; + } + + /** + * Builds a signaling message for a CONNECTRESPONSE. + * + * @param connectionId The unique connection ID. + * @param sdp The SDP payload. + * @return The formatted signaling message. + */ + public static String buildSignalConnectResponse(long connectionId, String sdp) { + return RTC_NEGOTIATION_CONNECT_RESPONSE + " " + Long.toUnsignedString(connectionId) + " " + sdp; + } + + /** + * Builds a signaling message for a CANDIDATEADD. + * + * @param connectionId The unique connection ID. + * @param candidateSdp The candidate SDP string. + * @return The formatted signaling message. + */ + public static String buildSignalCandidateAdd(long connectionId, String candidateSdp) { + return RTC_NEGOTIATION_CANDIDATE_ADD + " " + Long.toUnsignedString(connectionId) + " " + candidateSdp; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java new file mode 100644 index 00000000..117a5a53 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -0,0 +1,363 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherServerChannelConfig; +import dev.kastle.netty.channel.nethernet.config.NetherChannelOption; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo; +import dev.kastle.netty.util.nethernet.ServerIdentity; +import io.netty.channel.AbstractServerChannel; +import io.netty.channel.ChannelConfig; +import io.netty.channel.ChannelMetadata; +import io.netty.channel.EventLoop; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import org.jose4j.lang.JoseException; +import tel.schich.libdatachannel.DataChannel; +import tel.schich.libdatachannel.GatheringState; +import tel.schich.libdatachannel.PeerConnection; +import tel.schich.libdatachannel.PeerConnectionConfiguration; +import tel.schich.libdatachannel.PeerState; +import tel.schich.libdatachannel.SessionDescriptionType; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class NetherNetServerChannel extends AbstractServerChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetServerChannel.class); + private static final ChannelMetadata METADATA = new ChannelMetadata(false, 16); + + private final DefaultNetherServerChannelConfig config; + private final NetherNetServerSignaling signaling; + + private InetSocketAddress localAddress; + private volatile boolean open = true; + + private ServerIdentity serverIdentity; + + /** + * Creates a NetherNetServerChannel. + * + * @param signaling The NetherNetServerSignaling instance for signaling. + */ + public NetherNetServerChannel(NetherNetServerSignaling signaling) { + this.signaling = signaling; + this.config = new DefaultNetherServerChannelConfig(this); + + // Prefer the signaling identity so answers are signed with a key clients can attribute to us + this.serverIdentity = signaling.serverIdentity(); + if (this.serverIdentity == null) { + try { + this.serverIdentity = ServerIdentity.generate("self"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + if (!(localAddress instanceof InetSocketAddress)) throw new IllegalArgumentException("Unsupported address type"); + this.localAddress = (InetSocketAddress) localAddress; + + this.signaling.setNewConnectionHandler((connectionId, remoteNetworkId, offerSdp) -> { + acceptConnection(connectionId, offerSdp, remoteNetworkId); + }); + + this.signaling.bind(localAddress, eventLoop()); + } + + /** + * Pins ICE to the bound address, so the transport uses one predictable port rather than an + * ephemeral one per connection. Skipped when the signaling holds that UDP port itself. + * + * @param config The configuration to derive from. + * @return The configuration with the bound address applied. + */ + private PeerConnectionConfiguration bindIce(PeerConnectionConfiguration config) { + if (localAddress == null || !signaling.allowsIceOnLocalPort()) return config; + + // A wildcard bind is left unset so ICE keeps gathering on every interface + InetAddress host = localAddress.getAddress(); + if (host != null && !host.isAnyLocalAddress()) { + config = config.withBindAddress(host); + } + + int port = localAddress.getPort(); + if (port <= 0) return config; + + // Enable multiplexing and set the port + return config + .withEnableIceUdpMux(true) + .withPortRangeBegin((short) port) + .withPortRangeEnd((short) port); + } + + public void acceptConnection(long connectionId, String offerSdp, String remoteNetworkId) { + PeerConnectionConfiguration rtcConfig = bindIce(this.config.getOption(NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG)) + .withDisableAutoNegotiation(true) + .withIceServers(this.signaling.getIceServers().stream().map(IceServerInfo::toUris).flatMap(List::stream).toList()); + + ServerPeerConnectionObserver observer = new ServerPeerConnectionObserver(connectionId, remoteNetworkId); + PeerConnection pc = PeerConnection.createPeer(rtcConfig); + observer.setPeerConnection(pc); + + NetherNetChildChannel child = new NetherNetChildChannel(this, pc, new InetSocketAddress(0), localAddress); + observer.setChildChannel(child); + + child.closeFuture().addListener(future -> signaling.removeSignalHandler(connectionId)); + + int handshakeTimeoutSeconds = this.config.getOption(NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS); + ScheduledFuture timeoutTask = eventLoop().schedule(() -> { + if (!child.isActive()) { + log.warn("Connection {} timed out during handshake ({}s)", Long.toUnsignedString(connectionId), handshakeTimeoutSeconds); + child.close(); + pc.close(); + } + }, handshakeTimeoutSeconds, TimeUnit.SECONDS); + observer.setHandshakeTimeout(timeoutTask); + + observer.register(pc); + + // Register Signal Handler + signaling.setSignalHandler(connectionId, (signal) -> { + String[] parts = signal.split(" ", 3); + if (parts.length < 3) return; + String type = parts[0]; + String data = parts[2]; + + switch (type) { + case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { + log.trace("Applying Remote Candidate for {}: {}", Long.toUnsignedString(connectionId), data); + try { + pc.addRemoteCandidate(data); + } catch (Exception e) { + log.debug("Failed to apply ICE candidate for {} (Connection likely closed): {}", Long.toUnsignedString(connectionId), e.toString()); + } + } + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_ERROR -> { + log.debug("Received CONNECT_ERROR for {}", Long.toUnsignedString(connectionId)); + child.close(); + } + } + }); + + // Handle Offer + try { + pc.setRemoteDescription(offerSdp, SessionDescriptionType.OFFER); + log.trace("Remote description set for {}", Long.toUnsignedString(connectionId)); + pc.setLocalDescription("answer"); + } catch (Exception e) { + log.error("Failed to negotiate answer for {}", Long.toUnsignedString(connectionId), e); + abandon(connectionId, timeoutTask, pc); + return; + } + + // Anything without trickle answers once from onGatheringStateChange instead + if (signaling.usesTrickleIce()) { + log.trace("Sending Answer SDP for {}", Long.toUnsignedString(connectionId)); + try { + signaling.sendSignal( + remoteNetworkId, + NetherNetConstants.buildSignalConnectResponse(connectionId, serverIdentity.augmentAnswer(pc.localDescription())) + ); + } catch (JoseException e) { + log.error("Failed to send Answer SDP for {}", Long.toUnsignedString(connectionId), e); + abandon(connectionId, timeoutTask, pc); + return; + } + } + + pipeline().fireChannelRead(child); + } + + /** + * Tears down a connection that failed before its child channel reached the pipeline. The child is + * left alone as it was never registered with an event loop, so closing it would throw. + * + * @param connectionId The connection being abandoned. + * @param timeoutTask The handshake timeout to cancel. + * @param pc The peer connection to close. + */ + private void abandon(long connectionId, ScheduledFuture timeoutTask, PeerConnection pc) { + timeoutTask.cancel(false); + signaling.removeSignalHandler(connectionId); + NetherNetChannel.deregisterAll(pc); + pc.close(); + } + + /** + * Observer to handle Data Channel creation from the client. + */ + private class ServerPeerConnectionObserver { + private final long connectionId; + private final String remoteNetworkId; + private NetherNetChildChannel child; + + private DataChannel reliable; + private DataChannel unreliable; + + private ScheduledFuture handshakeTimeout; + + private PeerConnection peerConnection; + private volatile boolean fullSdpSent = false; + + public ServerPeerConnectionObserver(long connectionId, String remoteNetworkId) { + this.connectionId = connectionId; + this.remoteNetworkId = remoteNetworkId; + } + + public void register(PeerConnection pc) { + pc.onDataChannel.register((peer, dataChannel) -> onDataChannel(dataChannel)); + pc.onLocalCandidate.register((peer, candidate, mediaId) -> onLocalCandidate(candidate)); + pc.onStateChange.register((peer, state) -> onConnectionChange(state)); + pc.onGatheringStateChange.register((peer, state) -> onGatheringStateChange(state)); + } + + public void setHandshakeTimeout(ScheduledFuture handshakeTimeout) { + this.handshakeTimeout = handshakeTimeout; + } + + public void setChildChannel(NetherNetChildChannel child) { + this.child = child; + checkDataChannels(); + } + + public void setPeerConnection(PeerConnection pc) { + this.peerConnection = pc; + } + + private void onLocalCandidate(String candidate) { + if (log.isTraceEnabled()) { + log.trace("Generated ICE Candidate for {}: {} (Type: {})", + Long.toUnsignedString(this.connectionId), candidate, extractCandidateType(candidate)); + } + + // Skip sending candidate if the signaling doesn't support trickle ICE + if (!signaling.usesTrickleIce()) { + return; + } + + signaling.sendSignal( + remoteNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate) + ); + } + + private String extractCandidateType(String sdp) { + if (sdp.contains(" typ host")) return "host"; + if (sdp.contains(" typ srflx")) return "srflx"; + if (sdp.contains(" typ relay")) return "relay"; + return "unknown"; + } + + private void onConnectionChange(PeerState state) { + log.debug("Connection {} state changed: {}", Long.toUnsignedString(this.connectionId), state); + if (state == PeerState.RTC_CONNECTED) { + // Resolve the real client address from the selected ICE candidate pair and store it on the child channel. + InetSocketAddress raw = this.peerConnection.remoteAddress(); + this.child.setRemoteAddress(new InetSocketAddress(raw.getHostString(), raw.getPort())); + } + if (state == PeerState.RTC_FAILED || state == PeerState.RTC_CLOSED) { + if (child != null && child.isOpen()) { + log.debug("Closing connection {} due to state change: {}", Long.toUnsignedString(this.connectionId), state); + child.close(); + } + if (handshakeTimeout != null) { + handshakeTimeout.cancel(false); + } + } + } + + private void onDataChannel(DataChannel dataChannel) { + String label = dataChannel.label(); + log.debug("Received Data Channel: {}", label); + + if (NetherNetConstants.RELIABLE_CHANNEL_LABEL.equals(label)) { + this.reliable = dataChannel; + } else if (NetherNetConstants.UNRELIABLE_CHANNEL_LABEL.equals(label)) { + this.unreliable = dataChannel; + } + + checkDataChannels(); + } + + private void checkDataChannels() { + if (child != null && reliable != null && unreliable != null) { + if (handshakeTimeout != null) { + handshakeTimeout.cancel(false); + } + + log.debug("Data Channels established for {}", Long.toUnsignedString(this.connectionId)); + child.setDataChannels(reliable, unreliable); + + if (child.pipeline() != null) { + child.pipeline().fireChannelActive(); + } + } + } + + private void onGatheringStateChange(GatheringState state) { + if (state != GatheringState.RTC_GATHERING_COMPLETE || fullSdpSent || signaling.usesTrickleIce()) return; + + String local; + try { + local = peerConnection.localDescription(); + } catch (Exception e) { + log.warn("Gathering complete for {} but the local description is unavailable: {}", Long.toUnsignedString(connectionId), e.toString()); + return; + } + + fullSdpSent = true; + + log.trace("Sending full SDP (with gathered candidates) for {}", Long.toUnsignedString(connectionId)); + try { + signaling.sendFullSdp(remoteNetworkId, serverIdentity.augmentAnswer(local)); + } catch (Exception e) { + log.error("Failed to sign the full SDP for {}", Long.toUnsignedString(connectionId), e); + } + } + } + + @Override + protected void doClose() throws Exception { + this.open = false; + signaling.close(); + } + + @Override + protected void doBeginRead() throws Exception { + // Server channel doesn't read data directly + } + + @Override + protected SocketAddress localAddress0() { + return this.localAddress; + } + + @Override + protected boolean isCompatible(EventLoop loop) { + return true; + } + + @Override + public ChannelConfig config() { return config; } + + @Override + public boolean isOpen() { + return this.open; + } + + @Override + public boolean isActive() { + return isOpen() && localAddress0() != null; + } + + @Override + public ChannelMetadata metadata() { + return METADATA; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionGate.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionGate.java new file mode 100644 index 00000000..fbeffba9 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionGate.java @@ -0,0 +1,112 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import java.net.InetSocketAddress; +import java.util.*; + +/** Bounded admission reservations and records of used tokens. Packet processing stays native. */ +public final class AdmissionGate { + public record Limits(int sessions, int claims, int pending, long handshakeMillis) { + public Limits { + if (sessions < 1 || sessions > 65536 || claims < sessions || claims > 262144 || pending < 1 || pending > sessions || handshakeMillis < 100 || handshakeMillis > 120_000) + throw new IllegalArgumentException("Admission limits"); + } + public static Limits defaults() { return new Limits(1024, 8192, 1024, 15_000); } + } + public static final class Reservation { + private VerifiedAdmission admission; + private final String tokenId; + private final InetSocketAddress tuple; + private final long expiresAt, acceptedNanos; + private boolean ready, connected, closing, closed; + private Reservation(VerifiedAdmission admission, InetSocketAddress tuple, long nanos) { + this.admission = admission; this.tokenId = admission.tokenId(); this.tuple = tuple; + this.expiresAt = admission.expiresAt(); this.acceptedNanos = nanos; + } + public String tokenId() { return tokenId; } + public InetSocketAddress tuple() { return tuple; } + public long acceptedNanos() { return acceptedNanos; } + @Override public String toString() { return "Reservation[tokenId=" + tokenId + "]"; } + } + public record Stats(int sessions, int pending, int claims, long invalid, long replayRejected, long capacityRejected, long accepted) {} + public record PendingLimitWarning(int pending, int limit, long rejected) {} + private static final long WARNING_INTERVAL_NANOS = 5_000_000_000L; + private final Limits limits; + private final AdmissionValidator validator; + private final Map claims = new HashMap<>(); + private final Map tuples = new HashMap<>(); + private int pending; + private boolean draining, closed; + private long invalid, replayRejected, capacityRejected, accepted; + private long pendingLimitRejected, lastPendingWarningNanos; + private int pendingAtRejection; + private boolean pendingWarningEmitted; + + public AdmissionGate(Limits limits, AdmissionValidator validator) { + this.limits = Objects.requireNonNull(limits); this.validator = Objects.requireNonNull(validator); + } + + /** Reserve capacity after token validation. Native STUN verification must still succeed. */ + public synchronized Reservation reserve(AdmissionRequest request, long nowMillis, long nowNanos) { + if (closed) return null; + VerifiedAdmission a = validator.validate(request, nowMillis); + if (a == null) { invalid++; return null; } + if (claims.containsKey(a.tokenId()) || tuples.containsKey(request.address())) { replayRejected++; return null; } + if (draining) { capacityRejected++; return null; } + if (pending >= limits.pending()) { + capacityRejected++; pendingLimitRejected++; pendingAtRejection = pending; + return null; + } + if (tuples.size() >= limits.sessions() || claims.size() >= limits.claims()) { capacityRejected++; return null; } + Reservation r = new Reservation(a, request.address(), nowNanos); + claims.put(r.tokenId, r); tuples.put(r.tuple, r); pending++; + return r; + } + + public synchronized VerifiedAdmission admission(Reservation r) { return current(r) && !r.closing ? r.admission : null; } + /** Native STUN verification and peer creation succeeded. A used token cannot allocate another peer. */ + public synchronized boolean ready(Reservation r) { + if (!current(r) || r.closing || r.ready) return false; + r.ready = true; pending--; accepted++; + return true; + } + public synchronized void connected(Reservation r) { if (current(r) && !r.closing) r.connected = true; } + public synchronized void invalidNativeRequest() { invalid++; } + /** Call only once native teardown is complete, or when native creation never started. */ + public synchronized boolean finish(Reservation r) { + if (!current(r)) return false; + if (!r.ready) pending--; + tuples.remove(r.tuple); r.closed = true; r.admission = null; + // A copied token with forged STUN integrity must not consume the real client's token. + if (!r.ready || closed) claims.remove(r.tokenId); + return true; + } + private boolean current(Reservation r) { return !r.closed && claims.get(r.tokenId) == r; } + + /** Mark timed-out handshakes for closure. Capacity stays reserved until finish is called. */ + public synchronized List sweep(long nowMillis, long nowNanos) { + List timedOut = new ArrayList<>(); + for (Reservation r : claims.values()) { + if (!r.closed && !r.closing && !r.connected && nowNanos - r.acceptedNanos >= limits.handshakeMillis() * 1_000_000L) { + r.closing = true; timedOut.add(r); + } + } + claims.values().removeIf(r -> r.closed && r.expiresAt <= nowMillis); + return timedOut; + } + public synchronized void drain() { draining = true; } + public synchronized List close() { + closed = true; + List active = new ArrayList<>(tuples.values()); + for (Reservation r : active) r.closing = true; + claims.values().removeIf(r -> r.closed); + return active; + } + public synchronized Stats stats() { return new Stats(tuples.size(), pending, claims.size(), invalid, replayRejected, capacityRejected, accepted); } + /** Read one aggregate warning on the owner thread. */ + public synchronized PendingLimitWarning pollPendingLimitWarning(long nowNanos) { + if (pendingLimitRejected == 0 || (pendingWarningEmitted && nowNanos - lastPendingWarningNanos < WARNING_INTERVAL_NANOS)) return null; + var warning = new PendingLimitWarning(pendingAtRejection, limits.pending(), pendingLimitRejected); + pendingLimitRejected = 0; lastPendingWarningNanos = nowNanos; pendingWarningEmitted = true; + return warning; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionPrincipal.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionPrincipal.java new file mode 100644 index 00000000..3992a511 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionPrincipal.java @@ -0,0 +1,8 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import io.netty.util.AttributeKey; + +/** Token-authenticated context bound to the certificate checked by native DTLS. No credentials. */ +public record AdmissionPrincipal(String ticketId, String networkId, String callerContextHash, String keyId) { + public static final AttributeKey KEY = AttributeKey.valueOf(AdmissionPrincipal.class, "principal"); +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionRequest.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionRequest.java new file mode 100644 index 00000000..ff6714f2 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionRequest.java @@ -0,0 +1,23 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import java.net.InetSocketAddress; +import java.util.Objects; + +/** Untrusted fields from one incoming ICE attempt. Packet bytes remain native. */ +public record AdmissionRequest(String localUfrag, String remoteUfrag, InetSocketAddress address) { + public AdmissionRequest { + if (!iceString(localUfrag, 4, 256) || !iceString(remoteUfrag, 4, 256)) + throw new IllegalArgumentException("ICE username fragments"); + Objects.requireNonNull(address); + if (address.isUnresolved()) throw new IllegalArgumentException("Resolved source address required"); + } + static boolean iceString(String value, int min, int max) { + if (value == null || value.length() < min || value.length() > max) return false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '+' && c != '/') return false; + } + return true; + } + @Override public String toString() { return "AdmissionRequest[redacted]"; } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionValidator.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionValidator.java new file mode 100644 index 00000000..9169165e --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmissionValidator.java @@ -0,0 +1,8 @@ +package dev.kastle.netty.channel.nethernet.admission; + +/** Validates admission metadata using locally installed keys. No network calls. */ +@FunctionalInterface +public interface AdmissionValidator { + /** Return connection settings, or null to reject. Native code separately verifies STUN integrity. */ + VerifiedAdmission validate(AdmissionRequest request, long nowMillis); +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java new file mode 100644 index 00000000..45301a54 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/AdmittedNetherNetChildChannel.java @@ -0,0 +1,164 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import dev.kastle.netty.channel.nethernet.NetherNetChildChannel; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.*; +import io.netty.util.concurrent.ScheduledFuture; +import tel.schich.libdatachannel.*; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; + +/** Native admission child with bounded queues and both NetherNet channel semantics. */ +public final class AdmittedNetherNetChildChannel extends NetherNetChildChannel { + public static final int WRITE_LIMIT = 1 << 20, NATIVE_WRITE_LIMIT = 1 << 19, INBOUND_FRAMES = 128; + private record Incoming(byte[] bytes, boolean reliable) {} + private final ArrayBlockingQueue incoming = new ArrayBlockingQueue<>(INBOUND_FRAMES); + private final NetherNetFrameDecoder decoder = new NetherNetFrameDecoder(); + private final AtomicBoolean failed = new AtomicBoolean(); + private final CompletableFuture nativeTermination = new CompletableFuture<>(); + private final Consumer nativeCloser; + private ScheduledFuture tick; + private volatile boolean installed; + private boolean activated, readDemand; + + public AdmittedNetherNetChildChannel(Channel parent, PeerConnection peer, InetSocketAddress remote, InetSocketAddress local) { + this(parent, peer, remote, local, AdmittedNetherNetChildChannel::closeNativePeer); + } + AdmittedNetherNetChildChannel(Channel parent, PeerConnection peer, InetSocketAddress remote, InetSocketAddress local, Consumer nativeCloser) { + super(parent, peer, remote, local); + this.nativeCloser = nativeCloser; + config().setWriteBufferWaterMark(new WriteBufferWaterMark(WRITE_LIMIT / 4, WRITE_LIMIT / 2)); + } + @Override protected void doRegister() { + tick = eventLoop().scheduleWithFixedDelay(this::pump, 0, 5, TimeUnit.MILLISECONDS); + } + @Override public synchronized void setDataChannels(DataChannel reliable, DataChannel unreliable) { + acceptDataChannel(reliable); acceptDataChannel(unreliable); + } + /** Install immediately on the inline JNI callback; never retain an unobserved receive queue. */ + public synchronized void acceptDataChannel(DataChannel dc) { + if (!isOpen()) throw new IllegalStateException("Child closed"); + String label = dc.label(); + if (label.equals("ReliableDataChannel") && reliableChannel == null) { + checkSemantics(dc, true); listen(dc, true); reliableChannel = dc; + } else if (label.equals("UnreliableDataChannel") && unreliableChannel == null) { + checkSemantics(dc, false); listen(dc, false); unreliableChannel = dc; + } else throw new IllegalArgumentException("Unexpected or duplicate NetherNet channel"); + installed = reliableChannel != null && unreliableChannel != null; + } + private static void checkSemantics(DataChannel channel, boolean reliable) { + DataChannelReliability r = channel.reliability(); + if (r.isUnordered() == reliable || r.isUnreliable() == reliable || + (!reliable && (r.maxRetransmits() != 0 || !r.maxPacketLifeTime().isZero()))) + throw new IllegalArgumentException("Incorrect NetherNet channel reliability"); + } + private void listen(DataChannel dc, boolean reliable) { + // Peers use an INLINE JNI executor. Copy before native callback storage expires. + dc.onMessage.register(DataChannelCallback.Message.handleBinary((channel, bytes) -> { + if (!isOpen()) return; + if (bytes.remaining() < 2 || bytes.remaining() > NetherNetFrameDecoder.FRAME_LIMIT) { failed.set(true); return; } + byte[] copy = new byte[bytes.remaining()]; bytes.get(copy); + if (!incoming.offer(new Incoming(copy, reliable))) failed.set(true); + })); + dc.onClosed.register(channel -> failed.set(true)); + dc.onError.register((channel, message) -> failed.set(true)); + dc.bufferedAmountLowThreshold(NATIVE_WRITE_LIMIT / 2); + } + private void pump() { + if (!isOpen()) return; + if (failed.get()) { close(); return; } + try { + if (isActive() && !activated) { activated = true; pipeline().fireChannelActive(); } + if (config().isAutoRead() || readDemand) { + readDemand = false; + boolean read = false; + for (int count = 0; count < INBOUND_FRAMES; count++) { + Incoming frame = incoming.poll(); if (frame == null) break; + byte[] message = decoder.decode(frame.bytes(), frame.reliable()); + if (message != null) { + pipeline().fireUserEventTriggered(new NetherNetPacket.Delivery(frame.reliable())); + pipeline().fireChannelRead(Unpooled.wrappedBuffer(message)); read = true; + } + } + if (read) pipeline().fireChannelReadComplete(); + } + if (isActive()) { + ChannelOutboundBuffer out = unsafe().outboundBuffer(); + if (out != null) { out.setUserDefinedWritability(1, reliableChannel.bufferedAmount() < NATIVE_WRITE_LIMIT / 2 && unreliableChannel.bufferedAmount() < NATIVE_WRITE_LIMIT / 2); unsafe().flush(); } + } + } catch (Exception e) { pipeline().fireExceptionCaught(e); close(); } + } + @Override protected Object filterOutboundMessage(Object message) { + ByteBuf payload = payload(message); + boolean reliable = !(message instanceof NetherNetPacket p) || p.reliable(); + int size = payload.readableBytes(); + if (size < 1 || size > (reliable ? NetherNetFrameDecoder.MESSAGE_LIMIT : NetherNetFrameDecoder.FRAME_LIMIT - 1)) + throw new IllegalArgumentException("NetherNet message exceeds channel framing limit"); + ChannelOutboundBuffer out = unsafe().outboundBuffer(); + if (out == null || out.totalPendingWriteBytes() + size + 128 > WRITE_LIMIT) + throw new IllegalStateException("NetherNet outbound queue full"); + return message; + } + private static ByteBuf payload(Object message) { + if (message instanceof ByteBuf b) return b; + if (message instanceof NetherNetPacket p) return p.content(); + throw new IllegalArgumentException("Expected ByteBuf or NetherNetPacket"); + } + @Override protected void doWrite(ChannelOutboundBuffer out) { + if (!isActive()) return; // Netty retains ownership and promises; no private unbounded queue + while (out.current() != null) { + Object message = out.current(); ByteBuf payload = payload(message); + DataChannel dc = message instanceof NetherNetPacket p && !p.reliable() ? unreliableChannel : reliableChannel; + int length = payload.readableBytes(), chunks = (length + 9998) / 9999; + if (dc.bufferedAmount() + length + chunks > NATIVE_WRITE_LIMIT) { out.setUserDefinedWritability(1, false); return; } + try { + for (int i = 0, offset = payload.readerIndex(); i < chunks; i++) { + int count = Math.min(9999, length - i * 9999); + ByteBuffer frame = ByteBuffer.allocateDirect(count + 1); + frame.put((byte)(chunks - i - 1)); payload.getBytes(offset, frame); frame.flip(); + dc.sendMessage(frame); offset += count; + } + out.remove(); + } catch (Exception failure) { out.remove(failure); close(); return; } + } + } + @Override protected void doBeginRead() { readDemand = true; } + @Override public boolean isActive() { + DataChannel reliable = reliableChannel, unreliable = unreliableChannel; + return open && installed && reliable != null && unreliable != null && reliable.isOpen() && unreliable.isOpen(); + } + @Override protected void doClose() { + PeerConnection peer; + synchronized (this) { + open = false; installed = false; peer = peerConnection; peerConnection = null; + reliableChannel = null; unreliableChannel = null; + } + if (tick != null) { tick.cancel(false); tick = null; } + // Native close waits for callbacks. Never hold the monitor used by acceptDataChannel here. + try { + nativeCloser.accept(peer); + } catch (RuntimeException | Error failure) { + nativeTermination.completeExceptionally(failure); throw failure; + } finally { + incoming.clear(); decoder.clear(); + } + nativeTermination.complete(null); + } + private static void closeNativePeer(PeerConnection peer) { + if (peer != null && !peer.closeAndAwait(java.time.Duration.ofSeconds(5))) { + peer.close(); + throw new IllegalStateException("Native transport teardown did not complete within its deadline"); + } + } + public CompletionStage nativeTermination() { return nativeTermination; } + void closeUnregistered() { doClose(); } + public int queuedFrames() { return incoming.size(); } + public int retainedAssemblyBytes() { return decoder.retainedBytes(); } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java new file mode 100644 index 00000000..702266b7 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionServerChannel.java @@ -0,0 +1,198 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherServerChannelConfig; +import io.netty.channel.*; +import io.netty.util.NetUtil; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import tel.schich.libdatachannel.*; +import java.net.*; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** Fixed-UDP host. Java decides admission once; native code owns transport packets. */ +public final class NativeAdmissionServerChannel extends AbstractServerChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NativeAdmissionServerChannel.class); + public record Event(String ticketId, String stage, String reason, long occurredAt, long validationToCreationNanos) {} + private static final class Session { + final AdmissionGate.Reservation reservation; + final AdmittedNetherNetChildChannel child; + final long creationNanos = System.nanoTime(); + final CompletableFuture closed = new CompletableFuture<>(); + volatile boolean failed; + boolean reported, closing; + Session(AdmissionGate.Reservation reservation, AdmittedNetherNetChildChannel child) { this.reservation = reservation; this.child = child; } + } + private final DefaultNetherServerChannelConfig config = new DefaultNetherServerChannelConfig(this); + private final NativeHostIdentity identity; + private final boolean allowWildcardBind; + private final AdmissionGate gate; + private final AdmissionGate.Limits limits; + private final AtomicReference nativeCloseFailure = new AtomicReference<>(); + private final AtomicInteger liveNativePeers = new AtomicInteger(); + private final Set> nativeClosures = ConcurrentHashMap.newKeySet(); + private final Set> admissions = ConcurrentHashMap.newKeySet(); + private final Map sessions = new HashMap<>(); + private final ArrayBlockingQueue events = new ArrayBlockingQueue<>(256); + private final AtomicLong droppedEvents = new AtomicLong(), creations = new AtomicLong(); + private final CompletableFuture termination = new CompletableFuture<>(); + private volatile boolean open = true; + private volatile InetSocketAddress address; + private volatile IceUdpMuxListener mux; + private ScheduledFuture maintenance; + + public NativeAdmissionServerChannel(NativeHostIdentity identity, AdmissionValidator validator, AdmissionGate.Limits limits) { + this(identity, validator, limits, false); + } + /** Wildcard binding requires a separately validated concrete advertised candidate. */ + public NativeAdmissionServerChannel(NativeHostIdentity identity, AdmissionValidator validator, AdmissionGate.Limits limits, boolean allowWildcardBind) { + this.identity = Objects.requireNonNull(identity); this.limits = Objects.requireNonNull(limits); + gate = new AdmissionGate(limits, validator); this.allowWildcardBind = allowWildcardBind; + } + @Override protected void doBind(SocketAddress socketAddress) throws Exception { + if (!(socketAddress instanceof InetSocketAddress a) || a.isUnresolved() || a.getPort() == 0 || (!allowWildcardBind && a.getAddress().isAnyLocalAddress())) + throw new IllegalArgumentException("Resolved explicit interface address and fixed UDP port required"); + address = a; + mux = new IceUdpMuxListener(a.getAddress(), a.getPort(), Math.min(limits.pending(), 4096), + Duration.ofMillis(Math.min(limits.handshakeMillis(), 30_000)), eventLoop(), this::admit); + maintenance = eventLoop().scheduleWithFixedDelay(this::maintain, 100, 100, TimeUnit.MILLISECONDS); + } + private CompletionStage admit(IceUdpMuxListener.Request request) throws Exception { + if (!isOpen() || nativeCloseFailure.get() != null) return CompletableFuture.completedFuture(null); + byte[] ip = NetUtil.createByteArrayFromIpAddressString(request.remoteAddress()); + if (ip == null) return CompletableFuture.completedFuture(null); + AdmissionRequest metadata = new AdmissionRequest(request.localUfrag(), request.remoteUfrag(), + new InetSocketAddress(InetAddress.getByAddress(ip), request.remotePort())); + AdmissionGate.Reservation reservation = gate.reserve(metadata, System.currentTimeMillis(), System.nanoTime()); + if (reservation == null) return CompletableFuture.completedFuture(null); + VerifiedAdmission a = gate.admission(reservation); + CompletableFuture settled = new CompletableFuture<>(); admissions.add(settled); + request.completion().whenComplete((peer, failure) -> { + try { + eventLoop().execute(() -> { + if (failure != null) { + gate.invalidNativeRequest(); + Session session = sessions.get(reservation); + if (session == null) gate.finish(reservation); + else finish(reservation, "native_acceptance_failed"); + } + settled.complete(null); admissions.remove(settled); + }); + } catch (RejectedExecutionException stopped) { + nativeCloseFailure.compareAndSet(null, stopped); gate.drain(); settled.completeExceptionally(stopped); + } + }); + return CompletableFuture.completedFuture(new IceUdpMuxListener.Acceptance( + PeerConnectionConfiguration.DEFAULT.withDisableAutoNegotiation(true) + .withMaxMessageSize(NetherNetFrameDecoder.MESSAGE_LIMIT), + a.remoteDescription(), a.localPassword(), identity.certificate(), identity.privateKey(), null, + Runnable::run, peer -> initialize(reservation, a, peer), Instant.ofEpochMilli(a.expiresAt()))); + } + /** Called by the listener on this channel's event loop, before the first request resumes. */ + private void initialize(AdmissionGate.Reservation reservation, VerifiedAdmission a, PeerConnection peer) { + var child = new AdmittedNetherNetChildChannel(this, peer, reservation.tuple(), address); + var session = new Session(reservation, child); + creations.incrementAndGet(); liveNativePeers.incrementAndGet(); + sessions.put(reservation, session); nativeClosures.add(session.closed); + session.closed.whenComplete((ignored, failure) -> { + if (failure != null) { nativeCloseFailure.compareAndSet(null, failure); gate.drain(); } + else { liveNativePeers.decrementAndGet(); gate.finish(reservation); } + nativeClosures.remove(session.closed); + }); + child.nativeTermination().whenComplete((ignored, failure) -> { + if (failure == null) session.closed.complete(null); else session.closed.completeExceptionally(failure); + }); + // Keep ownership before checks that may fail, so partial setup is included in teardown. + if (!isOpen() || gate.admission(reservation) == null || a.expiresAt() <= System.currentTimeMillis()) + throw new IllegalStateException("Admission expired or cancelled"); + String local = peer.localDescription(); + if (!local.contains("a=fingerprint:" + identity.fingerprint() + "\r\n") || !local.contains("a=ice-ufrag:" + a.localUfrag() + "\r\n")) + throw new IllegalStateException("Native identity does not match published profile"); + child.attr(AdmissionPrincipal.KEY).set(new AdmissionPrincipal(a.tokenId(), a.networkId(), a.callerContextHash(), a.keyId())); + peer.onStateChange.register((p, state) -> { if (state == PeerState.RTC_FAILED || state == PeerState.RTC_CLOSED) session.failed = true; }); + peer.onDataChannel.register((p, dc) -> { + if (session.failed) return; + try { child.acceptDataChannel(dc); } + catch (Exception invalidChannel) { session.failed = true; } + }); + if (!gate.ready(reservation)) throw new IllegalStateException("Admission cancelled"); + pipeline().fireChannelRead(child); pipeline().fireChannelReadComplete(); + emit(reservation, "ticket.ice_seen", "token_and_stun_validated", session.creationNanos); + } + /** Periodic expiry and session reporting; connection creation is driven by admission completion. */ + private void maintain() { + if (!isOpen()) return; + try { + IceUdpMuxListener listener = mux; + if (listener != null && listener.failure() != null) nativeCloseFailure.compareAndSet(null, listener.failure()); + if (nativeCloseFailure.get() != null) { close(); return; } + var warning = gate.pollPendingLimitWarning(System.nanoTime()); + if (warning != null) log.warn("Pending admission limit reached: pending={}, limit={}, rejectedSinceLastWarning={}", + warning.pending(), warning.limit(), warning.rejected()); + for (AdmissionGate.Reservation r : gate.sweep(System.currentTimeMillis(), System.nanoTime())) finish(r, "timeout"); + for (Session session : new ArrayList<>(sessions.values())) { + if (session.failed || !session.child.isOpen()) { finish(session.reservation, "closed"); continue; } + if (!session.reported && session.child.isActive()) { + session.reported = true; gate.connected(session.reservation); + emit(session.reservation, "ticket.data_channels_open", "both_channels_open", session.creationNanos); + } + } + } catch (Exception failure) { pipeline().fireExceptionCaught(failure); close(); } + } + private void finish(AdmissionGate.Reservation r, String reason) { + Session session = sessions.remove(r); + // An outstanding native prepare owns its reservation until request.completion settles. + if (session != null) { + closeChild(session); + if (!session.reported) emit(r, "ticket.failed", reason, session.creationNanos); + } + } + private static void closeChild(Session session) { + if (session.closing) return; + session.closing = true; + try { session.child.close(); } + catch (IllegalStateException unregistered) { + try { session.child.closeUnregistered(); session.closed.complete(null); } + catch (Exception failedClose) { session.closed.completeExceptionally(failedClose); } + } + } + private void emit(AdmissionGate.Reservation r, String stage, String reason, long createdAt) { + if (!events.offer(new Event(r.tokenId(), stage, reason, System.currentTimeMillis(), Math.max(0, createdAt - r.acceptedNanos())))) droppedEvents.incrementAndGet(); + } + public List pollEvents() { List result = new ArrayList<>(256); events.drainTo(result); return result; } + public AdmissionGate.Stats admissionStats() { return gate.stats(); } + public int liveNativePeers() { return liveNativePeers.get(); } + public long creationAttempts() { return creations.get(); } + public long droppedEvents() { return droppedEvents.get(); } + public long[] nativeStats() { IceUdpMuxListener listener = mux; if (listener == null) throw new IllegalStateException("Endpoint not bound"); return listener.stats(); } + public NativeHostIdentity identity() { return identity; } + public CompletionStage termination() { return termination; } + public void drainAdmissions() { gate.drain(); } + @Override protected void doClose() { + open = false; gate.close(); + if (maintenance != null) maintenance.cancel(false); + IceUdpMuxListener listener = mux; mux = null; + if (listener != null) listener.close(); + for (Session session : sessions.values()) closeChild(session); + sessions.clear(); + List> outstanding = new ArrayList<>(nativeClosures); outstanding.addAll(admissions); + CompletableFuture.allOf(outstanding.toArray(CompletableFuture[]::new)).whenComplete((ignored, error) -> { + events.clear(); + Throwable failure = error == null ? nativeCloseFailure.get() : error; + if (failure == null) termination.complete(null); else termination.completeExceptionally(failure); + }); + } + @Override protected void doBeginRead() {} + @Override protected boolean isCompatible(EventLoop loop) { return true; } + @Override protected SocketAddress localAddress0() { return address; } + @Override public ChannelConfig config() { return config; } + @Override public boolean isOpen() { return open; } + @Override public boolean isActive() { return open && mux != null; } + @Override public ChannelMetadata metadata() { return new ChannelMetadata(false, 16); } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeHostIdentity.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeHostIdentity.java new file mode 100644 index 00000000..52d5a380 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NativeHostIdentity.java @@ -0,0 +1,31 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import java.nio.file.*; +import java.security.*; +import java.security.cert.CertificateFactory; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.*; + +/** Validated background PEM identity; never creates a peer to obtain its fingerprint. */ +public record NativeHostIdentity(Path certificate, Path privateKey, String fingerprint) { + public static NativeHostIdentity load(Path certificate, Path privateKey) throws Exception { + if (Files.size(certificate) > 65536 || Files.size(privateKey) > 65536) throw new IllegalArgumentException("Oversized PEM identity"); + java.security.cert.Certificate cert; + try (var in = Files.newInputStream(certificate)) { cert = CertificateFactory.getInstance("X.509").generateCertificate(in); } + String pem = Files.readString(privateKey); + if (!pem.startsWith("-----BEGIN PRIVATE KEY-----")) throw new IllegalArgumentException("PKCS8 PEM private key required"); + byte[] der = Base64.getMimeDecoder().decode(pem.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "")); + try { + String algorithm = cert.getPublicKey().getAlgorithm(); + String signature = switch (algorithm) { case "EC" -> "SHA256withECDSA"; case "RSA" -> "SHA256withRSA"; default -> throw new IllegalArgumentException("Unsupported DTLS certificate key type"); }; + PrivateKey key = KeyFactory.getInstance(algorithm).generatePrivate(new PKCS8EncodedKeySpec(der)); + byte[] challenge = new byte[32]; new SecureRandom().nextBytes(challenge); + Signature signer = Signature.getInstance(signature); signer.initSign(key); signer.update(challenge); byte[] signed = signer.sign(); + signer.initVerify(cert.getPublicKey()); signer.update(challenge); + if (!signer.verify(signed)) throw new IllegalArgumentException("Certificate/private key mismatch"); + } finally { Arrays.fill(der, (byte)0); } + String fp = HexFormat.ofDelimiter(":").withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(cert.getEncoded())); + return new NativeHostIdentity(certificate.toRealPath(), privateKey.toRealPath(), "sha-256 " + fp); + } + @Override public String toString() { return "NativeHostIdentity[fingerprint=" + fingerprint + "]"; } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoder.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoder.java new file mode 100644 index 00000000..6bc8f1b1 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoder.java @@ -0,0 +1,30 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import java.util.Arrays; + +/** Bounded countdown framing. Unordered traffic must fit one SCTP message. */ +public final class NetherNetFrameDecoder { + public static final int FRAME_LIMIT = 10000, MESSAGE_LIMIT = 262144; + private byte[] assembly; + private int size, expected = -1; + public byte[] decode(byte[] frame, boolean reliable) { + if (frame.length < 2 || frame.length > FRAME_LIMIT) throw new IllegalArgumentException("Invalid NetherNet frame length"); + int remaining = Byte.toUnsignedInt(frame[0]), payload = frame.length - 1; + // Countdown alone cannot disambiguate interleaved/reordered fragmented messages. + if (!reliable) { + if (remaining != 0) throw new IllegalArgumentException("Fragmented unordered NetherNet message is unsupported"); + return Arrays.copyOfRange(frame, 1, frame.length); + } + if (remaining >= (MESSAGE_LIMIT + FRAME_LIMIT - 2) / (FRAME_LIMIT - 1) || + (expected != -1 && expected != remaining) || size + payload > MESSAGE_LIMIT) { + clear(); throw new IllegalArgumentException("Invalid NetherNet fragment sequence"); + } + if (expected == -1 && remaining == 0) return Arrays.copyOfRange(frame, 1, frame.length); + if (assembly == null) assembly = new byte[MESSAGE_LIMIT]; + System.arraycopy(frame, 1, assembly, size, payload); size += payload; expected = remaining - 1; + if (remaining != 0) return null; + byte[] message = Arrays.copyOf(assembly, size); clear(); return message; + } + public void clear() { assembly = null; size = 0; expected = -1; } + public int retainedBytes() { return assembly == null ? 0 : assembly.length; } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetPacket.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetPacket.java new file mode 100644 index 00000000..478365e8 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/NetherNetPacket.java @@ -0,0 +1,14 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.DefaultByteBufHolder; + +/** Explicit outbound channel selection. Plain ByteBuf writes use the reliable channel. */ +public final class NetherNetPacket extends DefaultByteBufHolder { + private final boolean reliable; + public NetherNetPacket(ByteBuf content, boolean reliable) { super(content); this.reliable = reliable; } + public boolean reliable() { return reliable; } + @Override public NetherNetPacket replace(ByteBuf content) { return new NetherNetPacket(content, reliable); } + /** Fired immediately before the corresponding inbound ByteBuf, on the same event loop. */ + public record Delivery(boolean reliable) {} +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/VerifiedAdmission.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/VerifiedAdmission.java new file mode 100644 index 00000000..1311de04 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/admission/VerifiedAdmission.java @@ -0,0 +1,22 @@ +package dev.kastle.netty.channel.nethernet.admission; + +/** Trusted validator output. Never log credentials or reconstructed SDP. */ +public record VerifiedAdmission(String tokenId, String localUfrag, String localPassword, + String remoteUfrag, String remotePassword, String remoteFingerprint, + int remoteSctpPort, int remoteMaxMessageSize, long expiresAt, + String networkId, String callerContextHash, String keyId) { + public VerifiedAdmission { + if (tokenId == null || !tokenId.matches("[0-9a-f]{32}")) throw new IllegalArgumentException("tokenId"); + if (!AdmissionRequest.iceString(localUfrag, 4, 256) || !AdmissionRequest.iceString(remoteUfrag, 4, 256) || + !AdmissionRequest.iceString(localPassword, 22, 256) || !AdmissionRequest.iceString(remotePassword, 22, 256)) throw new IllegalArgumentException("ICE identity"); + if (remoteFingerprint == null || !remoteFingerprint.matches("sha-256 ([0-9A-F]{2}:){31}[0-9A-F]{2}")) throw new IllegalArgumentException("DTLS fingerprint"); + if (remoteSctpPort < 1 || remoteSctpPort > 65535 || remoteMaxMessageSize < 1 || remoteMaxMessageSize > 262144) throw new IllegalArgumentException("SCTP parameters"); + } + public String remoteDescription() { + return "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n" + + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=mid:0\r\na=setup:actpass\r\n" + + "a=ice-ufrag:" + remoteUfrag + "\r\na=ice-pwd:" + remotePassword + "\r\na=fingerprint:" + remoteFingerprint + + "\r\na=sctp-port:" + remoteSctpPort + "\r\na=max-message-size:" + remoteMaxMessageSize + "\r\n"; + } + @Override public String toString() { return "VerifiedAdmission[tokenId=" + tokenId + "]"; } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java new file mode 100644 index 00000000..1c931fd0 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java @@ -0,0 +1,59 @@ +package dev.kastle.netty.channel.nethernet.config; + +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; +import io.netty.channel.DefaultChannelConfig; +import tel.schich.libdatachannel.PeerConnectionConfiguration; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class DefaultNetherChannelConfig extends DefaultChannelConfig { + private final Map, Object> options = new ConcurrentHashMap<>(); + + private volatile PeerConnectionConfiguration peerConnectionConfig = PeerConnectionConfiguration.DEFAULT + .withMaxMessageSize(NetherNetConstants.MAX_ADVERTISED_MESSAGE_SIZE); + + public DefaultNetherChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), + NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG + ); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + + if (option == NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG) { + return (T) this.peerConnectionConfig; + } else if (options.containsKey(option)) { + return (T) options.get(option); + } + + return super.getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + if (option == NetherChannelOption.NETHER_PEER_CONNECTION_CONFIG) { + this.setPeerConnectionConfig((PeerConnectionConfiguration) value); + return true; + } else if (super.setOption(option, value)) { + return true; + } else { + options.put(option, value); + return true; + } + } + + void setPeerConnectionConfig(PeerConnectionConfiguration peerConnectionConfig) { + this.peerConnectionConfig = peerConnectionConfig; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java new file mode 100644 index 00000000..7c5456cb --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java @@ -0,0 +1,59 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; + +import java.util.Map; + +public class DefaultNetherClientChannelConfig extends DefaultNetherChannelConfig { + private volatile int clientHandshakeTimeoutMs = 3000; + private volatile int maxHandshakeAttempts = 3; + + public DefaultNetherClientChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), + NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS, + NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS + ); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + if (option == NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS) { + return (T) Integer.valueOf(this.clientHandshakeTimeoutMs); + } else if (option == NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS) { + return (T) Integer.valueOf(this.maxHandshakeAttempts); + } + + return super.getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + this.validate(option, value); + + if (option == NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS) { + this.setClientHandshakeTimeoutMs((Integer) value); + return true; + } else if (option == NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS) { + this.setMaxHandshakeAttempts((Integer) value); + return true; + } else { + return super.setOption(option, value); + } + } + + void setClientHandshakeTimeoutMs(int clientHandshakeTimeoutMs) { + this.clientHandshakeTimeoutMs = clientHandshakeTimeoutMs; + } + + void setMaxHandshakeAttempts(int maxHandshakeAttempts) { + this.maxHandshakeAttempts = maxHandshakeAttempts; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java new file mode 100644 index 00000000..f683068c --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java @@ -0,0 +1,47 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; + +import java.util.Map; + +public class DefaultNetherServerChannelConfig extends DefaultNetherChannelConfig { + private volatile int serverRtcHandshakeTimeoutSeconds = 30; + + public DefaultNetherServerChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS + ); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + if (option == NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS) { + return (T) Integer.valueOf(this.serverRtcHandshakeTimeoutSeconds); + } + + return super.getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + this.validate(option, value); + + if (option == NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS) { + this.setServerRtcHandshakeTimeoutSeconds((Integer) value); + return true; + } else { + return super.setOption(option, value); + } + } + + void setServerRtcHandshakeTimeoutSeconds(int serverRtcHandshakeTimeoutSeconds) { + this.serverRtcHandshakeTimeoutSeconds = serverRtcHandshakeTimeoutSeconds; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java new file mode 100644 index 00000000..1abcd2ee --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java @@ -0,0 +1,36 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.ChannelOption; +import tel.schich.libdatachannel.PeerConnectionConfiguration; + +public class NetherChannelOption extends ChannelOption { + + /** + * The {@link PeerConnectionConfiguration} used for the underlying peer connections. + */ + public static final ChannelOption NETHER_PEER_CONNECTION_CONFIG = + valueOf(NetherChannelOption.class, "NETHER_PEER_CONNECTION_CONFIG"); + + /** + * The timeout in seconds for completing the WebRTC handshake on the client before retrying. + */ + public static final ChannelOption NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS = + valueOf(NetherChannelOption.class, "NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS"); + + /** + * The maximum number of handshake attempts before giving up on connecting. + */ + public static final ChannelOption NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS = + valueOf(NetherChannelOption.class, "NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS"); + + /** + * The timeout in seconds for completing the WebRTC handshake on the server side before automatically closing the connection. + */ + public static final ChannelOption NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS = + valueOf(NetherChannelOption.class, "NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS"); + + @SuppressWarnings("deprecation") + protected NetherChannelOption(String name) { + super(name); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java new file mode 100644 index 00000000..a64fb9cc --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java @@ -0,0 +1,54 @@ +package dev.kastle.netty.channel.nethernet.config; + +import java.net.SocketAddress; + +public class NetherNetAddress extends SocketAddress { + private final String networkId; + + /** + * Creates a NetherNetAddress from a numeric Network ID. + * + * @param networkId The numeric Network ID. + */ + public NetherNetAddress(long networkId) { + this.networkId = Long.toUnsignedString(networkId); + } + + /** + * Creates a NetherNetAddress from a string Network ID. + * + * @param networkId The string Network ID. + */ + public NetherNetAddress(String networkId) { + this.networkId = networkId; + } + + /** + * Gets the Network ID as a String. + * + * @return the Network ID + */ + public String getNetworkId() { + return networkId; + } + + /** + * Tries to parse the Network ID as a long. + * + * @return the long value + * @throws NumberFormatException if the ID is not a valid unsigned long string (e.g. Realms ID). + */ + public long getNetworkIdAsLong() { + return Long.parseUnsignedLong(networkId); + } + + /** + * Returns the string representation of the Network ID. + * + * @return the Network ID as a string + */ + @Override + public String toString() { + return networkId; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java new file mode 100644 index 00000000..ae2ea099 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet.config; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java new file mode 100644 index 00000000..00aa908b --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java new file mode 100644 index 00000000..3a4c1800 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/AbstractNetherNetXboxSignaling.java @@ -0,0 +1,268 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoop; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; +import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler; +import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.ConnectException; +import java.net.SocketAddress; +import java.net.URI; +import java.nio.channels.ClosedChannelException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public abstract class AbstractNetherNetXboxSignaling extends SimpleChannelInboundHandler + implements NetherNetClientSignaling, NetherNetServerSignaling { + + protected final InternalLogger log = InternalLoggerFactory.getInstance(getClass()); + + protected final String xboxToken; + protected final String localNetworkId; + protected final URI uri; + protected final EventLoopGroup eventLoopGroup; + + protected Channel channel; + protected CompletableFuture> connectFuture; + protected volatile List iceServers = new ArrayList<>(); + + protected final Map handlers = new ConcurrentHashMap<>(); + protected NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; + protected volatile NetherNetClientSignaling.NotFoundHandler notFoundHandler; + + protected AbstractNetherNetXboxSignaling(String localNetworkId, String xboxToken, URI uri) { + this.localNetworkId = localNetworkId; + this.xboxToken = xboxToken; + this.uri = uri; + this.eventLoopGroup = new NioEventLoopGroup(1); + } + + @Override + public String getLocalNetworkId() { + return this.localNetworkId; + } + + @Override + public synchronized CompletableFuture> connect(SocketAddress remoteAddress) { + return connectInternal(); + } + + @Override + public void bind(SocketAddress localAddress, EventLoop eventLoop) throws ConnectException { + try { + connectInternal().join(); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + close(); + if (cause instanceof ConnectException) throw (ConnectException) cause; + ConnectException ce = new ConnectException("Failed to connect to Xbox Signaling: " + cause.getMessage()); + ce.initCause(cause); + throw ce; + } + } + + protected synchronized CompletableFuture> connectInternal() { + if (connectFuture != null) return connectFuture; + + connectFuture = new CompletableFuture<>(); + connectFuture.thenAccept(servers -> this.iceServers = servers); + + try { + SslContext sslCtx = SslContextBuilder.forClient().build(); + WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory.newHandshaker( + uri, WebSocketVersion.V13, null, false, + new DefaultHttpHeaders() + .add("Authorization", xboxToken) + .add("User-Agent", NetherNetConstants.SIGNALING_USER_AGENT) + .add("session-id", UUID.randomUUID().toString()) + .add("request-id", UUID.randomUUID().toString()) + ); + + Bootstrap b = new Bootstrap(); + b.group(eventLoopGroup) + .channel(NioSocketChannel.class) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ChannelPipeline p = ch.pipeline(); + p.addLast(sslCtx.newHandler(ch.alloc(), uri.getHost(), 443)); + p.addLast(new HttpClientCodec(), new HttpObjectAggregator(8192)); + p.addLast("ws-handshake", new WebSocketClientProtocolHandler(handshaker)); + p.addLast("ws-aggregator", new WebSocketFrameAggregator(16 * 1024)); // Allow 16KB aggregations + p.addLast("handler", AbstractNetherNetXboxSignaling.this); + } + }); + + this.channel = b.connect(uri.getHost(), 443).sync().channel(); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (connectFuture != null) connectFuture.completeExceptionally(cause); + } + return connectFuture; + } + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) { + log.debug("{} WebSocket Connected", getClass().getSimpleName()); + onConnected(ctx); + } else { + super.userEventTriggered(ctx, evt); + } + } + + /** + * Called when the WebSocket handshake is complete. + */ + protected abstract void onConnected(ChannelHandlerContext ctx); + + @Override + public List getIceServers() { + return this.iceServers; + } + + @Override + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.newConnectionHandler = handler; + } + + @Override + public void setNotFoundHandler(NotFoundHandler handler) { + this.notFoundHandler = handler; + } + + @Override + public void setSignalHandler(long connectionId, SignalHandler handler) { + this.handlers.put(connectionId, handler); + } + + @Override + public void removeSignalHandler(long connectionId) { + this.handlers.remove(connectionId); + } + + @Override + public void setAdvertisementData(PongData pongData) { + // No-op for Xbox Signaling. + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + if (connectFuture != null && !connectFuture.isDone()) { + connectFuture.completeExceptionally(cause); + } + log.error("Signaling Exception: {}", cause.getMessage(), cause); + ctx.close(); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + synchronized (this) { + if (connectFuture != null && !connectFuture.isDone()) { + connectFuture.completeExceptionally(new ClosedChannelException()); + } + connectFuture = null; + this.channel = null; + } + super.channelInactive(ctx); + } + + @Override + public void close() { + if (channel != null) channel.close(); + eventLoopGroup.shutdownGracefully(); + } + + protected void dispatchSignalToPipeline(String sender, String rawMsg) { + try { + // Signal Format: + String[] parts = rawMsg.split(" ", 3); + if (parts.length < 2) return; + + long connectionId = Long.parseUnsignedLong(parts[1]); + + SignalHandler handler = handlers.get(connectionId); + if (handler != null) { + handler.onSignal(rawMsg); + return; + } + + if (NetherNetConstants.RTC_NEGOTIATION_CONNECT_REQUEST.equals(parts[0]) && newConnectionHandler != null) { + String payload = parts.length > 2 ? parts[2] : ""; + newConnectionHandler.onConnect(connectionId, sender, payload); + } else { + log.debug("No handler found for connection ID: {} (Type: {})", connectionId, parts[0]); + } + } catch (Exception e) { + log.error("Failed to dispatch signal: {}", rawMsg, e); + } + } + + protected List parseTurnServers(JsonObject json) { + List result = new ArrayList<>(); + try { + JsonArray servers = null; + if (json.has("TurnAuthServers")) servers = json.getAsJsonArray("TurnAuthServers"); + else if (json.has("turnAuthServers")) servers = json.getAsJsonArray("turnAuthServers"); + + if (servers != null) { + for (JsonElement el : servers) { + JsonObject server = el.getAsJsonObject(); + List urls = new ArrayList<>(); + + JsonArray urlsArray = null; + if (server.has("Urls")) urlsArray = server.getAsJsonArray("Urls"); + else if (server.has("urls")) urlsArray = server.getAsJsonArray("urls"); + + if (urlsArray != null) { + urlsArray.forEach(u -> urls.add(u.getAsString())); + + IceServerInfo.Builder info = new IceServerInfo.Builder().setUrls(urls); + + if (server.has("Username")) info.setUsername(server.get("Username").getAsString()); + else if (server.has("username")) info.setUsername(server.get("username").getAsString()); + + if (server.has("Password")) info.setPassword(server.get("Password").getAsString()); + else if (server.has("password")) info.setPassword(server.get("password").getAsString()); + else if (server.has("Credential")) info.setPassword(server.get("Credential").getAsString()); + else if (server.has("credential")) info.setPassword(server.get("credential").getAsString()); + + result.add(info.build()); + } + } + } + } catch (Exception e) { + log.error("Failed to parse TURN servers", e); + } + log.debug("Successfully parsed {} ICE servers.", result.size()); + return result; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java new file mode 100644 index 00000000..6640e238 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java @@ -0,0 +1,34 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import java.net.SocketAddress; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface NetherNetClientSignaling extends NetherNetSignaling { + /** + * Connects to the signaling medium (Client mode). + * + * @param remoteAddress The address of the signaling server to connect to. + */ + CompletableFuture> connect(SocketAddress remoteAddress); + + /** + * Sets a handler to be called when a signaling message is received for an unknown connection ID. + * + * @param handler The handler to process incoming signaling messages for unknown connection IDs. + */ + void setNotFoundHandler(NotFoundHandler handler); + + /** + * Functional interface for handling "Not Found" signals. + */ + @FunctionalInterface + interface NotFoundHandler { + /** + * Called when the signaling service indicates the target peer was not found. + * + * @param reason The reason or raw message payload regarding the failure. + */ + void onNotFound(String reason); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java new file mode 100644 index 00000000..58ca7b2c --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java @@ -0,0 +1,322 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling.PongData; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.SignalHandler; +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.DatagramPacket; +import io.netty.channel.socket.nio.NioDatagramChannel; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; + +public class NetherNetDiscovery extends SimpleChannelInboundHandler { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscovery.class); + + private final long networkId; + private final Map signalHandlers = new ConcurrentHashMap<>(); + private final Map peerAddresses = new ConcurrentHashMap<>(); + private Channel channel; + private byte[] pongData; + private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; + private BiConsumer discoveryCallback; + + /** + * Creates a NetherNetDiscovery instance with the specified Network ID. + * + * @param networkId The Network ID to use for discovery. + */ + public NetherNetDiscovery(long networkId) { + this.networkId = networkId; + } + + public void bind() { + bind(NetherNetConstants.DISCOVERY_PORT); + } + + public void bind(int port) { + EventLoopGroup group = new NioEventLoopGroup(1); + try { + Bootstrap bootstrap = new Bootstrap(); + bootstrap.group(group) + .channel(NioDatagramChannel.class) + .option(ChannelOption.SO_BROADCAST, true) + .handler(this); + + this.channel = bootstrap.bind(port).sync().channel(); + log.info("NetherNet Discovery listening on port {}", port); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public void bind(InetSocketAddress address) { + EventLoopGroup group = new NioEventLoopGroup(1); + try { + Bootstrap bootstrap = new Bootstrap(); + bootstrap.group(group) + .channel(NioDatagramChannel.class) + .option(ChannelOption.SO_BROADCAST, true) + .handler(this); + + this.channel = bootstrap.bind(address).sync().channel(); + log.info("NetherNet Discovery listening on {}", address); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public void sendDiscoveryRequest(InetSocketAddress target, BiConsumer onServerFound) { + this.discoveryCallback = onServerFound; + + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_REQUEST); + buf.writeLongLE(this.networkId); + buf.writeZero(8); // Padding + + sendPacket(buf, target); + } + + public void setPongData(PongData data) { + ByteBuf buf = Unpooled.buffer(); + buf.writeByte(4); // Version + writeString(buf, data.serverName()); + writeString(buf, data.levelName()); + buf.writeByte(data.gameType() << 1); + buf.writeIntLE(data.playerCount()); + buf.writeIntLE(data.maxPlayerCount()); + buf.writeBoolean(data.isEditorWorld()); + buf.writeBoolean(data.isHardcore()); + buf.writeByte(data.transportLayer() << 1); + buf.writeByte(data.connectionType() << 1); + byte[] binaryData = new byte[buf.readableBytes()]; + buf.readBytes(binaryData); + buf.release(); + + String hex = HexFormat.of().formatHex(binaryData); + byte[] hexBytes = hex.getBytes(StandardCharsets.UTF_8); + + ByteBuf response = Unpooled.buffer(); + response.writeIntLE(hexBytes.length); + response.writeBytes(hexBytes); + + this.pongData = new byte[response.readableBytes()]; + response.readBytes(this.pongData); + response.release(); + } + + public void registerSignalHandler(long connectionId, SignalHandler handler) { + this.signalHandlers.put(connectionId, handler); + } + + public void unregisterSignalHandler(long connectionId) { + this.signalHandlers.remove(connectionId); + } + + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.newConnectionHandler = handler; + } + + /** + * Sends a signal immediately and schedules it to be resent periodically + * until the returned ScheduledFuture is cancelled. + */ + public ScheduledFuture sendSignalRetrying(InetSocketAddress recipient, long targetNetworkId, String data, long delayMs) { + return channel.eventLoop().scheduleAtFixedRate(() -> { + log.debug("Resending signal to {}: {}", recipient, data); + sendSignal(recipient, targetNetworkId, data); + }, 0, delayMs, TimeUnit.MILLISECONDS); + } + + public void sendSignal(InetSocketAddress recipient, long targetNetworkId, String data) { + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_MESSAGE); + buf.writeLongLE(this.networkId); // Sender ID + buf.writeZero(8); // Padding + + buf.writeLongLE(targetNetworkId); // Recipient ID + byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8); + buf.writeIntLE(dataBytes.length); + buf.writeBytes(dataBytes); + + sendPacket(buf, recipient); + } + + // New sendSignal looking up Address from ID + public void sendSignal(long targetNetworkId, String data) { + InetSocketAddress recipient = peerAddresses.get(targetNetworkId); + if (recipient != null) { + sendSignal(recipient, targetNetworkId, data); + } else { + throw new IllegalArgumentException("Attempted to send signal to unknown peer: " + targetNetworkId); + } + } + + private void sendPacket(ByteBuf packetData, InetSocketAddress target) { + try { + byte[] encrypted = NetherNetConstants.encryptDiscoveryPacket(packetData); + channel.writeAndFlush(new DatagramPacket(Unpooled.wrappedBuffer(encrypted), target)); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt discovery packet", e); + } finally { + packetData.release(); + } + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) throws Exception { + ByteBuf content = packet.content(); + ByteBuf decrypted = null; + try { + decrypted = NetherNetConstants.decryptDiscoveryPacket(content); + } catch (Exception e) { + log.debug("Failed to decrypt discovery packet from {}", packet.sender(), e); + return; + } + + if (decrypted == null) { + log.debug("Received invalid discovery packet from {}", packet.sender()); + return; + } + + try { + int packetId = decrypted.readUnsignedShortLE(); + long senderId = decrypted.readLongLE(); + + decrypted.skipBytes(8); // Padding + + if (senderId == this.networkId) { + log.debug("Ignoring own discovery packet"); + return; + } + + peerAddresses.put(senderId, packet.sender()); + + switch (packetId) { + case NetherNetConstants.ID_DISCOVERY_REQUEST -> { + log.trace("Handled discovery request from {}", packet.sender()); + handleRequest(senderId, packet.sender()); + } + case NetherNetConstants.ID_DISCOVERY_MESSAGE -> { + log.trace("Handled discovery message from {}", packet.sender()); + log.trace("Message Data: {}", decrypted.toString(StandardCharsets.UTF_8)); + handleMessage(decrypted, senderId); + } + case NetherNetConstants.ID_DISCOVERY_RESPONSE -> { + log.trace("Handled discovery response from {}", packet.sender()); + if (discoveryCallback != null) { + log.trace("Response Data: {}", decrypted.toString(StandardCharsets.UTF_8)); + // Pass the payload (decrypted buffer) to the callback + // We retain it because we are passing it out of the pipeline handler + discoveryCallback.accept(senderId, decrypted.retain()); + } + } + default -> { + log.debug("Received unknown discovery packet ID {} from {}", packetId, packet.sender()); + } + } + } catch (Exception e) { + log.debug("Error processing discovery packet from {}", packet.sender(), e); + } finally { + decrypted.release(); + } + } + + private void handleRequest(long senderId, InetSocketAddress sender) { + if (this.pongData == null) return; + + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_RESPONSE); + buf.writeLongLE(this.networkId); + buf.writeZero(8); + buf.writeBytes(this.pongData); + + sendPacket(buf, sender); + } + + private void handleMessage(ByteBuf data, long senderId) { + long recipientId = data.readLongLE(); + + if (recipientId != this.networkId && recipientId != 0) { + log.trace("Ignoring message intended for {}, but I am {}", recipientId, this.networkId); + return; + } + + int len = data.readIntLE(); + if (data.readableBytes() < len) { + log.trace("Malformed message: claimed length {} but only has {}", len, data.readableBytes()); + return; + } + + String messageData = data.readCharSequence(len, StandardCharsets.UTF_8).toString(); + if ("Ping".equals(messageData)) { + return; + } + + String[] parts = messageData.split(" ", 3); + if (parts.length < 2) return; + + try { + String type = parts[0]; + long connectionId = Long.parseUnsignedLong(parts[1]); + + SignalHandler handler = signalHandlers.get(connectionId); + + if (handler != null) { + handler.onSignal(messageData); + } else if (NetherNetConstants.RTC_NEGOTIATION_CONNECT_REQUEST.equals(type)) { + if (newConnectionHandler != null) { + String payload = parts.length > 2 ? parts[2] : ""; + log.trace("Dispatching New Connection: ID={} Sender={}", Long.toUnsignedString(connectionId), Long.toUnsignedString(senderId)); + newConnectionHandler.onConnect(connectionId, Long.toUnsignedString(senderId), payload); + } else { + log.debug("Received CONNECT_REQUEST but no NewConnectionHandler is set!"); + } + } else { + log.debug("Unhandled signal type: {}", type); + } + } catch (NumberFormatException e) { + log.debug("Invalid connection ID format in message: {}", messageData); + } + } + + public void close() { + if (channel != null) { + channel.close(); + } + } + + public boolean isActive() { + return channel != null && channel.isActive(); + } + + private void writeString(ByteBuf buf, String s) { + byte[] b = s.getBytes(StandardCharsets.UTF_8); + this.writeUnsignedVarInt(buf, b.length); + buf.writeBytes(b); + } + + private void writeUnsignedVarInt(ByteBuf buf, int value) { + while ((value & 0xFFFFFF80) != 0) { + buf.writeByte((byte) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + buf.writeByte((byte) value); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java new file mode 100644 index 00000000..c3404f83 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -0,0 +1,177 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import io.netty.channel.EventLoop; +import io.netty.util.ReferenceCountUtil; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicReference; + +public class NetherNetDiscoverySignaling implements NetherNetClientSignaling, NetherNetServerSignaling { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscoverySignaling.class); + + private final NetherNetDiscovery discovery; + private final InetSocketAddress bindAddress; + private final String localNetworkId; + + // State captured after connect + private volatile InetSocketAddress remoteAddress; + private final AtomicReference discoveredServerId = new AtomicReference<>(null); + + /** + * Creates a NetherNetDiscoverySignaling with a random local Network ID and binds to an ephemeral port. * + */ + public NetherNetDiscoverySignaling() { + this(ThreadLocalRandom.current().nextLong(), new InetSocketAddress(0)); + } + + /** + * Creates a NetherNetDiscoverySignaling with the specified local Network ID. + * + * @param localNetworkId The local Network ID to use. + */ + public NetherNetDiscoverySignaling(long localNetworkId) { + this(localNetworkId, new InetSocketAddress(0)); + } + + /** + * Creates a NetherNetDiscoverySignaling with the specified local Network ID and bind address. + * + * @param localNetworkId The local Network ID to use. + * @param bindAddress The address to bind the discovery socket to. + */ + public NetherNetDiscoverySignaling(long localNetworkId, InetSocketAddress bindAddress) { + this.localNetworkId = Long.toUnsignedString(localNetworkId); + this.discovery = new NetherNetDiscovery(localNetworkId); + this.bindAddress = bindAddress; + } + + @Override + public String getLocalNetworkId() { + return this.localNetworkId; + } + + @Override + public CompletableFuture> connect(SocketAddress remote) { + CompletableFuture> future = new CompletableFuture<>(); + + if (!(remote instanceof InetSocketAddress)) { + future.completeExceptionally(new IllegalArgumentException("Discovery requires InetSocketAddress")); + return future; + } + + this.remoteAddress = (InetSocketAddress) remote; + + try { + if (!this.discovery.isActive()) { + log.info("Binding NetherNet Discovery to {}", bindAddress); + this.discovery.bind(bindAddress); + } + + log.debug("Sending Discovery Request to {}", remote); + + // Send request and register the callback to capture the ID + this.discovery.sendDiscoveryRequest(this.remoteAddress, (serverNetworkId, payload) -> { + try { + log.info("Discovery Response Received! Server NetworkID: {}", serverNetworkId); + + // Capture the ID so we can use it for signaling later + discoveredServerId.set(Long.toUnsignedString(serverNetworkId)); + + future.complete(Collections.emptyList()); + } catch (Exception e) { + log.error("Error processing discovery response", e); + future.completeExceptionally(e); + } finally { + ReferenceCountUtil.release(payload); + } + }); + } catch (Exception e) { + log.error("Failed to send discovery request", e); + future.completeExceptionally(e); + } + + return future; + } + + @Override + public boolean allowsIceOnLocalPort() { + return false; + } + + @Override + public void bind(SocketAddress localAddress, EventLoop eventLoop) { + if (!this.discovery.isActive()) { + if (localAddress instanceof InetSocketAddress) { + this.discovery.bind((InetSocketAddress) localAddress); + } else { + this.discovery.bind(bindAddress); + } + } + } + + @Override + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.discovery.setNewConnectionHandler(handler); + } + + @Override + public void setAdvertisementData(PongData pongData) { + this.discovery.setPongData(pongData); + } + + @Override + public void sendSignal(String targetNetworkId, String data) { + String actualIdStr = targetNetworkId; + + // If '0' is passed, try to use the discovered ID (Client Mode) + if (actualIdStr == null || actualIdStr.equals("0")) { + actualIdStr = discoveredServerId.get(); + } + + if (actualIdStr == null) { + log.warn("Cannot send signal: Unknown Network ID."); + return; + } + + try { + long id = Long.parseUnsignedLong(actualIdStr); + + // If we have an explicit remote address (Client Mode), use it directly + if (remoteAddress != null) { + this.discovery.sendSignal(remoteAddress, id, data); + } else { + // Server Mode: Use the ID to find the address in the Discovery map + this.discovery.sendSignal(id, data); + } + } catch (NumberFormatException e) { + log.error("Cannot send LAN signal to non-numeric Network ID: {}", actualIdStr); + } + } + + @Override + public void setSignalHandler(long connectionId, SignalHandler handler) { + this.discovery.registerSignalHandler(connectionId, handler); + } + + @Override + public void removeSignalHandler(long connectionId) { + this.discovery.unregisterSignalHandler(connectionId); + } + + @Override + public void setNotFoundHandler(NetherNetClientSignaling.NotFoundHandler handler) { + // Not implemented for Discovery signaling + } + + @Override + public void close() { + this.discovery.close(); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetHTTPSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetHTTPSignaling.java new file mode 100644 index 00000000..0e2a4b44 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetHTTPSignaling.java @@ -0,0 +1,497 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.JsonObject; +import dev.kastle.netty.util.http.HttpLoggingHandler; +import dev.kastle.netty.util.http.TlsRejectingHandler; +import dev.kastle.netty.util.nethernet.IdentityUtils; +import dev.kastle.netty.util.nethernet.PlayerInfo; +import dev.kastle.netty.util.nethernet.ServerIdentity; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFactory; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoop; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.QueryStringDecoder; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.util.concurrent.FutureListener; +import io.netty.util.concurrent.Promise; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import org.jose4j.jwt.JwtClaims; + +import javax.net.ssl.KeyManagerFactory; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.channels.ServerSocketChannel; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * This class implements a signaling server using HTTP(S) for the NetherNet protocol. + *

+ * Follows ... + */ +public class NetherNetHTTPSignaling implements NetherNetServerSignaling { + private final InternalLogger log = InternalLoggerFactory.getInstance(getClass()); + + private final Random random = new Random(); + private final Map> pendingAnswers = new ConcurrentHashMap<>(); + + private final PlayerFilter playerFilter; + private final MotdProvider motdProvider; + + private SslContext sslContext; + private ServerIdentity serverIdentity; + private NewConnectionHandler newConnectionHandler; + + private Channel serverChannel; + + private NetherNetHTTPSignaling(Builder builder) { + this.playerFilter = builder.playerFilter; + this.motdProvider = builder.motdProvider; + + if (builder.httpsKeystore != null) { + try { + char[] passwordChars = builder.httpsPassword.toCharArray(); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (FileInputStream fis = new FileInputStream(builder.httpsKeystore)) { + ks.load(fis, passwordChars); + } + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, passwordChars); + + this.sslContext = SslContextBuilder.forServer(kmf).build(); + } catch (Exception ex) { + log.error("Error loading https keystore: " + ex.getMessage(), ex); + } + } + + try { + this.serverIdentity = ServerIdentity.fromKeystore(builder.identityKeystore, builder.identityPassword); + } catch (Exception ex) { + log.error("Error loading identity keystore: " + ex.getMessage(), ex); + } + } + + @Override + public void bind(SocketAddress localAddress, EventLoop eventLoop) throws ConnectException { + if (!(localAddress instanceof InetSocketAddress)) { + throw new IllegalArgumentException("Unsupported address type"); + } + + // Bind the listening socket ourselves so a failure throws correctly + ServerSocketChannel channel; + try { + channel = ServerSocketChannel.open(); + channel.configureBlocking(false); + channel.bind(localAddress, 128); + } catch (IOException e) { + throw new ConnectException("Failed to bind HTTP signaling to " + localAddress + ": " + e.getMessage()); + } + + // Setup a new server bootstrap for http using the existing event loop and channel + ServerBootstrap bootstrap = new ServerBootstrap(); + bootstrap.group(eventLoop) + .channelFactory((ChannelFactory) () -> new NioServerSocketChannel(channel)) + .childHandler(new ChannelInitializer<>() { + @Override + protected void initChannel(Channel ch) { + ChannelPipeline p = ch.pipeline(); + // Handle ssl or drop it + if (sslContext != null) { + p.addLast(sslContext.newHandler(ch.alloc())); + } else { + p.addLast(new TlsRejectingHandler()); + } + + p.addLast(new HttpServerCodec()); + p.addLast(new HttpObjectAggregator(8 * 1024)); + p.addLast(new HttpLoggingHandler(log)); + p.addLast(new SignalingHandler()); + } + }); + + ChannelFuture regFuture = bootstrap.register(); + serverChannel = regFuture.channel(); + regFuture.addListener((ChannelFutureListener) future -> { + if (!future.isSuccess()) { + log.error("Failed to register HTTP signaling channel", future.cause()); + future.channel().close(); + } + }); + } + + private class SignalingHandler extends SimpleChannelInboundHandler { + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) { + if (req.decoderResult().isFailure()) { + respondEmptyWithStatus(ctx, HttpResponseStatus.BAD_REQUEST); + return; + } + + String path = new QueryStringDecoder(req.uri()).path(); + HttpMethod method = req.method(); + String host = req.headers().get(HttpHeaderNames.HOST); + InetSocketAddress remoteAddress = (InetSocketAddress) ctx.channel().remoteAddress(); + + // Respond to the status check + if (path.equals("/v1/join")) { + if (!HttpMethod.GET.equals(method)) { + respondEmptyWithStatus(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED); + return; + } + + PongData motd; + try { + motd = motdProvider.getMotd(host, remoteAddress); + } catch (Exception e) { + log.error("MOTD provider failed", e); + respondEmptyWithStatus(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR); + return; + } + + respondWithString(ctx, motd.toJson(), "application/json"); + return; + } + + // Only continue if the path is /v1/join/ + if (!path.startsWith("/v1/join/")) { + respondEmptyWithStatus(ctx, HttpResponseStatus.NOT_FOUND); + return; + } + + // Only continue if this is a post request + if (!HttpMethod.POST.equals(method)) { + respondEmptyWithStatus(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED); + return; + } + + String networkId = path.substring("/v1/join/".length()); + + // Reject empty, or anything with a further path segment + if (networkId.isEmpty() || networkId.indexOf('/') >= 0) { + respondEmptyWithStatus(ctx, HttpResponseStatus.NOT_FOUND); + return; + } + + String sdpOffer = req.content().toString(StandardCharsets.UTF_8); + log.trace("Received sdp offer: " + sdpOffer); + + JwtClaims claims; + try { + claims = IdentityUtils.validateSdp(sdpOffer); + } catch (Exception e) { + log.error("Identity validation failed", e); + respondEmptyWithStatus(ctx, HttpResponseStatus.UNAUTHORIZED); + return; + } + + PlayerInfo player = new PlayerInfo(claims.getClaimValueAsString("xid"), claims.getClaimValueAsString("xname"), networkId, remoteAddress, claims); + log.debug("Identity is valid: " + player.displayName() + " (" + player.xuid() + ")"); + + // Let the user reject the player before we start a connection for them + boolean allowed; + try { + allowed = playerFilter.allow(host, player); + } catch (Exception e) { + log.error("Player filter failed for " + player.xuid(), e); + allowed = false; + } + + if (!allowed) { + log.debug("Rejected join from " + player.displayName() + " (" + player.xuid() + ")"); + respondEmptyWithStatus(ctx, HttpResponseStatus.FORBIDDEN); + return; + } + + // Register the pending answer before firing the callback so a fast answer isn't missed. + Promise answer = ctx.executor().newPromise(); + pendingAnswers.put(networkId, answer); + + // Cancel the answer promise if we have waited 30s + ScheduledFuture timeout = ctx.executor().schedule(() -> { + answer.tryFailure(new TimeoutException("Timed out waiting for SDP answer")); + }, 30, TimeUnit.SECONDS); + + answer.addListener((FutureListener) future -> { + pendingAnswers.remove(networkId, answer); + timeout.cancel(false); + + if (!future.isSuccess()) { + log.error("No SDP answer for " + networkId, future.cause()); + respondEmptyWithStatus(ctx, HttpResponseStatus.GATEWAY_TIMEOUT); + return; + } + + String sdpAnswer = future.getNow(); + log.trace("Signed SDP answer: " + sdpAnswer); + + log.debug("Sending SDP answer"); + + respondWithString(ctx, sdpAnswer, "application/sdp"); + }); + + // We cant use the network ID as the connection ID as they can be out of the bounds of a long + newConnectionHandler.onConnect(random.nextLong(), networkId, sdpOffer); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + log.error("Signaling handler error", cause); + ctx.close(); + } + } + + private void respondEmptyWithStatus(ChannelHandlerContext ctx, HttpResponseStatus status) { + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.EMPTY_BUFFER); + response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 0); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private void respondWithString(ChannelHandlerContext ctx, String body, String contentType) { + ByteBuf bodyBuf = Unpooled.wrappedBuffer(body.getBytes(StandardCharsets.UTF_8)); + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, bodyBuf); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType); + response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bodyBuf.readableBytes()); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + @Override + public void setNewConnectionHandler(NewConnectionHandler handler) { + this.newConnectionHandler = handler; + } + + @Override + public void setAdvertisementData(PongData pongData) { + // No-op for Web Signaling. + } + + @Override + public ServerIdentity serverIdentity() { + return this.serverIdentity; + } + + @Override + public boolean usesTrickleIce() { + return false; + } + + @Override + public void sendFullSdp(String targetNetworkId, String sdp) { + log.debug("Sending sdp to " + targetNetworkId); + + Promise answer = pendingAnswers.get(targetNetworkId); + if (answer != null) { + answer.trySuccess(sdp); + } else { + log.debug("No pending join waiting for " + targetNetworkId); + } + } + + @Override + public void setSignalHandler(long connectionId, SignalHandler handler) { + // No-op for Web Signaling. + } + + @Override + public void removeSignalHandler(long connectionId) { + // No-op for Web Signaling. + } + + @Override + public String getLocalNetworkId() { + return ""; + } + + @Override + public void close() { + if (serverChannel != null) serverChannel.close(); + } + + /** + * Functional interface for filtering players before a connection is created for them. + */ + @FunctionalInterface + public interface PlayerFilter { + /** + * Called once the identity attached to an SDP offer has been validated, before + * the connection is handed to the {@link NewConnectionHandler}. + *

+ * Called on the event loop, so don't block in here. A thrown exception is treated + * as a rejection. + * + * @param host The host header from the join request, which may be used to identify the server + * @param player The validated player attempting to join + * @return true to accept the player, false to reject them with a 403 + */ + boolean allow(String host, PlayerInfo player); + } + + /** + * Functional interface providing the MOTD returned to clients querying the server. + */ + @FunctionalInterface + public interface MotdProvider { + /** + * Called for every status request, so the returned data can change over time. + *

+ * Called on the event loop, so don't block in here. The discovery-only fields of + * {@link PongData} are ignored, as they have no place in the status response. + * + * @param host The host header from the join request, which may be used to identify the server + * @param remoteAddress The address the status request came from + * @return The MOTD to advertise + */ + PongData getMotd(String host, InetSocketAddress remoteAddress); + } + + /** + * Builder for {@link NetherNetHTTPSignaling}. + *

+ * The server is backed by one keystore for the TLS listener and another for the + * server identity used to sign SDP answers. Both must be PKCS12 files, and only + * the identity keystore is required. + */ + public static class Builder { + private File identityKeystore; + private String identityPassword = ""; + private File httpsKeystore; + private String httpsPassword = ""; + private PlayerFilter playerFilter = (host, player) -> true; + private MotdProvider motdProvider = (host, remoteAddress) -> PongData.DEFAULT; + + /** + * Sets the unprotected keystore holding the identity key. Required. + * + * @param identityKeystore PKCS12 keystore holding the EC P-384 identity key + * @return This builder + */ + public Builder setIdentityKeystore(File identityKeystore) { + return setIdentityKeystore(identityKeystore, ""); + } + + /** + * Sets the keystore holding the identity key used to sign SDP answers. Required. + *

+ * The key must be EC P-384, and its certificate CN is surfaced as the identity + * domain, so set it to something recognisable. + * Generate one with: + *

{@code
+         * keytool -genkeypair -alias identity -keyalg EC -groupname secp384r1 \
+         *         -storetype PKCS12 -keystore identity.p12 -storepass changeit \
+         *         -dname "CN=Your Server" -validity 3650
+         * }
+ * + * @param identityKeystore PKCS12 keystore holding the EC P-384 identity key + * @param identityPassword Password for {@code identityKeystore}, or "" if unprotected + * @return This builder + */ + public Builder setIdentityKeystore(File identityKeystore, String identityPassword) { + this.identityKeystore = identityKeystore; + this.identityPassword = identityPassword; + return this; + } + + /** + * Sets the unprotected keystore holding the TLS certificate and key. + * + * @param httpsKeystore PKCS12 keystore holding the TLS certificate and key + * @return This builder + */ + public Builder setHttpsKeystore(File httpsKeystore) { + return setHttpsKeystore(httpsKeystore, ""); + } + + /** + * Sets the keystore holding the TLS certificate and key. + * If unset the server listens in plaintext. + * + * @param httpsKeystore PKCS12 keystore holding the TLS certificate and key + * @param httpsPassword Password for {@code httpsKeystore}, or "" if unprotected + * @return This builder + */ + public Builder setHttpsKeystore(File httpsKeystore, String httpsPassword) { + this.httpsKeystore = httpsKeystore; + this.httpsPassword = httpsPassword; + return this; + } + + /** + * Sets the filter consulted for each join once its identity has been validated. + * Defaults to allowing everyone. + * + * @param playerFilter The filter to consult + * @return This builder + */ + public Builder setPlayerFilter(PlayerFilter playerFilter) { + this.playerFilter = playerFilter; + return this; + } + + /** + * Sets the provider called for each status request. + * Defaults to {@link PongData#DEFAULT}. + * + * @param motdProvider The provider to call + * @return This builder + */ + public Builder setMotdProvider(MotdProvider motdProvider) { + this.motdProvider = motdProvider; + return this; + } + + /** + * Sets a fixed MOTD to advertise for every status request. + * + * @param motd The MOTD to advertise + * @return This builder + */ + public Builder setMotd(PongData motd) { + return setMotdProvider((host, remoteAddress) -> motd); + } + + /** + * Builds the signalling instance. + * + * @return A new signalling instance + * @throws IllegalStateException If no identity keystore was set + */ + public NetherNetHTTPSignaling build() { + if (identityKeystore == null) { + throw new IllegalStateException("An identity keystore is required"); + } + + return new NetherNetHTTPSignaling(this); + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java new file mode 100644 index 00000000..8063b7c6 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -0,0 +1,192 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.JsonObject; +import dev.kastle.netty.util.nethernet.ServerIdentity; +import io.netty.channel.EventLoop; + +import java.net.ConnectException; +import java.net.SocketAddress; +import java.util.List; + +public interface NetherNetServerSignaling extends NetherNetSignaling { + /** + * Binds the signaling medium to listen for incoming connections (Server mode). + * + * @param localAddress The local address to bind to. + * @param eventLoop The owning channel's event loop. + * @throws ConnectException + */ + void bind(SocketAddress localAddress, EventLoop eventLoop) throws ConnectException; + + /** + * Handler for new connections. + * + * @param handler Functional interface receiving (ConnectionID, RemoteNetworkID, Payload) + */ + void setNewConnectionHandler(NewConnectionHandler handler); + + /** + * Sets the advertisement data for the discovery mechanism (e.g. LAN Pong). + * + * @param pongData The Pong advertisement data. + */ + void setAdvertisementData(PongData pongData); + + /** + * Functional interface for new connection handling. + */ + @FunctionalInterface + interface NewConnectionHandler { + /** + * Called when a new connection is initiated by a remote peer. + * + * @param connectionId The unique connection ID for this session. + * @param remoteNetworkId The Network ID of the remote peer. + * @param payload The initial signaling payload from the remote peer. + */ + void onConnect(long connectionId, String remoteNetworkId, String payload); + } + + /** + * Returns the ICE servers (STUN/TURN) obtained from the signaling handshake. + * Returns empty list if none available or not applicable. + */ + default List getIceServers() { + return java.util.Collections.emptyList(); + } + + /** + * Returns the identity used to sign SDP answers, or null to have the channel generate an ephemeral one. + * + * @return The server identity, or null if this signaling has none + */ + default ServerIdentity serverIdentity() { + return null; + } + + /** + * Whether ICE may bind to the address the channel was bound to, instead of an ephemeral port. + * + * @return true if ICE should be pinned to the bound address + */ + default boolean allowsIceOnLocalPort() { + return true; + } + + /** + * Whether this signaling can deliver ICE candidates incrementally after the answer has been sent. + * + * @return true if candidates are trickled as they are gathered + */ + default boolean usesTrickleIce() { + return true; + } + + /** + * Data structure for Pong advertisement data. + * + * @param serverName The name of the server. + * @param protocol The Bedrock protocol version the server speaks. + * @param version The Bedrock version string the server reports. + * @param levelName The name of the level/world. + * @param gameType The game type (e.g. Survival, Creative). + * @param playerCount The current number of players. + * @param maxPlayerCount The maximum number of players allowed. + * @param isEditorWorld Whether the world is an editor world. + * @param isHardcore Whether the world is in hardcore mode. + * @param transportLayer The transport layer identifier (e.g. NetherNet). + * @param connectionType The connection type identifier (e.g. LAN, Online). + */ + public record PongData(String serverName, int protocol, String version, String levelName, int gameType, + int playerCount, int maxPlayerCount, boolean isEditorWorld, boolean isHardcore, int transportLayer, + int connectionType) { + + public static final PongData DEFAULT = new Builder().build(); + + public String toJson() { + JsonObject info = new JsonObject(); + info.addProperty("name", serverName()); + info.addProperty("protocol", protocol()); + info.addProperty("version", version()); + info.addProperty("level", levelName()); + info.addProperty("players", playerCount()); + info.addProperty("maxPlayers", maxPlayerCount()); + info.addProperty("gameType", gameType()); + return info.toString(); + } + + public static class Builder { + private String serverName = "Server"; + private int protocol = 2187; + private String version = "1.26.50"; + private String levelName = "World"; + private int gameType = 0; // Default to Survival + private int playerCount = 0; + private int maxPlayerCount = 10; + private boolean isEditorWorld = false; + private boolean isHardcore = false; + private int transportLayer = 2; // Default to NetherNet + private int connectionType = 4; // Default to LAN + + public Builder setServerName(String serverName) { + this.serverName = serverName; + return this; + } + + public Builder setProtocol(int protocol) { + this.protocol = protocol; + return this; + } + + public Builder setVersion(String version) { + this.version = version; + return this; + } + + public Builder setLevelName(String levelName) { + this.levelName = levelName; + return this; + } + + public Builder setGameType(int gameType) { + this.gameType = gameType; + return this; + } + + public Builder setPlayerCount(int playerCount) { + this.playerCount = playerCount; + return this; + } + + public Builder setMaxPlayerCount(int maxPlayerCount) { + this.maxPlayerCount = maxPlayerCount; + return this; + } + + public Builder setIsEditorWorld(boolean isEditorWorld) { + this.isEditorWorld = isEditorWorld; + return this; + } + + public Builder setIsHardcore(boolean isHardcore) { + this.isHardcore = isHardcore; + return this; + } + + public Builder setTransportLayer(int transportLayer) { + this.transportLayer = transportLayer; + return this; + } + + public Builder setConnectionType(int connectionType) { + this.connectionType = connectionType; + return this; + } + + public PongData build() { + return new PongData(serverName, protocol, version, levelName, gameType, playerCount, + maxPlayerCount, isEditorWorld, isHardcore, transportLayer, connectionType); + } + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java new file mode 100644 index 00000000..8e66068f --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java @@ -0,0 +1,158 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public interface NetherNetSignaling extends AutoCloseable { + + /** + * Sends a signaling message to the remote peer. + * + * @param targetNetworkId The Network ID of the destination (String to support Realms). + * @param data The raw signaling payload. + */ + default void sendSignal(String targetNetworkId, String data) { + // Default implementation does nothing + } + + /** + * Sends a full SDP message with all candidates to the remote peer + * TODO Find a better name + * + * @param targetNetworkId The Network ID of the destination (String to support Realms). + * @param sdp The full SDP message with all candidates. + */ + default void sendFullSdp(String targetNetworkId, String sdp) { + // Default implementation does nothing + } + + /** + * Sets a handler to receive signaling messages for a specific connection ID. + * + * @param connectionId The connection ID to listen for. + * @param handler The handler to process incoming signaling messages. + */ + void setSignalHandler(long connectionId, SignalHandler handler); + + /** + * Removes the signaling handler for a specific connection ID. + * + * @param connectionId The connection ID whose handler should be removed. + */ + void removeSignalHandler(long connectionId); + + /** + * Returns the Local Network ID of this client as a String. + * This is required for formatting the 'candidate:' string in SDP. + */ + String getLocalNetworkId(); + + /** + * Closes the signaling channel and releases any associated resources. + */ + @Override + void close(); + + /** + * Functional interface for handling incoming signals. + */ + @FunctionalInterface + interface SignalHandler { + /** + * Called when a signal is received for the registered connection ID. + * + * @param signal The raw signal payload. + */ + void onSignal(String signal); + } + + /** + * Data structure for ICE server information. + * + * @param username The username for the ICE server (if applicable). + * @param password The password for the ICE server (if applicable). + * @param urls The list of URLs for the ICE server. + */ + public record IceServerInfo(String username, String password, List urls) { + private static final InternalLogger log = InternalLoggerFactory.getInstance(IceServerInfo.class); + + /** + * Converts this server to the URI form libdatachannel expects, which carries the credentials in + * the authority: {@code turn:user:pass@host:port?transport=udp}. Unparseable URLs are skipped. + * + * @return The URIs for this server. + */ + public List toUris() { + List uris = new ArrayList<>(); + if (urls == null) return uris; + + for (String url : urls) { + if (url == null || url.isBlank()) continue; + + try { + uris.add(new URI(withCredentials(url.trim()))); + } catch (URISyntaxException e) { + log.warn("Ignoring unparseable ICE server URL {}: {}", url, e.toString()); + } + } + + return uris; + } + + /** + * Inserts the credentials after the scheme, leaving STUN alone as it has no authentication. + */ + private String withCredentials(String url) { + int scheme = url.indexOf(':'); + if (scheme < 0 || username == null || username.isEmpty() || url.regionMatches(true, 0, "stun", 0, 4)) { + return url; + } + + int authority = url.startsWith("://", scheme) ? scheme + 3 : scheme + 1; + if (url.indexOf('@', authority) >= 0) return url; + + return url.substring(0, authority) + encode(username) + ":" + encode(password) + "@" + url.substring(authority); + } + + /** + * Encodes the input using %20 for spaces instead of +, which is what libdatachannel expects. + */ + private static String encode(String value) { + if (value == null) return ""; + + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + public static class Builder { + private String username = ""; + private String password = ""; + private List urls = List.of(); + + public Builder setUsername(String username) { + this.username = username; + return this; + } + + public Builder setPassword(String password) { + this.password = password; + return this; + } + + public Builder setUrls(List urls) { + this.urls = urls; + return this; + } + + public IceServerInfo build() { + return new IceServerInfo(username, password, urls); + } + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java new file mode 100644 index 00000000..b9cbd689 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxRpcSignaling.java @@ -0,0 +1,220 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; + +import java.net.URI; +import java.nio.channels.ClosedChannelException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +public class NetherNetXboxRpcSignaling extends AbstractNetherNetXboxSignaling { + private static final Gson gson = new GsonBuilder().serializeNulls().create(); + private final Map> pendingRequests = new ConcurrentHashMap<>(); + + /** + * Creates a NetherNetXboxRpcSignaling instance. + * + * @param networkId The Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxRpcSignaling(String networkId, String xboxToken) { + super(networkId, xboxToken, URI.create("wss://signal.franchise.minecraft-services.net/ws/v1.0/messaging/connect")); + } + + /** + * Creates a NetherNetXboxRpcSignaling instance. + * + * @param localNetworkId The local Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxRpcSignaling(long localNetworkId, String xboxToken) { + this(Long.toUnsignedString(localNetworkId), xboxToken); + } + + /** + * Creates a NetherNetXboxRpcSignaling instance with a random local Network ID. + * + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxRpcSignaling(String xboxToken) { + this(Long.toUnsignedString(ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE)), xboxToken); + } + + @Override + protected void onConnected(ChannelHandlerContext ctx) { + ctx.executor().scheduleAtFixedRate(() -> { + if (channel != null && channel.isActive()) { + sendJsonRpcRequest(NetherNetConstants.XBOX_RPC_METHOD_PING, new JsonObject()); + } + }, 30, 50, TimeUnit.SECONDS); + + sendJsonRpcRequest(NetherNetConstants.XBOX_RPC_METHOD_TURN_AUTH, new JsonObject()) + .thenAccept(response -> { + List servers = parseTurnServers(response); + if (connectFuture != null && !connectFuture.isDone()) connectFuture.complete(servers); + }) + .exceptionally(t -> { + log.error("Failed to fetch TURN credentials", t); + if (connectFuture != null && !connectFuture.isDone()) connectFuture.completeExceptionally(t); + return null; + }); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { + String text = frame.text(); + try { + JsonObject json = JsonParser.parseString(text).getAsJsonObject(); + + if (json.has("result") || (json.has("error") && json.has("id"))) { + handleResponse(json); + } else if (json.has("method")) { + handleRequest(json); + } + } catch (Exception e) { + log.error("Error processing signaling frame: " + text, e); + } + } + + private void handleResponse(JsonObject json) { + if (!json.has("id") || json.get("id").isJsonNull()) return; + String id = json.get("id").getAsString(); + CompletableFuture future = pendingRequests.remove(id); + + if (future != null) { + if (json.has("error") && !json.get("error").isJsonNull()) { + JsonObject error = json.getAsJsonObject("error"); + String msg = error.has("message") ? error.get("message").getAsString() : error.toString(); + + boolean isNotFound = msg.contains("Player not registered"); + if (!isNotFound && error.has("data") && error.get("data").isJsonObject()) { + JsonObject data = error.getAsJsonObject("data"); + if (data.has("Code") && "MissingOrExpiredIdentity".equals(data.get("Code").getAsString())) { + isNotFound = true; + } + } + + if (isNotFound && notFoundHandler != null) { + notFoundHandler.onNotFound(msg); + } + future.completeExceptionally(new RuntimeException(msg)); + } else { + future.complete(json.has("result") && !json.get("result").isJsonNull() ? json.getAsJsonObject("result") : new JsonObject()); + } + } + } + + private void handleRequest(JsonObject json) { + String method = json.get("method").getAsString(); + JsonElement id = json.get("id"); + + switch (method) { + case NetherNetConstants.XBOX_RPC_METHOD_RECEIVE_MESSAGE -> { + if (id != null) sendJsonRpcResult(id, null); + + if (json.isJsonArray()) { + JsonArray params = json.getAsJsonArray("params"); + if (params != null) { + for (JsonElement el : params) processIncomingMessage(el.getAsJsonObject()); + } + } else if (json.isJsonObject()) { + JsonObject params = json.getAsJsonObject("params"); + if (params != null) { + processIncomingMessage(params); + } + } + } + case NetherNetConstants.XBOX_RPC_METHOD_PONG, NetherNetConstants.XBOX_RPC_METHOD_PING -> { + if (id != null) sendJsonRpcResult(id, null); + } + } + } + + private void processIncomingMessage(JsonObject msgObj) { + String from = msgObj.get("From").getAsString(); + String rawInner = msgObj.get("Message").getAsString(); + String msgId = msgObj.has("Id") ? msgObj.get("Id").getAsString() : UUID.randomUUID().toString(); + + JsonObject innerParams = new JsonObject(); + innerParams.addProperty("messageId", msgId); + JsonObject innerMsg = new JsonObject(); + innerMsg.add("params", innerParams); + innerMsg.addProperty("jsonrpc", "2.0"); + innerMsg.addProperty("method", NetherNetConstants.XBOX_RPC_INNER_METHOD_DELIVERY); + sendJsonRpcRequest(NetherNetConstants.XBOX_RPC_METHOD_SEND_MESSAGE, createSendParams(from, innerMsg.toString())); + + try { + JsonObject innerJson = JsonParser.parseString(rawInner).getAsJsonObject(); + if (innerJson.has("method") && NetherNetConstants.XBOX_RPC_INNER_METHOD_WEBRTC.equals(innerJson.get("method").getAsString())) { + String payload = innerJson.getAsJsonObject("params").get("message").getAsString(); + dispatchSignalToPipeline(from, payload); + } + } catch (Exception e) { + log.error("Failed to parse inner signaling message from " + from, e); + } + } + + @Override + public void sendSignal(String targetNetworkId, String data) { + if (channel == null || !channel.isActive()) throw new IllegalStateException("Signaling channel is not active"); + + JsonObject innerParams = new JsonObject(); + innerParams.addProperty("netherNetId", localNetworkId); + innerParams.addProperty("message", data); + + JsonObject innerMsg = new JsonObject(); + innerMsg.add("params", innerParams); + innerMsg.addProperty("jsonrpc", "2.0"); + innerMsg.addProperty("method", NetherNetConstants.XBOX_RPC_INNER_METHOD_WEBRTC); + + sendJsonRpcRequest(NetherNetConstants.XBOX_RPC_METHOD_SEND_MESSAGE, createSendParams(targetNetworkId, innerMsg.toString())); + } + + private JsonObject createSendParams(String toPlayerId, String message) { + JsonObject params = new JsonObject(); + params.addProperty("toPlayerId", toPlayerId); + params.addProperty("messageId", UUID.randomUUID().toString()); + params.addProperty("message", message); + return params; + } + + private CompletableFuture sendJsonRpcRequest(String method, JsonObject params) { + String id = UUID.randomUUID().toString(); + JsonObject rpc = new JsonObject(); + rpc.add("params", params); + rpc.addProperty("jsonrpc", "2.0"); + rpc.addProperty("method", method); + rpc.addProperty("id", id); + + CompletableFuture future = new CompletableFuture<>(); + pendingRequests.put(id, future); + + if (channel != null && channel.isActive()) { + channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(rpc))); + } else { + future.completeExceptionally(new ClosedChannelException()); + } + return future; + } + + private void sendJsonRpcResult(JsonElement id, JsonElement result) { + JsonObject response = new JsonObject(); + response.add("id", id); + response.add("result", result); + response.addProperty("jsonrpc", "2.0"); + if (channel != null && channel.isActive()) channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(response))); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java new file mode 100644 index 00000000..8e00d65a --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -0,0 +1,108 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; + +import java.net.URI; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +@Sharable +public class NetherNetXboxSignaling extends AbstractNetherNetXboxSignaling { + private static final Gson gson = new Gson(); + + /** + * Creates a NetherNetXboxSignaling instance. + * + * @param networkId The Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxSignaling(String networkId, String xboxToken) { + super(networkId, xboxToken, URI.create("wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling/" + networkId)); + } + + /** + * Creates a NetherNetXboxSignaling instance. + * + * @param localNetworkId The local Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxSignaling(long localNetworkId, String xboxToken) { + this(Long.toUnsignedString(localNetworkId), xboxToken); + } + + /** + * Creates a NetherNetXboxSignaling instance with a random local Network ID. + * + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxSignaling(String xboxToken) { + this(Long.toUnsignedString(ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE)), xboxToken); + } + + @Override + protected void onConnected(ChannelHandlerContext ctx) { + ctx.executor().scheduleAtFixedRate(() -> { + JsonObject ping = new JsonObject(); + ping.addProperty("Type", 0); + ctx.writeAndFlush(new TextWebSocketFrame(gson.toJson(ping))); + }, 5, 5, TimeUnit.SECONDS); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { + String text = frame.text(); + try { + JsonObject json = gson.fromJson(text, JsonObject.class); + if (!json.has("Type")) return; + + int type = json.get("Type").getAsInt(); + switch (type) { + case NetherNetConstants.XBOX_SIGNAL_NOT_FOUND -> { + log.debug("Peer Not Found: {}", text); + if (notFoundHandler != null) { + String reason = json.has("Message") ? json.get("Message").getAsString() : text; + notFoundHandler.onNotFound(reason); + } + } + case NetherNetConstants.XBOX_SIGNAL_SIGNAL -> { + String sender = json.has("From") ? json.get("From").getAsString() : "0"; + if (json.has("Message")) { + dispatchSignalToPipeline(sender, json.get("Message").getAsString()); + } + } + case NetherNetConstants.XBOX_SIGNAL_CREDENTIALS -> { + log.trace("Received Credentials"); + if (json.has("Message") && connectFuture != null && !connectFuture.isDone()) { + String rawMsg = json.get("Message").getAsString(); + JsonObject credentials = JsonParser.parseString(rawMsg).getAsJsonObject(); + + connectFuture.complete(parseTurnServers(credentials)); + } + } + case NetherNetConstants.XBOX_SIGNAL_ACCEPTED, NetherNetConstants.XBOX_SIGNAL_ACK -> log.trace("Signal Ack: {}", text); + default -> log.debug("Unknown message type {}: {}", type, text); + } + } catch (Exception e) { + log.error("Error processing signaling frame: " + text, e); + } + } + + @Override + public void sendSignal(String targetNetworkId, String data) { + if (channel != null && channel.isActive()) { + JsonObject msg = new JsonObject(); + msg.addProperty("Type", 1); + msg.addProperty("To", targetNetworkId); + msg.addProperty("Message", data); + channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(msg))); + } else { + throw new IllegalStateException("Attempted to send signal to " + targetNetworkId + " but WebSocket is closed!"); + } + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java new file mode 100644 index 00000000..6bc5ae85 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet.signaling; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/http/HttpLoggingHandler.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/http/HttpLoggingHandler.java new file mode 100644 index 00000000..68d383b7 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/http/HttpLoggingHandler.java @@ -0,0 +1,41 @@ +package dev.kastle.netty.util.http; + +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.util.internal.logging.InternalLogger; + +/** + * Log any web requests and their response codes and times to a given Netty log + */ +public class HttpLoggingHandler extends ChannelDuplexHandler { + private final InternalLogger log; + + private long startNanos; + private String request; + + public HttpLoggingHandler(InternalLogger log) { + this.log = log; + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof HttpRequest req) { + startNanos = System.nanoTime(); + request = ctx.channel().remoteAddress() + " " + req.method() + " " + req.uri(); + } + ctx.fireChannelRead(msg); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + if (msg instanceof HttpResponse res) { + long ms = (System.nanoTime() - startNanos) / 1_000_000; + log.debug("{} -> {} ({} ms)", request, res.status().code(), ms); + } + ctx.write(msg, promise); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/http/TlsRejectingHandler.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/http/TlsRejectingHandler.java new file mode 100644 index 00000000..88aea911 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/http/TlsRejectingHandler.java @@ -0,0 +1,28 @@ +package dev.kastle.netty.util.http; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.handler.ssl.SslHandler; + +import java.util.List; + +/** + * Rejects TLS clients on a plaintext port + */ +public class TlsRejectingHandler extends ByteToMessageDecoder { + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + // Make sure we have enough bytes to check if this is TLS + if (in.readableBytes() < 5) return; + + if (SslHandler.isEncrypted(in)) { + ctx.close(); + return; + } + + // Allow the pipeline to continue + ctx.pipeline().remove(this); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/Identity.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/Identity.java new file mode 100644 index 00000000..065b0398 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/Identity.java @@ -0,0 +1,47 @@ +package dev.kastle.netty.util.nethernet; + +import com.google.gson.Gson; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +public record Identity(Idp idp, Assertion assertion) { + private static Gson gson = new Gson(); + + public static Identity fromJson(String identityString) { + return new Identity(gson.fromJson(identityString, Raw.class)); + } + + public static Identity fromBase64(String identityString) { + return Identity.fromJson(new String(Base64.getDecoder().decode(identityString.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)); + } + + public static Identity fromSdpOffer(String sdpOffer) { + String prefix = "a=identity:"; + String identity = Arrays.stream(sdpOffer.split("\n")).filter(line -> line.startsWith(prefix)).findFirst().orElse(null); + if (identity == null) { + return null; + } + identity = identity.substring(prefix.length()).trim(); + return Identity.fromBase64(identity); + } + + private Identity(Raw raw) { + this(raw.idp(), gson.fromJson(raw.assertion(), Assertion.class)); + } + + private record Raw(Idp idp, String assertion) {} + + public record Idp(String domain, String protocol) {} + + public record Assertion(String token, String fingerprints) {} + + public String toJson() { + return gson.toJson(new Raw(idp, gson.toJson(assertion))); + } + + public String toBase64() { + return Base64.getEncoder().encodeToString(toJson().getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/IdentityUtils.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/IdentityUtils.java new file mode 100644 index 00000000..13791819 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/IdentityUtils.java @@ -0,0 +1,119 @@ +package dev.kastle.netty.util.nethernet; + +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import org.jose4j.jwk.HttpsJwks; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwa.AlgorithmConstraints; +import org.jose4j.jwa.AlgorithmConstraints.ConstraintType; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumer; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.jwt.consumer.JwtContext; +import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; +import org.jose4j.lang.JoseException; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; +import java.util.Base64; +import java.util.stream.Collectors; + +public class IdentityUtils { + private static final InternalLogger log = InternalLoggerFactory.getInstance(IdentityUtils.class); + + private static final JwtConsumer JWT_CONSUMER = new JwtConsumerBuilder() + .setVerificationKeyResolver(new HttpsJwksVerificationKeyResolver(new HttpsJwks("https://authorization.franchise.minecraft-services.net/.well-known/keys"))) + .setRequireExpirationTime() + .setRequireSubject() + .setExpectedAudience(true, "api://auth-minecraft-services/multiplayer") + .setExpectedIssuer("https://authorization.franchise.minecraft-services.net/") + .build(); + + /** + * Validate the given identity against the known jwt signer + * + * @param identity The identity to validate + * @return The JWT context if the identity is valid + * @throws InvalidJwtException If the identity is invalid + */ + public static JwtContext validateIdentity(Identity identity) throws InvalidJwtException { + return JWT_CONSUMER.process(identity.assertion().token()); // TODO Take into account the idp in the identity + } + + /** + * Validate the SDP offer against the embedded identity and known jwt signer + * This is designed into the spec to prevent MITM + * + * @param sdpOffer The SDP offer to validate + * @return The JWT claims if the SDP offer is valid + * @throws JoseException If there is an error processing the SDP offer + * @throws InvalidJwtException If the SDP offer contains an invalid JWT + */ + public static JwtClaims validateSdp(String sdpOffer) throws JoseException, InvalidJwtException { + // Extract the identity + Identity identity = Identity.fromSdpOffer(sdpOffer); + if (identity == null) { + throw new JoseException("Invalid SDP offer: missing identity"); + } + log.debug("Received identity: " + identity); + + JwtContext jwtContext = IdentityUtils.validateIdentity(identity); + JwtClaims claims = jwtContext.getJwtClaims(); + + // Reconstruct the detached payload from the SDP fingerprint lines + String fingerprints = getCanonicalFingerprintJson(sdpOffer); + if (fingerprints.length() == 18) { // Check if it is just the empty array {"fingerprint":[]} + throw new JoseException("Invalid SDP offer: no fingerprints"); + } + + // Validate the detached fingerprints JWS against the cpk from the token + String detachedJws = identity.assertion().fingerprints(); + try { + JsonWebSignature jws = new JsonWebSignature(); + jws.setCompactSerialization(detachedJws); + + // cpk is base64, decode it and parse as a public key + byte[] der = Base64.getDecoder().decode(claims.getClaimValueAsString("cpk")); + PublicKey cpkKey = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(der)); + + // Set the JWS properties so we can verify + jws.setKey(cpkKey); + jws.setPayload(fingerprints); + jws.setAlgorithmConstraints(new AlgorithmConstraints(ConstraintType.PERMIT, AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384)); + + if (!jws.verifySignature()) { + throw new JoseException("Fingerprint signature mismatch"); + } + } catch (GeneralSecurityException e) { + throw new JoseException("Fingerprint JWS validation failed", e); + } + + return claims; + } + + /** + * Get the canonical fingerprint JSON from the SDP offer + * + * @param sdpOffer The SDP offer to extract fingerprints from + * @return The canonical fingerprint JSON + */ + public static String getCanonicalFingerprintJson(String sdpOffer) { + String prefix = "a=fingerprint:"; + return Arrays.stream(sdpOffer.split("\n")) + .filter(line -> line.startsWith(prefix)) + .map(line -> line.substring(prefix.length()).trim()) + .map(line -> { + String[] parts = line.split(" "); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid fingerprint line: " + line); + } + return "{\"algorithm\":\"" + parts[0] + "\",\"digest\":\"" + parts[1] + "\"}"; + }) + .collect(Collectors.joining(",", "{\"fingerprint\":[", "]}")); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java new file mode 100644 index 00000000..d398c277 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetLogging.java @@ -0,0 +1,84 @@ +package dev.kastle.netty.util.nethernet; + +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import tel.schich.libdatachannel.LibDataChannel; +import java.util.Locale; + +/** Controls native logging before messages cross into Java, and configures supported Java backends. */ +public final class NetherNetLogging { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetLogging.class); + + /** The SLF4J logger libdatachannel routes its native output through. */ + public static final String NATIVE_LOGGER = "tel.schich.libdatachannel.LibDataChannel"; + + private NetherNetLogging() { + } + + /** + * Sets the native threshold and, when available, the Log4j2 or Logback logger level. + * + * @param level One of OFF, ERROR, WARN, INFO, DEBUG, TRACE or ALL. WARN is a good default. + * @return true if the native threshold was set, false if the level was invalid. + */ + public static boolean setNativeLogLevel(String level) { + if (level == null || level.isBlank()) { + return false; + } + + String normalised = level.trim().toUpperCase(Locale.ROOT); + LibDataChannel.LogLevel nativeLevel = switch (normalised) { + case "OFF" -> LibDataChannel.LogLevel.NONE; + case "ERROR" -> LibDataChannel.LogLevel.ERROR; + case "WARN" -> LibDataChannel.LogLevel.WARNING; + case "INFO" -> LibDataChannel.LogLevel.INFO; + case "DEBUG" -> LibDataChannel.LogLevel.DEBUG; + case "TRACE", "ALL" -> LibDataChannel.LogLevel.VERBOSE; + default -> null; + }; + if (nativeLevel == null) return false; + LibDataChannel.setLogLevel(nativeLevel); + if (!applyLog4j2(normalised)) applyLogback(normalised); + log.debug("Set native transport log level to {}", normalised); + return true; + } + + private static boolean applyLog4j2(String level) { + try { + Class levelClass = Class.forName("org.apache.logging.log4j.Level"); + Class configurator = Class.forName("org.apache.logging.log4j.core.config.Configurator"); + + Object parsed = levelClass.getMethod("toLevel", String.class, levelClass) + .invoke(null, level, levelClass.getField("WARN").get(null)); + + configurator.getMethod("setLevel", String.class, levelClass) + .invoke(null, NATIVE_LOGGER, parsed); + return true; + } catch (Throwable t) { + // Not on Log4j2, or it resolved a different logger context than ours + return false; + } + } + + private static boolean applyLogback(String level) { + try { + Object logger = Class.forName("org.slf4j.LoggerFactory") + .getMethod("getLogger", String.class) + .invoke(null, NATIVE_LOGGER); + + Class logbackLogger = Class.forName("ch.qos.logback.classic.Logger"); + if (!logbackLogger.isInstance(logger)) { + return false; + } + + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Object parsed = levelClass.getMethod("toLevel", String.class).invoke(null, level); + + logbackLogger.getMethod("setLevel", levelClass).invoke(logger, parsed); + return true; + } catch (Throwable t) { + // Not on Logback + return false; + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java new file mode 100644 index 00000000..b3349334 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java @@ -0,0 +1,80 @@ +package dev.kastle.netty.util.nethernet; + +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetDiscovery; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ThreadLocalRandom; + +/** + * A simple scanner example for discovering NetherNet servers on the local network. + */ +public class NetherNetScanner { + public static void main(String[] args) throws Exception { + long myNetworkId = ThreadLocalRandom.current().nextLong(); + NetherNetDiscovery discovery = new NetherNetDiscovery(myNetworkId); + + discovery.bind(new InetSocketAddress("::", 0)); + + System.out.println("Scanning for NetherNet servers on port 7551..."); + + InetSocketAddress broadcastTarget = new InetSocketAddress("255.255.255.255", NetherNetConstants.DISCOVERY_PORT); + + discovery.sendDiscoveryRequest(broadcastTarget, (senderId, payload) -> { + try { + if (payload.readableBytes() < 4) return; + + int length = payload.readIntLE(); + if (payload.readableBytes() < length) return; + + String hexString = payload.readCharSequence(length, StandardCharsets.UTF_8).toString(); + + byte[] binaryData = ByteBufUtil.decodeHexDump(hexString); + ByteBuf data = Unpooled.wrappedBuffer(binaryData); + + try { + int version = data.readUnsignedByte(); + String serverName = readString(data); + String levelName = readString(data); + int gameType = data.readUnsignedByte() >> 1; + int playerCount = data.readIntLE(); + int maxPlayers = data.readIntLE(); + boolean isEditor = data.readBoolean(); + boolean isHardcore = data.readBoolean(); + + System.out.println("--------------------------------"); + System.out.println("Found Server: " + senderId); + System.out.println("MOTD: " + serverName); + System.out.println("Level: " + levelName); + System.out.println("Players: " + playerCount + "/" + maxPlayers); + System.out.println("Game Mode: " + gameType); + System.out.println("Editor World: " + isEditor); + System.out.println("Hardcore: " + isHardcore); + System.out.println("Version: " + version); + System.out.println("--------------------------------"); + + } finally { + data.release(); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + payload.release(); + } + }); + + Thread.sleep(10000); + discovery.close(); + } + + private static String readString(ByteBuf buf) { + if (!buf.isReadable()) return ""; + int len = buf.readUnsignedByte(); + if (buf.readableBytes() < len) return ""; + return buf.readCharSequence(len, StandardCharsets.UTF_8).toString(); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/PlayerInfo.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/PlayerInfo.java new file mode 100644 index 00000000..c5cccfce --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/PlayerInfo.java @@ -0,0 +1,17 @@ +package dev.kastle.netty.util.nethernet; + +import org.jose4j.jwt.JwtClaims; + +import java.net.InetSocketAddress; + +/** + * The validated identity of a player attempting to join. + * + * @param xuid The Xbox user ID of the player + * @param displayName The Xbox gamertag of the player + * @param networkId The Network ID the player is joining with + * @param remoteAddress The address the join request came from + * @param claims The full set of validated JWT claims, for anything not surfaced above + */ +public record PlayerInfo(String xuid, String displayName, String networkId, InetSocketAddress remoteAddress, JwtClaims claims) { +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/ServerIdentity.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/ServerIdentity.java new file mode 100644 index 00000000..eeda3aa8 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/ServerIdentity.java @@ -0,0 +1,220 @@ +package dev.kastle.netty.util.nethernet; + +import org.jose4j.jwk.EcJwkGenerator; +import org.jose4j.jwk.EllipticCurveJsonWebKey; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.NumericDate; +import org.jose4j.keys.EllipticCurves; +import org.jose4j.lang.JoseException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.security.auth.x500.X500Principal; + +/** + * Produces the server-side identity assertion for each SDP answer + * + * @see NetherNet onboarding guide, section 5.2 + */ +public class ServerIdentity { + private static final String ALG = AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384; // ES384 / P-384 + + private final PrivateKey privateKey; + private final String domain; + private final String token; + + public ServerIdentity(PrivateKey privateKey, PublicKey publicKey, Instant expiry, String domain) throws JoseException { + this.privateKey = privateKey; + this.domain = domain; + this.token = buildToken(publicKey, expiry); + } + + /** + * Loads the keypair from the first key entry of a PKCS12 keystore. + * + * @param keystore The PKCS12 keystore file + * @param password The keystore password + * @return The loaded ServerIdentity + * @throws GeneralSecurityException If there is a security error + * @throws IOException If there is an I/O error + * @throws JoseException If there is an error creating the JWT + */ + public static ServerIdentity fromKeystore(File keystore, String password) throws GeneralSecurityException, IOException, JoseException { + char[] pwd = password.toCharArray(); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (FileInputStream fis = new FileInputStream(keystore)) { + ks.load(fis, pwd); + } + + // Find the first key in the keystore and extract the certificate + String alias = findKeyAlias(ks); + PrivateKey privateKey = (PrivateKey) ks.getKey(alias, pwd); + Certificate cert = ks.getCertificate(alias); + PublicKey publicKey = cert.getPublicKey(); + + // Extract the expiry and common name from the cert if they exist + Instant expiry = null; + String domain = ""; + if (cert instanceof X509Certificate x509) { + expiry = x509.getNotAfter().toInstant(); + domain = extractCommonName(x509.getSubjectX500Principal()); + } + + return new ServerIdentity(privateKey, publicKey, expiry, domain); + } + + /** + * Finds the first key entry alias in a keystore. + * + * @param keyStore The keystore to search + * @return The alias of the first key entry + * @throws KeyStoreException If no key entry is found + */ + private static String findKeyAlias(KeyStore keyStore) throws KeyStoreException { + for (String candidate : Collections.list(keyStore.aliases())) { + if (keyStore.isKeyEntry(candidate)) { + return candidate; + } + } + throw new KeyStoreException("No private key entry found in identity keystore"); + } + + /** + * Search the principal and extract the common name + * + * @param principal The X500Principal to extract the common name from + * @return The common name, or an empty string if not found + */ + private static String extractCommonName(X500Principal principal) { + try { + LdapName name = new LdapName(principal.getName()); + for (Rdn rdn : name.getRdns()) { + if (rdn.getType().equalsIgnoreCase("CN")) { + return rdn.getValue().toString(); + } + } + } catch (InvalidNameException ignored) { } + return ""; + } + + /** + * Generate a brand-new server identity that is not stored + * + * @param domain The domain name for the server identity + * @return A new ServerIdentity instance + * @throws JoseException If there is an error creating the JWT + */ + public static ServerIdentity generate(String domain) throws JoseException { + EllipticCurveJsonWebKey jwk = EcJwkGenerator.generateJwk(EllipticCurves.P384); + return new ServerIdentity(jwk.getPrivateKey(), jwk.getPublicKey(), null, domain); + } + + /** + * Build a JWT token with the given public key and expiry. + * + * @param publicKey The public key to include in the token + * @param expiry The expiration time of the token + * @return The signed JWT token + * @throws JoseException If there is an error signing the token + */ + private String buildToken(PublicKey publicKey, Instant expiry) throws JoseException { + JwtClaims claims = new JwtClaims(); + claims.setClaim("cpk", Base64.getEncoder().encodeToString(publicKey.getEncoded())); // Custom claim required by the NetherNet spec + claims.setIssuedAtToNow(); + + // If we have a domain set it as the isser as it could be shown to the user + if (domain != null && !domain.isBlank()) { + claims.setIssuer(domain); + } + + // Mirror the certificate expiry if set + if (expiry != null) { + claims.setExpirationTime(NumericDate.fromMilliseconds(expiry.toEpochMilli())); + } + + return sign(claims.toJson()); + } + + /** + * Sign the payload with the private key and return the compact JWS serialization. + * + * @param payload The payload to sign + * @return The compact JWS serialization + * @throws JoseException If there is an error signing the payload + */ + private String sign(String payload) throws JoseException { + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(payload); + jws.setKey(privateKey); + jws.setAlgorithmHeaderValue(ALG); + return jws.getCompactSerialization(); + } + + /** + * Generate the identity value as base64 for this answer SDP + * + * @param answerSdp The SDP to generate the identity value for + * @return The base64 identity value + * @throws JoseException If there is an error signing the identity value + */ + public String identityValue(String answerSdp) throws JoseException { + // Generate and sign the fingerprint + String[] fingerprintParts = sign(IdentityUtils.getCanonicalFingerprintJson(answerSdp)).split("\\."); + String fingerprints = fingerprintParts[0] + ".." + fingerprintParts[2]; + + Identity.Assertion assertion = new Identity.Assertion(token, fingerprints); + Identity.Idp idp = new Identity.Idp(domain, "default"); + return new Identity(idp, assertion).toBase64(); + } + + /** + * Insert the identity into the answer SDP + * The specific placement is a strange requirement for the spec but we will follow it + * + * @param answerSdp The SDP to insert the identity into + * @return The SDP with the identity inserted + * @throws JoseException If there is an error signing the identity value + */ + public String augmentAnswer(String answerSdp) throws JoseException { + String line = "a=identity:" + identityValue(answerSdp); + String eol = answerSdp.contains("\r\n") ? "\r\n" : "\n"; + + String[] lines = answerSdp.split("\r\n|\n", -1); + List out = new ArrayList<>(lines.length + 1); + + boolean inserted = false; + for (String current : lines) { + if (!inserted && current.startsWith("m=")) { + out.add(line); + inserted = true; + } + out.add(current); + } + + if (!inserted) { + out.add(line); + } + + return String.join(eol, out); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java new file mode 100644 index 00000000..b7355d67 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.util.nethernet; diff --git a/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java new file mode 100644 index 00000000..d8da8904 --- /dev/null +++ b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NativeAdmissionWriteTest.java @@ -0,0 +1,50 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import io.netty.buffer.*; +import io.netty.channel.*; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +class NativeAdmissionWriteTest { + @Test void nettyClosureCannotHideNativeTeardownFailure() throws Exception { + var group = new DefaultEventLoopGroup(1); + var failure = new IllegalStateException("deterministically stalled native teardown"); + var channel = new AdmittedNetherNetChildChannel(null,null,new InetSocketAddress(1),new InetSocketAddress(2), peer -> { throw failure; }); + try { + group.register(channel).sync(); + ChannelFuture close = channel.close().await(); + assertSame(failure,close.cause()); + assertTrue(channel.closeFuture().await().isSuccess(),"Netty closure alone conceals teardown failure"); + var terminal = channel.nativeTermination().toCompletableFuture(); + assertTrue(terminal.isCompletedExceptionally()); + assertSame(failure,assertThrows(java.util.concurrent.CompletionException.class,terminal::join).getCause()); + assertEquals(0,channel.queuedFrames());assertEquals(0,channel.retainedAssemblyBytes()); + } finally { group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } + } + @Test void preHandshakeWritesAreBoundedPromisesFailAndBuffersReleaseOnClose() throws Exception { + var group = new DefaultEventLoopGroup(1); + var channel = new AdmittedNetherNetChildChannel(null,null,new InetSocketAddress(1),new InetSocketAddress(2)); + List buffers = new ArrayList<>(); List writes = new ArrayList<>(); + try { + group.register(channel).sync(); + for (int i = 0; i < 8; i++) { + ByteBuf buffer = Unpooled.buffer(200_000).writeZero(200_000);buffers.add(buffer);writes.add(channel.write(buffer)); + } + group.next().submit(() -> {}).sync(); + long pending = channel.unsafe().outboundBuffer().totalPendingWriteBytes(); + assertTrue(pending > 0 && pending <= AdmittedNetherNetChildChannel.WRITE_LIMIT, "pending=" + pending); + assertFalse(channel.isWritable()); + assertTrue(writes.stream().anyMatch(f -> f.isDone() && !f.isSuccess())); + assertTrue(writes.stream().anyMatch(f -> !f.isDone())); // acceptance waits for actual native send + ByteBuf unrel = Unpooled.buffer(10_000).writeZero(10_000);buffers.add(unrel); + ChannelFuture oversized = channel.write(new NetherNetPacket(unrel,false)).await(); + assertFalse(oversized.isSuccess());assertInstanceOf(IllegalArgumentException.class,oversized.cause()); + channel.close().sync();channel.eventLoop().submit(() -> {}).sync(); + for (ChannelFuture write : writes) { assertTrue(write.isDone());assertFalse(write.isSuccess()); } + for (ByteBuf buffer : buffers) assertEquals(0,buffer.refCnt()); + } finally { channel.close().awaitUninterruptibly();group.shutdownGracefully(0,1,TimeUnit.SECONDS).sync(); } + } +} diff --git a/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoderTest.java b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoderTest.java new file mode 100644 index 00000000..7024997e --- /dev/null +++ b/transport-nethernet/src/test/java/dev/kastle/netty/channel/nethernet/admission/NetherNetFrameDecoderTest.java @@ -0,0 +1,34 @@ +package dev.kastle.netty.channel.nethernet.admission; + +import org.junit.jupiter.api.Test; +import java.util.Arrays; +import static org.junit.jupiter.api.Assertions.*; + +class NetherNetFrameDecoderTest { + @Test void channelsStayIndependentAndPartialCloseReleasesAssembly() { + var decoder = new NetherNetFrameDecoder(); + assertNull(decoder.decode(new byte[]{1, 10, 11}, true)); + assertArrayEquals(new byte[]{99}, decoder.decode(new byte[]{0, 99}, false)); + assertArrayEquals(new byte[]{10, 11, 12}, decoder.decode(new byte[]{0, 12}, true)); + assertEquals(0, decoder.retainedBytes()); + decoder.decode(new byte[]{1, 42}, true); decoder.clear(); assertEquals(0, decoder.retainedBytes()); + } + @Test void malformedOutOfOrderAndOverLimitAreRejectedWithoutLeaking() { + var decoder = new NetherNetFrameDecoder(); + decoder.decode(new byte[]{2, 1}, true); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[]{0, 2}, true)); + assertEquals(0, decoder.retainedBytes()); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[]{(byte)255, 1}, true)); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[10001], true)); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[]{0}, true)); + for (int i = 26; i > 0; i--) { byte[] frame = new byte[10000]; frame[0] = (byte)i; assertNull(decoder.decode(frame, true)); } + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[10000], true)); + assertEquals(0, decoder.retainedBytes()); + } + @Test void unreliableFragmentsCannotBeMisassembledAcrossReordering() { + var decoder = new NetherNetFrameDecoder(); + assertThrows(IllegalArgumentException.class, () -> decoder.decode(new byte[]{1, 7}, false)); + assertEquals(0, decoder.retainedBytes()); + assertArrayEquals(new byte[]{8}, decoder.decode(new byte[]{0, 8}, false)); + } +} diff --git a/transport-raknet/src/test/java/org/cloudburstmc/netty/RakThrottleTests.java b/transport-raknet/src/test/java/org/cloudburstmc/netty/RakThrottleTests.java index f24ec141..5d155a10 100644 --- a/transport-raknet/src/test/java/org/cloudburstmc/netty/RakThrottleTests.java +++ b/transport-raknet/src/test/java/org/cloudburstmc/netty/RakThrottleTests.java @@ -74,6 +74,7 @@ private Bootstrap clientBootstrap() { .channelFactory(RakChannelFactory.client(NioDatagramChannel.class)) .group(group) .option(RakChannelOption.RAK_PROTOCOL_VERSION, PROTOCOL_VERSION) + .option(RakChannelOption.RAK_MAX_CONNECTION_ATTEMPTS, 1) .handler(new ChannelInitializer() { @Override protected void initChannel(RakClientChannel ch) { diff --git a/transport-raknet/src/test/java/org/cloudburstmc/netty/SplitPacketHelperTests.java b/transport-raknet/src/test/java/org/cloudburstmc/netty/SplitPacketHelperTests.java index 8f5f2cdd..d4c36634 100644 --- a/transport-raknet/src/test/java/org/cloudburstmc/netty/SplitPacketHelperTests.java +++ b/transport-raknet/src/test/java/org/cloudburstmc/netty/SplitPacketHelperTests.java @@ -44,7 +44,7 @@ private static EncapsulatedPacket part(int partCount, int partIndex, int payload @Test public void reassembledSizeTracksRetainedBytes() { - SplitPacketHelper helper = new SplitPacketHelper(3); + SplitPacketHelper helper = new SplitPacketHelper(0, 3); Assertions.assertEquals(0, helper.getReassembledSize()); EncapsulatedPacket p0 = part(3, 0, 100); @@ -77,7 +77,7 @@ public void reassembledSizeTracksRetainedBytes() { @Test public void expiresAfterTimeout() { - SplitPacketHelper helper = new SplitPacketHelper(2); + SplitPacketHelper helper = new SplitPacketHelper(0, 2); Assertions.assertFalse(helper.expired()); helper.release(); }