feat: Moonlight-host (Sunshine/Apollo/Wolf) client support - #170
Open
emir-hasanbegovic wants to merge 8 commits into
Open
feat: Moonlight-host (Sunshine/Apollo/Wolf) client support#170emir-hasanbegovic wants to merge 8 commits into
emir-hasanbegovic wants to merge 8 commits into
Conversation
Add a second connection path so each dish can speak the Moonlight (GameStream) protocol directly to any Moonlight-compatible host, alongside the existing Satellite protocol-1 path. Scope: discovery, PIN pairing, app launch, RTSP stream setup, controller input out, and rumble/trigger/motion/ LED events back. No video/audio decoding: streams are negotiated at the lowest settings and their payloads discarded. Pure-Kotlin protocol library (core/net/moonlight): - MoonlightCrypto: pairing-key derivation, AES-ECB challenge exchange, and AES-GCM control sealing, pinned byte-for-byte to Wolf's captured vectors. - MoonlightInputEncoder + MoonlightHotSealer: the hot path encodes CONTROLLER_MULTI into a reused ByteBuffer at fixed offsets and seals it with a reused Cipher and one monotonic control seq, no per-packet allocation. - MoonlightEventDecoder: rumble/trigger/motion/LED decode with malformed and short-buffer handling. - MoonlightControlPacket, MoonlightRtsp, MoonlightPairing, MoonlightXml, MoonlightUrls: framing, RTSP codec, 5-phase client pairing, response parsing. - enet/: a minimal pure-Kotlin ENet client subset (connect handshake, reliable send/receive on channel 0, acks, ping, disconnect) ported from the MIT-licensed cgutman/enet C source. Runtime + integration (source/connection/moonlight): keystore-backed identity, mutual-TLS + TOFU gateway, mDNS (_nvstream._tcp) discovery, UDP control transport, per-host connection and orchestration manager. Surfaced as a ConnectionKind.MOONLIGHT sibling through the connections composer, coordinator, capability profile, and the on-screen controller overlay send path. 66 new unit tests cover the crypto vectors, the byte-exact encoders/decoders, the ENet client, RTSP, pairing round-trip, and the session lifecycle. Wolf and cgutman/enet attributed in THIRD_PARTY.md.
…d bridge Completes the two pieces the first Moonlight commit left staged. User-facing flow. The connections screen grows a MOONLIGHT HOSTS section (Scan + Add, mirroring the satellites section) listing discovered and remembered hosts. Tapping Connect runs the full flow: an emulated-device pick [Auto, Xbox, PlayStation, Nintendo] seeded from the host's remembered choice, then pairing that shows the generated PIN for the user to type into the host's web UI and resolves when the host accepts it, then an app pick from /applist seeded with the last app launched there. Both choices persist per host, so a later reconnect is one tap. Manual host entry probes /serverinfo before the host is added. New strings ship in all six locales. Native physical-pad bridge. The C++ capture path gains SLOT_MOONLIGHT beside SLOT_BLUETOOTH: the Bluetooth report queue and dispatch thread generalize into one bridge queue whose reports carry their slot kind, so a physical pad bound to a Moonlight host publishes through the same fixed-offset hot path and upcalls MoonlightGamepadBridge, which seals and sends on the live control session. The XUSB wButtons map straight across since Moonlight's low-16 button flags share XInput's bit layout. reconcileSlots gains a Moonlight branch with the same live-session re-check the Bluetooth branch does, and its binds are deduped like the others so an unchanged bind never replays a destructive baseline sync. Manager gains pairHost/fetchApps/launch as discrete steps for the screens to orchestrate, keeping one-tap connect for a remembered host. Test delta: 5 new tests (3 reconcile/dedupe cases for the Moonlight branch, 2 for the host-row builder), 68 Moonlight tests total.
…itted PEMs MoonlightPairingTest read four PEM fixtures out of test resources, two of them RSA private keys. They were throwaway keys that protected nothing, but gitleaks is right to flag PEM key material and the tests never needed byte-stable keys: the only pinned assertion is a hardcoded clientSalt, and everything else is a round trip through MoonlightCrypto with client and reference server driven by the same primitives. The test now mints two disposable RSA-2048 self-signed identities at class load via okhttp-tls HeldCertificate, in a companion object so JUnit's per-method instances do not re-generate them. .rsa2048() is explicit because the builder defaults to ECDSA and pairing signs SHA256withRSA. This is how the androidTest FakeSatellite already mints its cert. All four fixtures are deleted; test/resources held nothing else. libs.okhttp.tls was already a vetted androidTestImplementation in the version catalog, so the new testImplementation adds no supply chain. Note that this does not turn the gitleaks job green on its own: the workflow scans full history, where the blobs still live. Clearing that needs a history rewrite, which was deliberately not taken.
…ent key) Two runtime bugs kept pairing from ever reaching the host. Sunshine reported "Pairing Failed: Check if the PIN is typed correctly", which was a red herring: the PIN was fine, the request never arrived. Cleartext. res/xml/network_security_config.xml denies cleartext app-wide, so every /pair phase died inside the app with "Cleartext HTTP traffic to <host> not permitted" and the host never opened a pairing session. Moonlight pairing phases 1-4 and the /serverinfo probe are plaintext on port 47989 by protocol (Wolf http-pairing.adoc): there is no shared secret to build a TLS session on yet. Rather than weaken a policy that exists to be read by Play's pre-launch checks, and which every URL-stack request really should obey, this adds MoonlightPlainHttpClient: a minimal HTTP/1.1 GET spoken by hand over a Socket, which the network security config does not gate, exactly as the encrypted UDP gamepad wire and the discovery beacons already are not. Only getHttp routes through it. Safe by protocol design: what crosses the wire is a salt, the public client cert, and AES challenges over them; the PIN is shown on the dish and typed into the host's own UI, never sent, and phase 5 onwards is pinned mutual TLS. The config's comment now says all this, so the next reader is not misled into thinking the app sends no cleartext at all. The client handles Content-Length, chunked and close-delimited bodies, keeps the gateway's timeouts and its unreachable reply on any transport failure, and never throws. getHttps loses its dead non-secure branch now that nothing else shares it. Client key. The identity key was generated PURPOSE_SIGN + PKCS1 + SHA-256, which signs the pairing secret but is not what Conscrypt asks for during TLS client auth: BoringSSL reduces both TLS 1.3 and RSA-PSS to a raw private-key operation, which CryptoUpcalls requests as a Cipher over RSA/ECB/NoPadding in ENCRYPT_MODE. On AndroidKeyStore that lands in AndroidKeyStoreRSACipherSpi.NoPadding, which overrides the keymaster purpose to SIGN and needs KM_PAD_NONE with KM_DIGEST_NONE. The key authorized neither, so mutual TLS died with INCOMPATIBLE_PADDING_MODE and then an RSA internal error. That only broke the isPaired() pre-check so far, but phase 5, /applist and /launch all ride the same channel. Generation now adds DIGEST_NONE and ENCRYPTION_PADDING_NONE (the KeyProperties spelling of KM_PAD_NONE; both padding setters feed one KM_TAG_PADDING list). The purpose stays SIGN-only, so this authorizes raw signing and not decryption, and the key stays non-exportable in the keystore. A keystore key's authorizations are fixed at generation, so a wider spec does not fix the key already stored under the alias. MoonlightIdentityDecision reads the stored key's own KeyInfo and replaces it when it cannot do the job. Pairing has never succeeded, so no host holds the old cert; any that somehow did would see an unknown client and ask to pair again. Test delta: 15 tests for the raw client (request formatting, Content-Length, chunked, close-delimited, case-insensitive headers, truncated body, truncated head, non-HTTP reply, refused connect, read timeout, bad URL) driven against a loopback ServerSocket, and 11 for the migration decision, which is factored to be pure so it runs without a device.
Found on device once the cleartext and client-key fixes let the request
actually reach Sunshine:
W/MoonlightPlainHttp: plain GET failed for /pair: Read timed out
The host does not answer pairing phase 1 until a human has typed the displayed
PIN into its own web UI. Sunshine parks the phase-1 response and only completes
it on PIN entry, so the request is deliberately long-lived. The gateway applied
its ordinary 5s probe timeout to it, which tore the socket down about the time
the user would still be reaching for a browser, and the host dropped its
half-open pairing session with it. That is the same user-visible dead end as
before: a PIN on screen that the host will never accept.
Phase 1 now gets PAIR_PIN_TIMEOUT_MS instead. The plaintext client takes a
per-call read timeout so this stays scoped to the one request that waits on a
person; /serverinfo probes and pairing phases 2-4, which answer immediately,
keep the short timeout and still fail fast on an unreachable host. The connect
timeout is untouched either way.
Also logs the plaintext client's non-exception give-ups (unusable URL, head
never finished, reply that is not HTTP). Those previously returned an
unreachable reply silently, which is what made this bug take a rebuild to find:
the failure was invisible in logcat.
Verified against a live Sunshine host: the phase-1 request now stays open past
the old cutoff and the host holds an established connection while the PIN is
displayed.
…roid
MoonlightXml.rootOf asked the DOM factory for the Apache DTD switch:
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
Android's DocumentBuilderFactoryImpl recognizes exactly two feature names, the
SAX namespaces and validation ones, and throws ParserConfigurationException for
everything else. rootOf builds the factory inside a runCatching, so on device
that exception was swallowed and rootOf returned null for EVERY document. The
JVM's Xerces does support the feature, so the unit tests all passed and the
breakage existed only on device.
Every Moonlight XML reply therefore failed to parse on Android: /serverinfo (so
a manually added host was always rejected as "No Moonlight host answered"),
/pair (so pairing would have died the instant the user typed the PIN and phase
1 finally returned), /applist and /launch.
Each switch is now applied best-effort, and the guarantee that actually matters
is enforced portably instead: the builder gets an EntityResolver that resolves
every external entity to nothing, so no DTD or entity in a host's reply can make
the parser open a file or a connection, whatever the underlying factory was
willing to admit. On the JVM the feature switches still apply exactly as before,
so nothing is weakened there either.
Test delta: two tests. One parses a /serverinfo body captured verbatim off
Sunshine 7.1, single-line and in the field order it really sends, so a parser
that only copes with the pretty-printed samples cannot pass. One feeds a
file:// external entity through parseServerInfo and fails if the file's contents
reach the parsed document, which pins the XXE guarantee to behaviour rather than
to a feature name.
Verified on device: with this fix a host added by LAN address is accepted and
appears in the list, and pairing against it reaches the PIN prompt with the host
holding an established connection.
Every HTTPS call after the first one against the live Sunshine host timed out:
W/MoonlightHttpGateway: request failed for /serverinfo: Read timed out
W/MoonlightHttpGateway: request failed for /applist: Read timed out
so pairing phase 5 never confirmed, /applist never returned, and the host never
showed as connected even though it had recorded the pairing. The host knew the
dish; the dish did not know the host.
Sunshine's own debug log settles what happened. It received exactly ONE HTTPS
request in the whole session: our /serverinfo, client certificate verified as
"/CN=NVIDIA GameStream Client -- verified", served. Not one later HTTPS request
reached it, while the plaintext half kept arriving normally throughout, pairing
phases 1-4 included. So the requests were not being refused, they were not being
answered at all, and the mutual-TLS credential was never the suspect it looked
like: instrumented on the device, KeyStore.getDefaultType() is BKS, setKeyEntry
accepts the opaque AndroidKeyStoreRSAPrivateKey, and Conscrypt's KeyManagerImpl
hands back the alias, chain and key for keyType RSA.
From the host's own socket table, eleven TLS connections from the dish were
sitting open, one per call the app had made, none of them closed by us until the
app's process died. The host's HTTPS listener answered nothing from that point
on, ours or any other client's: curl from a third machine went from an instant
reject to an 8s timeout against that same port while port 47989 kept replying
in under a millisecond.
Eleven connections for eleven calls, because the URL stack could never reuse
one. com.android.okhttp keys its connection pool on an Address, and an Address
carries the SSLSocketFactory and HostnameVerifier instances. The gateway built
both per call and the verifier is necessarily per host, so no two calls ever
matched: each one dialled a fresh TLS connection, and disconnect() parked the
previous one in the pool instead of closing it.
The HTTPS half now rides the same raw socket the plaintext half has always
used, which is the half that never had this problem. MoonlightPlainHttpClient
becomes MoonlightHttp11Client and takes an optional socket upgrade; the gateway
passes the mutual-TLS handshake plus its certificate pin check, which now
rejects by throwing out of that hook rather than through a HostnameVerifier, so
a host that fails the pin never sees a request. The credential itself is
untouched, and the SSLSocketFactory it builds is built once instead of per call.
Every request opens one socket, sends the Connection: close the client already
sent, and closes it, so the host holds nothing of ours between calls.
The HTTPS connect and read budget goes from 5s to 10s. Every call now pays for
its own handshake, and the dish's half of that handshake is a signature from a
hardware-backed keystore key, which on a cold or busy device waits on keystore
IPC and on the secure element.
Test delta: eight tests. Six drive MoonlightHttpGateway.getHttps against a real
loopback TLS host that demands a client certificate: the client certificate is
presented and the reply parses, Connection: close goes out on the wire, each
call takes its own accepted connection and the host reads EOF on every one, the
host certificate is pinned on first contact, a matching pin keeps working, and a
mismatching one is refused before any request is written. Two more cover the
upgrade hook itself, including that a hook which throws is an unreachable reply
rather than an exception. The throwaway-identity helper the pairing test already
had moves to ThrowawayIdentity so both suites mint their certs the same way.
Still unconfirmed end to end: the host's HTTPS listener has not recovered since
it stopped answering, so /applist returning and the host showing as connected
need a Sunshine restart to observe.
The header claimed the Moonlight mutual-TLS endpoints go through the platform URL stack, and named MoonlightPlainHttpClient. Neither is true any more: both Moonlight halves now speak HTTP/1.1 over their own socket, the plaintext one because this config denies the URL stack cleartext on purpose and the TLS one because the URL stack's connection pool leaked a live session per call. Nothing about the policy changes. The file still denies cleartext to everything that can reach the URL stack, and the TLS half is still TLS, with a client certificate and the host's certificate pinned on first use.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Adds a second connection path so each dish can speak the Moonlight (GameStream) protocol directly to any Moonlight-compatible host (Sunshine, Apollo, Vibepollo, Wolf), alongside the existing Satellite protocol-1 path. The dish becomes a controller for a Moonlight host: it pairs, launches an app, sets up the streams, and forwards controller input (on-screen and physical) while applying rumble/trigger/motion/LED events coming back.
No video or audio decoding. The streams are negotiated at the lowest settings and their payloads are discarded; only the control channel carries real work.
Protocol scope
_nvstream._tcpplus manual host entry (probed via/serverinfobefore being added)./applistthen/launchcarrying the client-generatedrikey/rikeyidthat key the control stream, or/resume.User-facing flow
The connections screen grows a MOONLIGHT HOSTS section (Scan + Add, mirroring the satellites section) listing discovered and remembered hosts. Tapping Connect runs the full flow:
/applist, seeded with the last app launched on that host.Both choices persist per host, so a later reconnect is one tap. New strings ship in all six locales.
Hot path and physical pads
CONTROLLER_MULTIencode + encrypt reuses a fixed-offsetByteBufferand a single reusedCipher, with one monotonic control seq feeding the GCM IV, so a steady input stream does not allocate per packet (mirrors the repo'ssatellite_jni.cppdiscipline). Only the ENet frame itself allocates.Physical controllers stream through that hot path: the C++ capture path gains
SLOT_MOONLIGHTbesideSLOT_BLUETOOTH. The Bluetooth report queue and dispatch thread generalize into one bridge queue whose reports carry their slot kind, so a pad bound to a Moonlight host publishes through the same fixed-offset path and upcallsMoonlightGamepadBridge, which seals and sends on the live control session. XUSBwButtonsmap straight across since Moonlight's low-16 button flags share XInput's bit layout.reconcileSlotsgained a Moonlight branch with the same live-session re-check the Bluetooth branch does, and its binds are deduped like the others.Architecture
The Moonlight path sits beside the Satellite session code as a sibling, surfaced as a new
ConnectionKind.MOONLIGHTthrough the connections composer, coordinator, capability profile, connection glyphs, the connections screen, and the on-screen controller overlay send path, so both paths flow through the same connection model. Identity generation is keystore-backed; the pairing/control crypto is pure JCA (no BouncyCastle) so it unit-tests against Wolf's captured vectors. The satellite protocol-1 path is untouched.Bring-up against a real Sunshine host
First contact with a live Sunshine host found five bugs that, in a chain, made pairing and then every call after it impossible. All five are fixed here. The user-visible symptom was Sunshine saying "Pairing Failed: Check if the PIN is typed correctly", which was a red herring: the PIN was correct, and the request never reached the host at all.
Each one was hidden behind the one before it, so they surfaced one at a time as each was fixed. Three of them (3, 4 and 5) are invisible to the unit suite by construction, and 5 could only be diagnosed with the host's own debug log and socket table open, which is the argument for having done this on a device.
1. Cleartext was denied app-wide, so
/pairnever left the device.res/xml/network_security_config.xmlsetscleartextTrafficPermitted="false"on the base config. Moonlight pairing phases 1-4 and the/serverinfoprobe are plaintext on port 47989 by protocol (Wolfhttp-pairing.adoc), because there is no shared secret to build a TLS session on yet. Every phase-1 request died inside the app, so Sunshine never opened a pairing session and had nothing to match the typed PIN against.The deny stays. It is deliberate: it is what Play's pre-launch security checks and Android's PlatformVal validator read, and every URL-stack request the app makes really should obey it. Instead,
MoonlightPlainHttpClientspeaks a minimal HTTP/1.1 GET by hand over aSocket, which the network security config does not gate, exactly as the encrypted UDP gamepad wire and the LAN discovery beacons already are not. OnlygetHttproutes through it; nothing else in the app gains a cleartext path.This is safe by protocol design: what crosses the wire is a random salt, the public client certificate, and AES challenges and signatures over them. The PIN itself is never sent, it is shown on the dish and typed into the host's own UI, and both ends only prove knowledge of it through the challenge exchange. Everything from phase 5 on is pinned mutual TLS. The config file's comment now spells this out, so the next reader is not misled into thinking the app sends no cleartext at all.
The client handles
Content-Length, chunked and close-delimited bodies, keeps the gateway's connect and read timeouts, returns the gateway's usual unreachable reply on any transport failure, and never throws.2. The keystore client key could not do TLS client auth.
The identity key was generated
PURPOSE_SIGN+SIGNATURE_PADDING_RSA_PKCS1+DIGEST_SHA256, which signs the pairing secret but is not what Conscrypt asks for during TLS client auth. BoringSSL reduces both TLS 1.3 and RSA-PSS to a raw private-key operation, whichCryptoUpcalls.rsaSignDigestWithPrivateKeyrequests as aCipheroverRSA/ECB/NoPaddinginENCRYPT_MODE. On AndroidKeyStore that lands inAndroidKeyStoreRSACipherSpi.NoPadding, whoseadjustConfigForEncryptingWithPrivateKey()overrides the keymaster purpose to SIGN and asks the key forKM_PAD_NONEwithKM_DIGEST_NONE. The key authorized neither.Generation now also authorizes
DIGEST_NONEandENCRYPTION_PADDING_NONE(theKeyPropertiesspelling ofKM_PAD_NONE; both padding setters feed oneKM_TAG_PADDINGlist at generation). The purpose stays SIGN-only, so this authorizes raw signing rather than decryption, and the key stays non-exportable in the keystore. That also covers the TLS 1.2 route, which asks forRSA/ECB/PKCS1Paddingand maps toKM_PAD_RSA_PKCS1_1_5_SIGN+KM_DIGEST_NONE.A keystore key's authorization list is fixed at generation, so widening the spec does not fix a key already stored under the alias.
MoonlightIdentityDecisionreads the stored key's ownKeyInfoand replaces it when it cannot do the job. Discarding the old identity is harmless because pairing has never succeeded, so no host holds the old certificate.Only bug 1 was reachable by the user; bug 2 was latent behind it, breaking just the
isPaired()pre-check so far. It becomes fatal the moment bug 1 is fixed, since pairing phase 5,/applistand/launchall ride the mutual-TLS channel.3. Pairing phase 1 was given a 5-second read timeout, so the host's pending session was torn down.
This one only became reachable once the first two were fixed, and it is the same user-visible dead end: a PIN on screen that the host will never accept. The host does not answer phase 1 until a human has typed the displayed PIN into its own web UI. Sunshine parks the phase-1 response and completes it only on PIN entry, so that request is deliberately long-lived, but the gateway applied its ordinary 5s probe timeout to it. The socket was torn down about the time the user would still be reaching for a browser, and the host dropped its half-open pairing session with it.
Phase 1 now gets
PAIR_PIN_TIMEOUT_MS(120s). The plaintext client takes a per-call read timeout so this stays scoped to the one request that waits on a person:/serverinfoprobes and phases 2-4, which answer immediately, keep the short timeout and still fail fast on an unreachable host. Connect timeouts are untouched throughout.The plaintext client also now logs its non-exception give-ups (unusable URL, head never finished, reply that is not HTTP). Those used to return an unreachable reply silently, which is exactly what made this bug hard to see.
4. The XML hardening disabled the XML parser outright on Android.
MoonlightXml.rootOfasked the DOM factory for the Apache DTD switch:Android's
DocumentBuilderFactoryImplrecognizes exactly two feature names, the SAX namespaces and validation ones, and throwsParserConfigurationExceptionfor everything else.rootOfbuilds its factory inside arunCatching, so on device that exception was swallowed androotOfreturned null for every document. The JVM's Xerces does support the feature, so the entire unit suite passed and the breakage existed only on device.That broke every Moonlight XML reply on Android:
/serverinfo(a manually added host was always rejected with "No Moonlight host answered", which is how this was caught),/pair(pairing would have died the instant the user typed the PIN and phase 1 finally returned),/applistand/launch.Each switch is now applied best-effort, and the guarantee that actually matters is enforced portably instead: the builder gets an
EntityResolverthat resolves every external entity to nothing, so no DTD or entity in a host's reply can make the parser open a file or a connection, whatever the underlying factory was willing to admit. On the JVM the feature switches still apply exactly as before, so nothing is weakened there either. The new test pins that to behaviour rather than to a feature name: it feeds afile://external entity throughparseServerInfoand fails if the file's contents reach the parsed document.5. Every mutual-TLS call after the first one timed out, because the URL stack could never reuse a connection and never closed the ones it made.
With the first four fixed, the user typed the PIN and Sunshine recorded the pairing:
sunshine_state.jsongained aPixel 8entry holding the dish's certificate. The dish never found out. Phase 5 (pairchallenge, the first thing after phase 4 and the first thing over mutual TLS) timed out,/applisttimed out, and the host never showed as connected. The host knew the dish; the dish did not know the host.Sunshine's own debug log settles what happened. It received exactly one HTTPS request in the entire session:
Not one later HTTPS request reached it, while the plaintext half kept arriving normally throughout, phases 1-4 included. Two things follow. The client certificate is fine and always was, since Sunshine verified it and served the request. And the later calls were not being refused, they were never being answered.
The keystore was the obvious suspect and it was checked on the device against the real paired key before being dropped:
KeyStore.getDefaultType()isBKSon Android 17,setKeyEntryaccepts the opaqueAndroidKeyStoreRSAPrivateKeywithout complaint, and Conscrypt'sKeyManagerImplreturns the alias, the chain and the private key for keyTypeRSA. The credential works.What was actually wrong is only visible from the host's side. Its socket table held eleven TLS connections from the dish, one for every call the app had made, none of them closed by us until the app's process died:
Eleven connections for eleven calls, because the URL stack could never reuse one.
com.android.okhttpkeys its connection pool on anAddress, and anAddresscarries theSSLSocketFactoryandHostnameVerifierinstances. The gateway built a fresh factory per call and the verifier is necessarily per host, so no two calls ever matched: every call dialled a new TLS connection, anddisconnect()returned the previous one to the pool rather than closing it. From that first call on, the host's HTTPS listener answered nothing at all, ours or any other client's:curlfrom a third machine went from an instant TLS reject (6 ms) to an 8 s timeout against port 47984, while port 47989 kept replying in under a millisecond.The HTTPS half now rides the same raw socket the plaintext half has always used, which is the half that never had this problem.
MoonlightPlainHttpClientbecomesMoonlightHttp11Clientand takes an optional socket upgrade; the gateway passes the mutual-TLS handshake plus its certificate pin check, which rejects by throwing out of that hook instead of through aHostnameVerifier, so a host that fails the pin never sees a request. The credential itself is untouched, and theSSLSocketFactoryit builds is now built once rather than per call. Every request opens one socket, sends theConnection: closethe client already sent, and closes it, so the host holds nothing of ours between calls and an HTTPS request looks on the wire exactly like the plaintext ones Sunshine has always handled.The HTTPS connect and read budget goes from 5 s to 10 s. Every call now pays for its own handshake, and the dish's half of that handshake is a signature from a hardware-backed keystore key, which on a cold or busy device waits on keystore IPC and on the secure element.
On-device verification
Run against the live Sunshine host on the LAN, from a booted Android emulator (API 36) after the physical Pixel 8 dropped off USB mid-session. The emulator reaches the host through NAT, so the pairing traffic is real traffic to the real Sunshine, not a loopback stub.
Driving the app to Connect on a discovered host now reaches the PIN prompt and stays there, and the whole run produces a completely silent logcat on every tag that used to carry the failures:
Cleartext HTTP traffic to <host> not permitted.CryptoUpcalls ... INCOMPATIBLE_PADDING_MODE, noCould not find provider for algorithm: RSA/ECB/NoPadding, noRSA routines:OPENSSL_internal:internal error.MoonlightHttpGateway: request failed, and noMoonlightPlainHttp: plain GET failed.The silence on the HTTPS side is a positive result, not an absence of evidence:
isPaired()makes a mutual-TLS/serverinfocall before pairing, and any failure there logs. It no longer does, so the keystore client certificate now completes TLS client auth.Two positive results beyond the silence:
192.168.68.98into Add now yields aSamus Aran • 192.168.68.98row. That single outcome requires the plaintext client to reach Sunshine on 47989, get a real 200 back, and parse the body on Android, so it exercises bugs 1 and 4 end to end. Before the fixes it always failed.netsimdis the emulator's network process, so these are the app's connections):The plaintext pairing connection is Established on both sides: Sunshine is holding an open, pending pairing request while the dish displays the PIN, which is precisely the state that was unreachable before. In an earlier run the mutual-TLS connection on 47984 was likewise seen completing and closing gracefully rather than dying in the handshake.
Completing the pairing needs a person to type the PIN into Sunshine's web UI, so the last step is not automated here. Everything up to that point is verified end to end.
Test fixtures
MoonlightPairingTestused to read four PEM fixtures out of test resources, two of them RSA private keys. They were throwaway keys, but gitleaks is right to flag PEM key material and the tests never needed byte-stable keys. The test now mints two disposable RSA-2048 self-signed identities at class load via okhttp-tlsHeldCertificate, the way the androidTestFakeSatellitealready does, and all four fixtures are deleted. See the note on the gitleaks job below.Test coverage
105 Moonlight unit tests:
testCrypto.cpp/testControl.cpp(pairing key, ECB, GCM seal/open, IV evolution, tamper rejection).ServerSocket, covering request formatting (request line,Hostwith the port,Connection: close),Content-Length, chunked, close-delimited and over-long bodies, case-insensitive headers, non-2xx replies, a truncated body, a truncated head, a non-HTTP reply, a refused connection, the read-timeout path, the raised per-call timeout that pairing phase 1 relies on, an unparseable URL, the socket-upgrade hook being handed the host it dialled, and an upgrade that rejects the host being an unreachable reply with nothing written to the wire.MoonlightHttpGateway.getHttpsagainst a real loopback TLS host that demands a client certificate: the client certificate is presented and the reply parses,Connection: closegoes out on the wire, each call takes its own accepted connection with the host reading EOF on every one, the host certificate is pinned on first contact, a matching pin keeps working, and a mismatching one is refused before any request is written./serverinfobody captured verbatim off Sunshine 7.1 and one asserting an external entity in a host's reply never reaches the parsed document.All existing Satellite tests keep passing; the protocol-1 path is untouched.
CI gates (run locally, JAVA_HOME = Android Studio JBR)
shared / Secret scan (gitleaks)is expected to stay red. The workflow checks out withfetch-depth: 0and scans full history, and the two PEM private keys still exist in commit7563dd1even though the working tree no longer carries them. Clearing that needs either a history rewrite (declined: it requires a force-push) or a.gitleaks.tomlallowlist (declined: it would leave the keys in history forever and permanently exempt those paths from future real leaks). The findings are the two known test fixtures and nothing else.Known gaps and risks
/applistreturning and the host showing as connected in the app, because that host's HTTPS listener stopped answering during the diagnosis and has not recovered; confirming it needs the host restarted./launch, RTSP and the control channel have still never run against a live host, and that is now the main risk in the change.AndroidKeyStoreRSACipherSpidocuments for this operation, so this is expected to behave identically, but it is untested on hardware.SLOT_MOONLIGHTpath is compile-verified and symbol-verified (both new JNI entry points are exported and match their Kotlin declarations), but the repo's:app:nativeTestsuite does not compilesatellite_jni.cpp, so the bridge dispatch itself has no native unit test. Its Kotlin-side routing is covered by the reconcile/dedupe tests.control.hpp). moonlight-common-c-based hosts may mixrikeyidinto the IV; that variant could not be verified because those sources are GPL and out of bounds for this LGPL repo. If a host rejects the control stream, the IV construction is the first thing to check.Licensing
Protocol facts and struct layouts derive from Wolf (MIT) docs and server source; the ENet subset is ported from the MIT-licensed cgutman/enet C source. Both are attributed in THIRD_PARTY.md. No GPL sources (moonlight-common-c, moonlight-qt/android, Sunshine, Apollo, Vibepollo) were read or copied.