Compare commits

...
Author SHA1 Message Date
enricobuehler 0df4ca957f fix(host,windows): the fixed-port firewall rules were open to every program on the machine
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 1m19s
ci / web (pull_request) Successful in 1m27s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 5m38s
android / android (pull_request) Successful in 6m1s
`service install` added `dir=in action=allow` rules carrying only `localport=`,
which admit ANY process on the machine on those ports — GameStream
(47984/47989/47998-48010/48010), the native plane (9777), mgmt (47990), mDNS
(5353), and the console pair (47992/47993). Binding a high port on Windows
needs no elevation, so an unprivileged program could take any of them and be
reachable from the LAN simply by binding first — silently, because our rule is
precisely what suppresses the "Allow this app to communicate on…" prompt that
would otherwise be the only way in.

Scope every rule to the executable that actually listens on it, keeping the
ports: program AND port is strictly tighter than either alone. The host rules
name the host exe (resolved once and shared with the data-plane rule, which
already worked this way and is the pattern the rest now follow); the console
rules name the bundled `<app>/bun/bun.exe` the supervisor spawns.

The old argument for leaving them unscoped — "an install whose recorded exe
path later moves still has its fixed ports open" — does not hold: `service
install` re-runs this whole remove-then-add on every upgrade, so the path is
refreshed rather than left stale.

Fallbacks are deliberate and asymmetric. A fixed-port rule whose program can't
be resolved falls back to the old any-program form, because a looser rule still
streams and no rule is a black screen. The data-plane rule instead skips: it
has no `localport=` to fall back to, so a program-less version of it would not
be a looser rule but an open host.

One externally visible change, called out in installer output: 5353 is ours
alone now, so anything else on the machine that answered mDNS through
punktfunk's rule needs its own.

Reported by a user on 2026-08-21, after the source-IP fix in #367 resolved
their black screen.
2026-08-21 16:05:00 +02:00
enricobuehler 13aa11355e Merge pull request 'Video egressed from whichever interface routing picked, not the one the client dialed' (#367) from worktree-blackscreen-data-plane-source-ip into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m2s
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m13s
ci / rust-arm64 (push) Successful in 1m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
deb / build-publish-gamescope (push) Successful in 49s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
deb / build-publish-client-arm64 (push) Successful in 1m11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 36s
deb / build-publish-host (push) Successful in 7m0s
ci / rust (push) Successful in 7m59s
arch / build-publish (push) Successful in 10m16s
android / android (push) Successful in 12m19s
windows-host / package (push) Successful in 13m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
deb / build-publish (push) Failing after 13m45s
deb / smoke-install (push) Successful in 2m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m25s
2026-08-21 12:31:44 +00:00
enricobuehler e989d7457f fix(host): video egressed from whichever interface routing picked, not the one the client dialed
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 6m26s
ci / rust (pull_request) Successful in 15m39s
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source
address from the routing table, independently of the address the client's
control connection actually arrived on. The client's data socket is
`connect`ed to the host IP it dialed, so its kernel drops every datagram from
any other source — before userspace, where nothing counts it.

On a host with two live paths to the client (Ethernet and Wi-Fi both up on the
same LAN; a VPN/overlay adapter claiming the route) that is a permanent black
screen with every gauge green: the hole-punch still arrives so the host logs
`punched=true`, `loss_ppm` stays 0 because there are no packets to see gaps
in, and QUIC — which quinn pins to the right local address — carries control,
audio and input perfectly. `from_socket_punch` already documents the mirror of
this assumption for the *client's* source IP; the host side was never checked.

Bind the data socket to `Connection::local_ip()` instead (unmapping an
IPv4-mapped v6 address so the socket can still `connect` to a v4 peer), and
fall back to the wildcard, loudly, when it is unavailable.

Two diagnostics, because this session's log could not answer the question:
- the `data plane bound` line now carries the socket's post-`connect` `local=`
  address — the source the kernel will actually stamp — and WARNs when it
  differs from the address the control plane arrived on.
- the black-screen ERROR no longer asserts "This is a PATH problem, not
  decode" and no longer names `punched=false` as *the* fingerprint. It fired
  with `punched=true` in the field, which contradicts its own advice and sent
  an investigation at the firewall. It now branches on what the bring-up line
  says, and admits its counter is incremented after decrypt and replay checks,
  so a session whose every datagram failed to open reports the same zero.
2026-08-21 14:06:48 +02:00
enricobuehler b05bb1dd48 A cold-booted host advertised 127.0.0.1 and never recovered (#366)
ci / docs-drift (push) Successful in 38s
ci / bun-nix (push) Successful in 1m14s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 2m2s
deb / build-publish-gamescope (push) Successful in 45s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
deb / build-publish-client-arm64 (push) Successful in 5m40s
ci / rust (push) Successful in 8m16s
arch / build-publish (push) Successful in 10m47s
android / android (push) Successful in 10m59s
deb / build-publish (push) Successful in 10m26s
deb / build-publish-host (push) Successful in 11m17s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 1m11s
deb / smoke-install (push) Successful in 3m54s
windows-host / package (push) Successful in 17m7s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 20m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 20m6s
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field for the life of the process. On a cold boot the
host outruns the network — the Windows service is `AutoStart` with no
dependencies — so the route probe failed with ENETUNREACH and the loopback
fallback stuck until someone restarted the host by hand.

Four surfaces broke off that one field: both mDNS adverts published 127.0.0.1 as
their A record, `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010`,
`wol::wake_macs()` dropped the `mac` TXT record and silently disabled
Wake-on-LAN, and `HostInfo.local_ip` reported loopback to the console.

Fixed at the choke point: `primary_local_ip()` never returns loopback and falls
back to the first non-loopback interface address when no default route exists
yet; `Host::local_ip` re-reads instead of freezing; and a live mDNS advert
re-registers when the routed address changes. Also covers a changed DHCP lease
and a host moved between Wi-Fi and Ethernet.
2026-08-21 12:04:15 +00:00
enricobuehler 2898f6b049 test(host): cover the interface fallback the boot race actually takes
ci / bun-nix (pull_request) Successful in 42s
ci / docs-drift (pull_request) Successful in 43s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m48s
ci / rust (pull_request) Successful in 5m30s
android / android (pull_request) Successful in 6m48s
The route probe needs a default route, which on a cold boot lands after the NIC
has its address; the fallback is what answers in between, and nothing exercised
it. Split it into `first_lan_ipv4` so a test can assert the one thing that
matters: it never hands back the loopback `get_if_addrs` also reports.
2026-08-21 13:34:44 +02:00
enricobuehler 4eb4e3465b refactor(host): end the mDNS re-announce loop with a channel, not a flag
std's mpsc doubles as the sleep and the stop signal: the loop times out every
IP_RECHECK to re-check the address, and the Advert dropping its sender wakes the
thread immediately instead of leaving it to notice a flag up to 10s later. Drops
the Arc<AtomicBool> and the Drop impl.
2026-08-21 13:32:08 +02:00
enricobuehler 44cd5bfd81 Merge pull request 'The button correction fired on pads that never needed it' (#365) from worktree-android-pad-mapping-regression into main
ci / bun-nix (push) Successful in 31s
ci / docs-drift (push) Successful in 44s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m34s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 59s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
docker / builders-arm64cross (push) Successful in 39s
docker / deploy-docs (push) Successful in 1m5s
ci / rust (push) Successful in 7m35s
android / android (push) Successful in 8m29s
2026-08-21 11:24:03 +00:00
enricobuehler 8977228a4b fix(host): a cold-booted host advertised 127.0.0.1 and never recovered
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field forever. On a cold boot the host wins the race
against the network — the Windows service is registered `AutoStart` with no
dependencies — so `primary_local_ip()`'s route probe to 8.8.8.8 failed with
ENETUNREACH and the loopback fallback stuck for the life of the process.
Restarting the host re-ran `detect()` on a live network, which is the workaround
users found.

Four surfaces broke together, all off that one field:

  * both mDNS adverts (`_punktfunk._udp`, `_nvstream._tcp`) published `127.0.0.1`
    as their A record — the address a client lists and dials;
  * `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010` after /launch,
    so even a manually-added host could not stream;
  * `wol::wake_macs()` found no interface for loopback and dropped the `mac` TXT
    record, silently disabling Wake-on-LAN;
  * `HostInfo.local_ip` reported loopback to the web console.

Fixed at the choke point rather than per-caller:

  * `primary_local_ip()` never returns loopback or the unspecified address. When
    the route probe fails it falls back to the first non-loopback interface
    address, which exists as soon as the NIC is configured even if the default
    route is not installed yet — the common shape of the boot race.
  * `Host::local_ip` becomes a method that re-reads instead of a field that
    freezes. A `connect(2)` on an unconnected UDP socket sends no packets and
    costs nothing beside the HTTP response it is serialized into.
  * mDNS records are pushed, not polled, so a live advert re-registers when the
    routed address changes (`discovery::advertise_live`, shared by both service
    types). It polls the routed address rather than subscribing to the daemon's
    IpAdd events because the boot race usually resolves without one: the NIC
    often has its address before we register and only the route lands late.

This also covers the sibling cases that were never reported — DHCP handing out a
different lease, and a host moved between Wi-Fi and Ethernet.
2026-08-21 13:21:52 +02:00
enricobuehler 6a82a602a1 fix(clients/android): the button correction fired on pads that never needed it
ci / docs-drift (pull_request) Successful in 50s
ci / web (pull_request) Successful in 1m12s
ci / bun-nix (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m12s
ci / rust (pull_request) Successful in 5m27s
android / android (pull_request) Successful in 6m41s
Two field reports (2026-08-21), one shape: a GameSir G8+ and an Xbox Elite
Series 2 ("Xbox Wireless Controller" over Bluetooth) with X answering Y, Y
answering LB, and the two shoulders answering menu buttons — everything else
correct. That is not a stray mapping, it is exactly what `GENERIC_XBOX` does to
scancodes `0x133`/`0x134`/`0x136`/`0x137`, so the correction added yesterday was
firing on pads whose buttons were already where `Generic.kl` says they are.

It fired because it asked the wrong question. `hasKeys(BUTTON_C, BUTTON_Z)`
answers for what a device DECLARES, not what it reports: `hid-input` allocates
`BTN_A + n` straight through for every button in the descriptor, so BTN_C
(`0x132`) and BTN_Z (`0x135`) are set on ANY pad declaring six or more —
including a standard-layout pad that never presses either. The signal is
therefore identical on the pad that needs correcting and the pad that does not,
and no amount of tightening it could have separated them. It is the same pad
model in both reports: an Elite Series 2 needed the correction on a Fire TV and
another Elite Series 2 was broken by it here.

What does separate them is the axes. A HID gamepad describes its triggers either
as the Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names
Android has words for — or as two more generic axes on `ABS_Z`/`ABS_RZ`, which
it does not. A descriptor well-formed enough to name its triggers puts its
buttons at the standard positions too. It is also the firmware line on the pad in
the report: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE after
its firmware update and Z/Rz before it, and only the older one was ever wrong.

`padButtons` now takes `namedTriggers` and answers NATIVE whenever it is set —
no correction of any kind, on buttons or axes, for a pad Android already reads.
`padMap` computed that fact one line below and only ever spent it on the axes;
it now decides both. `hasKeys` stays for the narrower question it can answer —
WHICH straight-through order, once the axes have established there is one — where
a false positive costs nothing.

Moonlight decides it on the same fact (`ControllerHandler`, `gasRange == null`
beside the `"Xbox Wireless Controller"` name); yesterday's commit cited its
tables and then replaced its discriminator, which is where this came in.

Verified: `:kit:testDebugUnitTest` and `:app:testDebugUnitTest` green (16 cases
in PadButtonsTest, 3 new: the gate holds for every vendor/declaration
combination, the four reported buttons stay themselves, and the report-order
choice past the gate is unchanged), `:app:compileDebugKotlin` clean. The
DualSense report filed alongside these — Triangle dead in the client UI and in
the stream — is NOT explained by this and is not fixed here: a button that
reaches neither is one `buttonBit` maps to nothing, which no branch of the
correction produces for Triangle. The Controllers screen prints the raw scancode
and keycode of every press; that line off the reporter's pad will pin it.
2026-08-21 13:10:23 +02:00
16 changed files with 646 additions and 172 deletions
+2 -2
View File
@@ -6688,7 +6688,7 @@
},
"HostInfo": {
"type": "object",
"description": "Host identity and advertised capabilities (static for the life of the process).",
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required": [
"hostname",
"uniqueid",
@@ -6734,7 +6734,7 @@
},
"local_ip": {
"type": "string",
"description": "Best-effort primary LAN IP."
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os": {
"type": "string",
@@ -366,15 +366,26 @@ object Gamepad {
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
// Two things keep it from breaking a pad that already works:
//
// 1. The correction is applied ONLY when the delivered keycode is what `Generic.kl` would
// have said ([genericKeyCode]). A different keycode means a device-specific layout IS in
// force and already knows this pad better than we do, so we leave it alone.
// 2. Which report order to read is decided from what the DEVICE declares, never a model
// table: a pad numbering straight through claims BUTTON_C and BUTTON_Z ([PadButtons]),
// keycodes no real controller has a button for.
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
// positions too, and that is the fact — not the model — that separates the two firmwares
// of the SAME Xbox pad, only the older of which needs any of this.
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
// device-specific layout IS in force and knows this pad better than we do.
//
// Moonlight carries the same two tables (`ControllerHandler`'s `isNonStandardDualShock4` /
// `isNonStandardXboxBtController`), which is why both pads work there on the same box.
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
// which is why both pads work there on the same box.
//
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
// does not. Declaration is not report order. Only the axes tell them apart.
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
const val AXIS_NONE = -1
@@ -526,22 +537,42 @@ object Gamepad {
private val padMaps = ConcurrentHashMap<String, PadMap>()
/**
* Which report order [dev]'s buttons follow, asked of the device rather than a model table.
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
* BUTTON_C and BUTTON_Z.
*
* A pad numbering its HID buttons straight through reaches BUTTON_C and BUTTON_Z, keycodes
* that exist only as `Generic.kl` positions — no controller has a physical C or Z button, and
* a pad with a kernel driver behind it emits the modern Linux gamepad codes, which skip both.
* Declaring the pair is therefore the signature of a pad Android is guessing at.
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
*
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
* declaring six or more — a standard-layout pad that never presses either included. Read alone
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
* there is one — where a false positive costs nothing.
*/
fun padButtons(dev: InputDevice): PadButtons {
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
val straightThrough = has[0] && has[1]
return when {
straightThrough && dev.vendorId == VID_SONY -> PadButtons.GENERIC_SONY
straightThrough -> PadButtons.GENERIC_XBOX
dev.vendorId == VID_SONY -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
}
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
* [InputDevice] cannot be built off a device). */
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
namedTriggers -> PadButtons.NATIVE
declaresCZ && sony -> PadButtons.GENERIC_SONY
declaresCZ -> PadButtons.GENERIC_XBOX
sony -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
/**
@@ -566,11 +597,11 @@ object Gamepad {
fun padMap(dev: InputDevice?): PadMap {
if (dev == null) return NATIVE_MAP
padMaps[dev.descriptor]?.let { return it }
val buttons = padButtons(dev)
fun has(a: Int) = axis(dev, a) != null
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
val buttons = padButtons(dev, namedTriggers = named)
val rx = axis(dev, MotionEvent.AXIS_RX)
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
@@ -200,4 +200,55 @@ class PadButtonsTest {
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
}
}
/**
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
* pad ever presses them or not. Naming the triggers is what tells the two apart.
*/
@Test
fun `a pad that names its triggers is never corrected, whatever it declares`() {
for (sony in listOf(false, true)) {
for (declaresCZ in listOf(false, true)) {
assertEquals(
Gamepad.PadButtons.NATIVE,
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
)
}
}
}
/**
* The four buttons the field reports named, on a pad whose report order is already standard:
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
* keeps them themselves — the correction tables are right for the pads they are for, and this
* is about not reaching one of them.
*/
@Test
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
val native = Gamepad.PadButtons.NATIVE
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
// What the old heuristic did to each of them, kept here so the difference stays visible.
val wrong = Gamepad.PadButtons.GENERIC_XBOX
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
}
/** Past the gate, which straight-through order to read is still the question it always was. */
@Test
fun `an unnamed-trigger pad still resolves its report order`() {
fun order(sony: Boolean, declaresCZ: Boolean) =
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
}
}
+104 -29
View File
@@ -25,7 +25,9 @@
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use std::collections::HashMap;
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::mpsc;
use std::time::Duration;
/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts.
pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local.";
@@ -81,9 +83,78 @@ pub(crate) fn dns_label(name: &str) -> String {
}
}
/// Holds the mDNS daemon; dropping it unregisters the service.
/// Holds the mDNS daemon; dropping it unregisters the service and stops the re-announce loop.
pub struct Advert {
_daemon: ServiceDaemon,
/// Never sent on. Dropping it disconnects the channel the re-announce thread waits on, which
/// wakes that thread immediately and ends it — so an `Advert` takes its loop with it instead
/// of leaving one behind polling for a service nobody advertises.
_stop: mpsc::Sender<()>,
}
/// How often a live advert re-checks the address it is announcing.
const IP_RECHECK: Duration = Duration::from_secs(10);
/// The address to advertise right now — loopback only while the machine still has none.
fn current_ip() -> IpAddr {
crate::gamestream::primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
/// Register `build(ip)` for the host's current address, and re-register it whenever that address
/// changes. Shared by both adverts ([`advertise_native`] and [`crate::gamestream::mdns`]).
///
/// mDNS records are PUSHED, not polled: whatever address was true at `register()` keeps being
/// announced until something registers a newer one. The host process comes up during boot, which
/// on a cold start is before the machine has an address — so the first registration could be
/// `127.0.0.1`, and it stayed that way until the host was restarted by hand. `mdns-sd` documents a
/// second `register()` of the same fullname as an update, so re-announcing is just calling it
/// again.
///
/// Polls the *routed* address rather than subscribing to the daemon's `IpAdd` events, because the
/// boot race usually resolves without one: the NIC often has its address before we register and
/// only the default route lands late, so no interface event ever fires.
pub(crate) fn advertise_live(
service: &'static str,
build: impl Fn(IpAddr) -> Result<ServiceInfo> + Send + 'static,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let registered = current_ip();
daemon
.register(build(registered)?)
.with_context(|| format!("register {service} mDNS service"))?;
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let bg_daemon = daemon.clone();
std::thread::spawn(move || {
let mut announced = registered;
// Doubles as the sleep: times out every `IP_RECHECK` to re-check, and returns
// `Disconnected` the moment the `Advert` drops its sender, which ends the loop.
while matches!(
stop_rx.recv_timeout(IP_RECHECK),
Err(mpsc::RecvTimeoutError::Timeout)
) {
let now = current_ip();
if now == announced {
continue;
}
match build(now)
.and_then(|info| bg_daemon.register(info).context("re-register mDNS service"))
{
Ok(()) => {
tracing::info!(service, from = %announced, to = %now, "host address changed — re-announced");
announced = now;
}
// Leave the previous record standing and retry next tick rather than going dark.
Err(e) => {
tracing::warn!(service, error = %format!("{e:#}"), "mDNS re-announce failed");
}
}
}
});
Ok(Advert {
_daemon: daemon,
_stop: stop_tx,
})
}
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
@@ -95,7 +166,6 @@ pub struct Advert {
#[allow(clippy::too_many_arguments)]
pub fn advertise_native(
hostname: &str,
ip: IpAddr,
port: u16,
fingerprint: &str,
require_pairing: bool,
@@ -103,14 +173,17 @@ pub fn advertise_native(
mgmt_port: Option<u16>,
os_chain: &str,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
// `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
// has to be a legal DNS name, hence the separate sanitized label.
let host_name = format!("{}.local.", dns_label(hostname));
let mut props: HashMap<String, String> = HashMap::new();
props.insert("proto".into(), NATIVE_PROTO.into());
props.insert("fp".into(), fingerprint.to_string());
props.insert(
// Owned, because the record is rebuilt whenever the host's address changes — see
// [`advertise_live`]. Everything except the address (and the MACs derived from it) is fixed,
// so it is computed once here and moved into the builder.
let instance = hostname.to_string();
let mut fixed: HashMap<String, String> = HashMap::new();
fixed.insert("proto".into(), NATIVE_PROTO.into());
fixed.insert("fp".into(), fingerprint.to_string());
fixed.insert(
"pair".into(),
if require_pairing {
"required"
@@ -119,31 +192,14 @@ pub fn advertise_native(
}
.into(),
);
props.insert("id".into(), uniqueid.to_string());
fixed.insert("id".into(), uniqueid.to_string());
if let Some(mgmt) = mgmt_port {
props.insert("mgmt".into(), mgmt.to_string());
fixed.insert("mgmt".into(), mgmt.to_string());
}
// `os` — advisory OS-identity chain for the client's host-card icon (see module doc).
if !os_chain.is_empty() {
props.insert("os".into(), os_chain.to_string());
fixed.insert("os".into(), os_chain.to_string());
}
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
// silently fails.
crate::wol::warn_if_not_armed(ip);
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")?;
daemon
.register(service)
.context("register native mDNS service")?;
tracing::info!(
service = "_punktfunk._udp",
port,
@@ -151,7 +207,26 @@ pub fn advertise_native(
pair = if require_pairing { "required" } else { "optional" },
"native punktfunk/1 mDNS advertising"
);
Ok(Advert { _daemon: daemon })
advertise_live(NATIVE_SERVICE, move |ip| {
let mut props = fixed.clone();
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed
// NIC first. A client persists these while the host is awake so it can send a
// Wake-on-LAN magic packet to wake it later (when it's asleep and no longer advertising).
// Unauthenticated like the rest of the advert, but a wrong MAC only makes a wake fail —
// the magic packet is inert and the cert fingerprint still gates the actual connection.
// Omitted when none can be read, which is what a host that came up before its network did
// used to report forever.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason
// WoL silently fails. Re-checked on an address change because the routed NIC may be a
// different one now.
crate::wol::warn_if_not_armed(ip);
ServiceInfo::new(NATIVE_SERVICE, &instance, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")
})
}
#[cfg(test)]
+18 -21
View File
@@ -3,37 +3,34 @@
use super::Host;
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use mdns_sd::ServiceInfo;
use std::collections::HashMap;
/// Holds the mDNS daemon; dropping it unregisters the service.
pub struct Advert {
_daemon: ServiceDaemon,
}
// One `Advert` for both service types: holds the mDNS daemon plus the re-announce loop that
// keeps the record pointed at the host's current address.
use crate::discovery::Advert;
const SERVICE: &str = "_nvstream._tcp.local.";
pub fn advertise(host: &Host) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
// Instance name = the display name (what Moonlight lists); A-record target = the sanitized
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
let service = ServiceInfo::new(
"_nvstream._tcp.local.",
&host.hostname,
&host_name,
host.local_ip,
host.http_port,
props,
)
.context("build mDNS ServiceInfo")?;
daemon.register(service).context("register mDNS service")?;
let instance = host.hostname.clone();
let port = host.http_port;
tracing::info!(
service = "_nvstream._tcp",
port = host.http_port,
port,
host = %host_name,
"mDNS advertising"
);
Ok(Advert { _daemon: daemon })
// The advertised address is supplied per-registration so the record follows the host onto a
// network that only came up after boot — see [`crate::discovery::advertise_live`].
crate::discovery::advertise_live(SERVICE, move |ip| {
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
ServiceInfo::new(SERVICE, &instance, &host_name, ip, port, props)
.context("build mDNS ServiceInfo")
})
}
+97 -8
View File
@@ -138,7 +138,6 @@ pub struct Host {
pub hostname: String,
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
pub uniqueid: String,
pub local_ip: IpAddr,
pub http_port: u16,
pub https_port: u16,
/// OS identity chain (`windows` | `macos` | `linux[/<family>][/<id>]`), advertised in the
@@ -155,13 +154,25 @@ impl Host {
Ok(Host {
hostname: hostname_string(),
uniqueid: load_or_create_uniqueid()?,
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: os.chain.clone(),
os_name: os.pretty.clone(),
})
}
/// Best-effort primary LAN IP, re-read on every call.
///
/// Deliberately NOT a field: [`Host::detect`] runs as the host process starts, which on a cold
/// boot is before the machine has an address at all, and a snapshot taken there used to stick
/// for the life of the process — the host then advertised itself over mDNS as `127.0.0.1`,
/// handed Moonlight an `rtsp://127.0.0.1` session URL, and dropped its Wake-on-LAN MAC record,
/// until someone restarted it by hand. Reading live costs a `connect(2)` on an unconnected UDP
/// socket (no packets are sent), which is nothing beside the HTTP responses it is serialized
/// into. Loopback here means "still no LAN address", not a stale one.
pub fn local_ip(&self) -> IpAddr {
primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
}
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
@@ -432,7 +443,7 @@ pub fn serve(
tracing::info!(
hostname = %state.host.hostname,
uniqueid = %state.host.uniqueid,
ip = %state.host.local_ip,
ip = %state.host.local_ip(),
native_port = native.port,
require_pairing = native.require_pairing,
gamestream,
@@ -656,10 +667,43 @@ fn load_or_create_uniqueid() -> Result<String> {
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
/// local address the OS would route through. No packets are actually sent.
fn primary_local_ip() -> Option<IpAddr> {
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
sock.local_addr().ok().map(|a| a.ip())
///
/// Returns `None` — never loopback — when the machine has no LAN address yet, so callers have to
/// decide what "unknown" means instead of silently inheriting `127.0.0.1`. During a cold boot the
/// route probe fails outright (the host outruns DHCP: the Windows service is `AutoStart` with no
/// network dependency), so it falls back to the first non-loopback interface address, which the
/// NIC has as soon as it is configured even if the default route is not installed yet.
pub(crate) fn primary_local_ip() -> Option<IpAddr> {
let routed = UdpSocket::bind("0.0.0.0:0")
.and_then(|sock| {
sock.connect("8.8.8.8:80")?;
sock.local_addr()
})
.ok()
.map(|a| a.ip())
.filter(|ip| usable_lan_ip(*ip));
routed.or_else(first_lan_ipv4)
}
/// First reachable IPv4 an interface holds, ignoring the routing table entirely.
///
/// Split out because this is the branch the boot race actually takes, and the one nothing would
/// otherwise exercise: the route probe above needs a default route, which lands *after* the NIC
/// has its address on a cold boot. Between those two moments the old code had no answer and fell
/// back to loopback for good.
fn first_lan_ipv4() -> Option<IpAddr> {
if_addrs::get_if_addrs()
.ok()?
.into_iter()
.map(|i| i.ip())
.find(|ip| ip.is_ipv4() && usable_lan_ip(*ip))
}
/// Is `ip` an address a client could actually reach this host on? Loopback and the unspecified
/// address are both "we don't know yet" dressed up as an answer, and advertising either is the
/// boot race that made a freshly-restarted host publish itself as `127.0.0.1`.
fn usable_lan_ip(ip: IpAddr) -> bool {
!ip.is_loopback() && !ip.is_unspecified()
}
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
@@ -740,6 +784,52 @@ mod host_name_tests {
}
}
#[cfg(test)]
mod local_ip_tests {
use super::{first_lan_ipv4, primary_local_ip, usable_lan_ip};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
fn loopback_and_unspecified_are_never_advertisable() {
// The bug: a host that started before its network did advertised these as its address and
// kept doing so for the life of the process.
for unusable in [
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V6(Ipv6Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
] {
assert!(
!usable_lan_ip(unusable),
"{unusable} must not be advertised"
);
}
for usable in [
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 173)),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
] {
assert!(usable_lan_ip(usable), "{usable} is reachable and must pass");
}
}
#[test]
fn probe_reports_no_address_rather_than_loopback() {
// Holds on a networked box and on an isolated CI runner alike: either we found a real LAN
// address, or we admit we have none. `None` is what lets `Host::local_ip()` and the mDNS
// advert keep retrying instead of freezing a wrong answer in place.
assert!(primary_local_ip().is_none_or(usable_lan_ip));
}
#[test]
fn interface_fallback_never_offers_loopback() {
// The branch a cold boot takes, before the default route exists. It may legitimately find
// nothing (a machine with no NIC up, e.g. an isolated CI container) — what it must never
// do is hand back the loopback that `get_if_addrs` also reports.
assert!(first_lan_ipv4().is_none_or(usable_lan_ip));
}
}
#[cfg(test)]
mod session_tests {
use super::*;
@@ -748,7 +838,6 @@ mod session_tests {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux".into(),
@@ -250,7 +250,7 @@ async fn h_launch(
fps = session.fps,
rikeyid = session.rikeyid,
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
st.host.local_ip()
);
xml(session_url_xml(&st, "gamesession")).into_response()
}
@@ -405,7 +405,7 @@ fn gamestream_admission(
fn session_url_xml(st: &AppState, tag: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
st.host.local_ip
st.host.local_ip()
)
}
@@ -485,13 +485,11 @@ fn error_xml() -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn test_state() -> Arc<AppState> {
let host = super::super::Host {
hostname: "t".into(),
uniqueid: "id".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux".into(),
@@ -39,7 +39,7 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
uniqueid = host.uniqueid,
https_port = host.https_port,
http_port = host.http_port,
local_ip = host.local_ip,
local_ip = host.local_ip(),
)
}
@@ -205,7 +205,6 @@ mod tests {
let host = Host {
hostname: "test".into(),
uniqueid: "uid".into(),
local_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
http_port: 47989,
https_port: 47984,
os_chain: "linux".into(),
+6 -3
View File
@@ -23,13 +23,16 @@ pub(crate) struct Health {
abi_version: u32,
}
/// Host identity and advertised capabilities (static for the life of the process).
/// Host identity and advertised capabilities (static for the life of the process, except
/// `local_ip`).
#[derive(Serialize, ToSchema)]
pub(crate) struct HostInfo {
hostname: String,
/// Stable per-host id (persisted across restarts), matched on pairing.
uniqueid: String,
/// Best-effort primary LAN IP.
/// Best-effort primary LAN IP, read fresh on every request — a host that started before its
/// network did (cold boot) reports `127.0.0.1` only until it actually has an address, and a
/// host that moves networks reports the new one. Poll it rather than caching it.
local_ip: String,
/// `punktfunk-host` crate version.
version: String,
@@ -324,7 +327,7 @@ pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<Host
Json(HostInfo {
hostname: h.hostname.clone(),
uniqueid: h.uniqueid.clone(),
local_ip: h.local_ip.to_string(),
local_ip: h.local_ip().to_string(),
version: env!("PUNKTFUNK_VERSION").into(),
abi_version: punktfunk_core::ABI_VERSION,
app_version: APP_VERSION.into(),
-2
View File
@@ -47,7 +47,6 @@ use axum::body::Body;
use axum::http::StatusCode;
use http_body_util::BodyExt;
use sha2::{Digest, Sha256};
use std::net::{IpAddr, Ipv4Addr};
use std::sync::atomic::Ordering;
use tower::ServiceExt;
@@ -73,7 +72,6 @@ fn test_state() -> Arc<AppState> {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux/arch/steamos".into(),
+104 -7
View File
@@ -156,9 +156,33 @@ pub struct Punktfunk1Options {
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
/// window in which a concurrent session could steal a fixed port.
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
///
/// `local_ip` is the address the client's QUIC connection was RECEIVED on (`Connection::local_ip`),
/// and binding to it is load-bearing on a multi-homed host. The client's data socket is
/// `connect`ed to the host IP it dialed, so its kernel accepts video only from THAT source
/// address; a wildcard bind here lets the routing table pick the egress interface independently of
/// the one the control plane arrived on, and the two differ whenever a host has two paths to the
/// client — Ethernet and Wi-Fi both up on the same LAN is the everyday case. Every video datagram
/// is then dropped by the client's kernel before userspace: nothing counts it, `loss_ppm` stays 0
/// (no packets, no gaps), the hole-punch still arrives so the host logs `punched=true`, and the
/// control plane — which quinn pins to the right local address — stays perfectly healthy. That is
/// the "connects fine, black screen forever" shape with every gauge green, and it is invisible on
/// both ends. `None` (platform can't report it) or a bind failure falls back to the wildcard.
fn bind_data_socket(
data_port: Option<u16>,
local_ip: Option<std::net::IpAddr>,
) -> std::io::Result<(std::net::UdpSocket, bool)> {
// An IPv4-mapped v6 local address (dual-stack endpoint) must be unmapped before it can bind a
// socket that will `connect` to a v4 peer — the families have to match.
let local_ip = local_ip.map(|ip| match ip {
std::net::IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, std::net::IpAddr::V4),
v4 => v4,
});
let wildcard = |ip: Option<std::net::IpAddr>| {
ip.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))
};
if let Some(p) = data_port.filter(|p| *p != 0) {
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
match std::net::UdpSocket::bind((wildcard(local_ip), p)) {
Ok(sock) => return Ok((sock, true)),
Err(e) => tracing::warn!(
data_port = p,
@@ -168,7 +192,23 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
),
}
}
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
match std::net::UdpSocket::bind((wildcard(local_ip), 0)) {
Ok(sock) => Ok((sock, false)),
// The control plane arrived on this address moments ago, so a failure here means it just
// went away (an adapter dropped mid-handshake). The wildcard still reaches a client the
// routing table can route to — degraded, not dead — so take it and say why.
Err(e) if local_ip.is_some() => {
tracing::warn!(
local_ip = ?local_ip,
error = %e,
"could not bind the data plane to the address the control connection arrived on \
falling back to the wildcard. On a multi-homed host video may now egress from \
a different interface than the client dialed, which it silently drops."
);
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
}
Err(e) => Err(e),
}
}
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
@@ -365,7 +405,6 @@ pub(crate) async fn serve(
match crate::gamestream::Host::detect() {
Ok(h) => crate::discovery::advertise_native(
&h.hostname,
h.local_ip,
opts.port,
&fingerprint_hex(&fingerprint),
opts.require_pairing,
@@ -2057,6 +2096,10 @@ async fn serve_session(
// stages ride the same per-session trace; resizes write their totals into the shared slot.
let bringup_dp = bringup.clone();
let resize_ms_dp = resize_ms.clone();
// The address the control connection arrived on, for the data plane's source-address check
// below — the one comparison that distinguishes "the client is filtering our video" from
// "the video never left". Captured here because the send loop runs on a blocking thread.
let control_local_ip = conn.local_ip();
let result: Result<()> = async {
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
@@ -2091,15 +2134,44 @@ async fn serve_session(
}
};
bringup_dp.mark("punch_done");
// Post-`connect`, `local_addr` reports the source address the kernel will actually
// stamp on every video datagram — the number that has to match the host IP the client
// dialed, because its data socket is connected and its kernel drops anything else
// before userspace. Logged unconditionally: a black-screen report is unanswerable
// without it (this session's showed only the port).
let local = transport.local_addr().ok();
tracing::info!(
%client_udp,
udp_port,
direct,
punched,
local = ?local,
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
address with no hole-punch; else punched=true the client's observed source, \
false no punch seen, the reported address)"
);
// A video source address that isn't the one the control plane arrived on means the
// client will discard every datagram we send, however healthy this end looks.
if let (Some(l), Some(c)) = (local.map(|a| a.ip()), control_local_ip) {
let c = match c {
std::net::IpAddr::V6(v6) => {
v6.to_ipv4_mapped().map_or(c, std::net::IpAddr::V4)
}
v4 => v4,
};
if !l.is_unspecified() && l != c {
tracing::warn!(
video_source_ip = %l,
control_local_ip = %c,
"the video data plane egresses from a DIFFERENT host address than the one \
this client connected to its data socket is connected to the address it \
dialed, so its kernel drops every video datagram before userspace: black \
screen, zero reported loss, healthy control plane. Usual cause is two \
live paths to the client (Ethernet and Wi-Fi both up on the same LAN, or \
a VPN/overlay adapter claiming the route)"
);
}
}
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
// data port the client cannot reach INBOUND, and every client punches (5/s for the
// first three seconds, then every two). Video then goes to an address the client only
@@ -2515,7 +2587,7 @@ mod tests {
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
// caller hole-punches.
for req in [None, Some(0)] {
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
let (sock, direct) = bind_data_socket(req, None).expect("bind random data socket");
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
assert_ne!(sock.local_addr().unwrap().port(), 0);
}
@@ -2532,13 +2604,14 @@ mod tests {
.port();
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
let (held, direct) = bind_data_socket(Some(free), None).expect("bind fixed data socket");
assert!(direct, "a fixed --data-port must stream direct");
assert_eq!(held.local_addr().unwrap().port(), free);
// While it's held, a second session on the same fixed port can't bind it → it must fall
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
let (fallback, direct2) =
bind_data_socket(Some(free), None).expect("busy fixed port falls back");
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
assert_ne!(
fallback.local_addr().unwrap().port(),
@@ -2547,6 +2620,30 @@ mod tests {
);
}
/// The multi-homed black screen: video must egress from the address the client's control
/// connection arrived on, because the client's data socket is connected to the host address it
/// dialed and its kernel drops every datagram from any other source — silently, before
/// userspace, so nothing on either end counts it. A wildcard bind here lets the routing table
/// choose a different interface whenever the host has two paths to the client.
#[test]
fn data_socket_binds_the_address_the_control_plane_arrived_on() {
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let (sock, direct) =
bind_data_socket(None, Some(loopback)).expect("bind pinned data socket");
assert!(!direct);
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
// An IPv4-mapped v6 local address (a dual-stack QUIC endpoint reports one) has to be
// unmapped, or the socket binds v6 and can never `connect` to the v4 client.
let mapped = std::net::IpAddr::V6(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped());
let (sock, _) = bind_data_socket(None, Some(mapped)).expect("bind mapped data socket");
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
// No reported local address (platform can't say) keeps the old wildcard behaviour.
let (sock, _) = bind_data_socket(None, None).expect("bind wildcard data socket");
assert!(sock.local_addr().unwrap().ip().is_unspecified());
}
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value in
/// `punktfunk_core::input::gamepad` — the single source both the punktfunk/1 native wire and the
/// GameStream/Limelight wire read from (they are one and the same). Renumbering a bit in core
@@ -780,7 +780,9 @@ pub(super) async fn negotiate(
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
// no punch-wait); otherwise a random ephemeral port + hole-punch.
let (data_sock, direct) = bind_data_socket(data_port)?;
// Bound to the address THIS connection arrived on, not the wildcard: the client only accepts
// video from the host IP it dialed (see `bind_data_socket`).
let (data_sock, direct) = bind_data_socket(data_port, conn.local_ip())?;
let udp_port = data_sock.local_addr()?.port();
// The session's video geometry (see the `shard_payload` field below). Resolved before the
+13 -5
View File
@@ -3034,11 +3034,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
counted here, so the picture is black and every keyframe we force is \
wasted. The control plane is healthy (this report arrived on it), so \
the session looks alive: audio, input and the library keep working. \
This is a PATH problem, not decode check that inbound UDP to this \
host's per-session data port is allowed (the 'data plane bound' line \
above shows `punched=false` when the client's hole-punch never \
arrived, which is the fingerprint), and that no other host or \
firewall is intercepting it"
READ THE 'data plane bound' LINE ABOVE it says which leg failed, \
and this line cannot. `punched=false`: the client's hole-punch never \
arrived, so inbound UDP to this host's per-session data port is \
blocked open it (the ports are ephemeral, so the rule must be \
program-scoped, not port-scoped). `punched=true`: inbound is FINE and \
the failure is on the return leg compare that line's `local=` \
source address against the host address this client dialed, because \
its data socket is connected and its kernel silently drops video from \
any other source. If those match, the datagrams left this host \
correctly and the client either never received them (a hop on the \
path) or received them and could not open them: this counter is \
incremented AFTER decrypt and replay checks, so a session whose every \
datagram failed to open reports exactly this same zero"
);
} else if matches_client_recovery_cooldown(period) {
if client_rx == u32::MAX {
+23 -15
View File
@@ -770,8 +770,24 @@ fn web_setup(args: &[String]) -> Result<()> {
// (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is
// what makes it a different origin to the browser while staying same-site for the session
// cookie. Without this rule, plugin interfaces simply do not load from another device.
// Both rules are scoped to the bundled bun binary that actually listens on them, not left
// open to any program: a port-only `dir=in action=allow` rule admits whatever binds the port
// first, needs no elevation to do so, and suppresses the Windows prompt that would otherwise
// be the only way in (see `service::fw_add_rule_args`). The console child is
// `<app>/bun/bun.exe` — the same path `service.rs`'s supervisor spawns — so the rule follows
// it. If that binary isn't there, fall back to the port-only rule rather than leaving the
// console unreachable, and say which happened.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
let bun = app_dir.join("bun").join("bun.exe");
let program = bun.exists().then_some(bun.as_path());
if program.is_none() {
eprintln!(
"warning: {} not found — the console firewall rules stay open to any program on those \
ports instead of only the console",
bun.display()
);
}
for (name, port) in [
("Punktfunk web console (TCP 47992)", "47992"),
("Punktfunk plugin UIs (TCP 47993)", "47993"),
@@ -786,21 +802,13 @@ fn web_setup(args: &[String]) -> Result<()> {
&format!("name={name}"),
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={port}"),
fw_profile,
],
) {
if !crate::service::run_netsh(&crate::service::fw_add_rule_args(
name,
"TCP",
Some(port),
program,
fw_profile,
)) {
eprintln!("warning: could not add the firewall rule for TCP {port}");
}
}
+167 -49
View File
@@ -1550,14 +1550,79 @@ pub(crate) fn allow_public_network(args: &[String]) -> Result<bool> {
Ok(fw_public_marker().exists())
}
/// Build the `netsh advfirewall firewall add rule` argument vector for one inbound allow rule.
///
/// `program` is the whole point of this helper existing. A `dir=in action=allow` rule carrying only
/// `localport=` admits **any process on the machine** on those ports, and binding a high port on
/// Windows needs no elevation — so such a rule is a standing hole that any unprivileged program can
/// step into simply by binding first, and it does so *silently*, because our rule is exactly what
/// suppresses the "Allow this app to communicate on…" prompt Windows would otherwise raise (that
/// prompt is the UAC gate; without a matching rule there is no way in without one). Naming the
/// owning executable keeps the ports open for punktfunk and no one else. Reported by a user on
/// 2026-08-21, and correct: the fixed rules were the last any-program ones we shipped.
///
/// `ports` stays alongside it rather than being replaced by it — program AND port is strictly
/// tighter than either alone, and it is only ever dropped where the port genuinely cannot be known
/// in advance ([`add_data_plane_firewall_rule`], whose port is ephemeral per session).
///
/// `None` for `program` reproduces the old any-program rule, and every caller falls back to it
/// rather than skipping the rule when it cannot resolve its executable: a looser rule still streams,
/// no rule at all is a black screen.
pub(crate) fn fw_add_rule_args(
name: &str,
proto: &str,
ports: Option<&str>,
program: Option<&std::path::Path>,
profile: &str,
) -> Vec<String> {
let mut args: Vec<String> = ["advfirewall", "firewall", "add", "rule"]
.iter()
.map(|s| s.to_string())
.collect();
args.push(format!("name={name}"));
args.push("dir=in".into());
args.push("action=allow".into());
args.push(format!("protocol={proto}"));
if let Some(p) = ports {
args.push(format!("localport={p}"));
}
if let Some(exe) = program {
args.push(format!("program={}", exe.display()));
}
args.push(profile.to_string());
args
}
/// [`run_quiet`] for an arg vector built by [`fw_add_rule_args`].
pub(crate) fn run_netsh(args: &[String]) -> bool {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
run_quiet("netsh", &borrowed)
}
/// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
/// install). Scoped by [`firewall_profile_arg`]: Domain + Private by default, all profiles when
/// `allow_public`. TCP 47990 is deliberate: `serve` binds the mgmt/library REST API to all interfaces
/// so paired clients can browse the game library over mTLS, and off-loopback `mgmt::require_auth`
/// exposes only the read-only status/library allowlist to a paired client cert — the bearer-token
/// admin surface stays loopback-only regardless of the bind — so opening it adds no admin exposure.
/// `allow_public`, and — since 2026-08-21 — to this host executable, so the ports below are open to
/// punktfunk rather than to anything on the machine that binds them first (see
/// [`fw_add_rule_args`]). TCP 47990 is deliberate: `serve` binds the mgmt/library REST API to all
/// interfaces so paired clients can browse the game library over mTLS, and off-loopback
/// `mgmt::require_auth` exposes only the read-only status/library allowlist to a paired client cert
/// — the bearer-token admin surface stays loopback-only regardless of the bind — so opening it adds
/// no admin exposure.
fn add_firewall_rules(allow_public: bool) {
let profile = firewall_profile_arg(allow_public);
// Resolved once and shared with the data-plane rule below. `service install` re-runs this whole
// remove-then-add on every upgrade, so a path recorded here cannot go stale behind a moved
// install — which is what previously argued for leaving these rules unscoped.
let exe = match std::env::current_exe() {
Ok(p) => Some(p),
Err(e) => {
eprintln!(
"warning: could not resolve the host executable path ({e}) — the rules below stay \
open to any program on those ports, and the per-session data-plane rule is skipped"
);
None
}
};
// (name suffix, protocol, ports). 47990 = mgmt/library (LAN = read-only, paired-cert only); the
// rest are the GameStream (47984/47989/48010, 47998-48010) + native (9777) + mDNS (5353) ports.
let rules = [
@@ -1566,28 +1631,35 @@ fn add_firewall_rules(allow_public: bool) {
];
for (suffix, proto, ports) in rules {
let name = format!("Punktfunk {suffix}");
let ok = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
&format!("protocol={proto}"),
&format!("localport={ports}"),
profile,
],
);
let ok = run_netsh(&fw_add_rule_args(
&name,
proto,
Some(ports),
exe.as_deref(),
profile,
));
if ok {
println!("Firewall rule added: {name} ({ports}) [{profile}]");
let scope = match &exe {
Some(p) => format!(" for {}", p.display()),
None => String::new(),
};
println!("Firewall rule added: {name} ({ports}{scope}) [{profile}]");
} else {
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
}
}
add_data_plane_firewall_rule(profile);
add_data_plane_firewall_rule(profile, exe.as_deref());
// 5353 is now ours alone. Anything else on this machine that answered mDNS through the old
// any-program rule needs its own — say so, because it is the one externally visible change.
// Only when the scoping actually happened: with no exe path these rules are still wide open,
// and claiming otherwise in installer output is worse than saying nothing.
if exe.is_some() {
println!(
"Note: these rules are scoped to the punktfunk host executable, so they no longer open \
those ports to every program on this machine. Another mDNS/GameStream application \
that relied on punktfunk's rules to be reachable now needs a rule of its own."
);
}
if !allow_public {
println!(
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
@@ -1613,35 +1685,29 @@ const FW_DATA_PLANE_RULE: &str = "Punktfunk UDP (data plane)";
///
/// Program-scoped rather than a pinned port: it covers whatever port the session picks, needs no
/// second rule when the range moves, and cannot collide with another host (a pinned data port in
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). The port rules above are kept as
/// they are — an install whose recorded exe path later moves still has its fixed ports open.
fn add_data_plane_firewall_rule(profile: &str) {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!(
"warning: could not resolve the host executable path ({e}) — skipping the \
data-plane firewall rule; streams may show a black picture behind a healthy \
connection on networks that need the client's hole-punch to open the path"
);
return;
}
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). This rule is the pattern the
/// fixed-port rules above now follow too — it is only the `localport=` they keep and this one
/// cannot have.
///
/// `exe` is resolved once by the caller and shared; `None` means it could not be resolved, and this
/// rule is skipped rather than widened, because a program-less "any inbound UDP on any port" rule is
/// not a looser version of this — it is an open host.
fn add_data_plane_firewall_rule(profile: &str, exe: Option<&std::path::Path>) {
let Some(exe) = exe else {
eprintln!(
"warning: no host executable path — skipping the data-plane firewall rule; streams may \
show a black picture behind a healthy connection on networks that need the client's \
hole-punch to open the path"
);
return;
};
let ok = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FW_DATA_PLANE_RULE}"),
"dir=in",
"action=allow",
"protocol=UDP",
&format!("program={}", exe.to_string_lossy()),
profile,
],
);
let ok = run_netsh(&fw_add_rule_args(
FW_DATA_PLANE_RULE,
"UDP",
None,
Some(exe),
profile,
));
if ok {
println!(
"Firewall rule added: {FW_DATA_PLANE_RULE} (any UDP port for {}) [{profile}]",
@@ -1872,3 +1938,55 @@ fn maybe_boot_loop_rollback(restarts: u32, attempted: &mut bool) {
Err(e) => tracing::error!(error = %e, "failed to spawn the rollback installer"),
}
}
#[cfg(test)]
mod firewall_tests {
use super::*;
use std::path::Path;
/// Every fixed-port rule must carry BOTH `program=` and `localport=`. Dropping the program
/// scope is the regression that matters: the rule still works, streaming still works, and the
/// only visible difference is that any unprivileged process on the machine can bind those
/// ports and be reachable from the LAN without ever raising a Windows prompt.
#[test]
fn fixed_port_rules_are_scoped_to_the_program_and_the_ports() {
let exe = Path::new(r"C:\Program Files\Punktfunk\punktfunk-host.exe");
let args = fw_add_rule_args(
"Punktfunk UDP",
"UDP",
Some("47998-48010,9777,5353"),
Some(exe),
"profile=domain,private",
);
assert!(args.contains(&format!("program={}", exe.display())));
assert!(args.contains(&"localport=47998-48010,9777,5353".to_string()));
assert!(args.contains(&"dir=in".to_string()));
assert!(args.contains(&"action=allow".to_string()));
assert!(args.contains(&"profile=domain,private".to_string()));
assert_eq!(&args[..4], &["advfirewall", "firewall", "add", "rule"]);
}
/// The data plane is the one rule that legitimately has no port: its socket binds `0.0.0.0:0`
/// per session. It must therefore never lose its program scope — a program-less "any inbound
/// UDP on any port" rule is not a looser version of this rule, it is an open host.
#[test]
fn the_data_plane_rule_has_a_program_but_no_port() {
let exe = Path::new(r"C:\Program Files\Punktfunk\punktfunk-host.exe");
let args = fw_add_rule_args(FW_DATA_PLANE_RULE, "UDP", None, Some(exe), "profile=any");
assert!(args.contains(&format!("program={}", exe.display())));
assert!(
!args.iter().any(|a| a.starts_with("localport=")),
"the per-session data port is ephemeral — pinning one would close the others"
);
}
/// An unresolvable executable falls back to the old any-program rule rather than to no rule:
/// a looser rule still streams, a missing one is a black screen. Pinned so the fallback stays
/// deliberate rather than becoming an accident.
#[test]
fn a_missing_program_falls_back_to_the_port_only_rule() {
let args = fw_add_rule_args("Punktfunk TCP", "TCP", Some("47990"), None, "profile=any");
assert!(!args.iter().any(|a| a.starts_with("program=")));
assert!(args.contains(&"localport=47990".to_string()));
}
}
+2 -2
View File
@@ -6688,7 +6688,7 @@
},
"HostInfo": {
"type": "object",
"description": "Host identity and advertised capabilities (static for the life of the process).",
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required": [
"hostname",
"uniqueid",
@@ -6734,7 +6734,7 @@
},
"local_ip": {
"type": "string",
"description": "Best-effort primary LAN IP."
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os": {
"type": "string",