A dir=in action=allow rule with no program= admits any process on the machine on those ports. Binding a high port on Windows needs no elevation, so any unprivileged program could take 9777, 47990, 5353 or the GameStream range and be reachable from the LAN just by binding first — and silently, because our rule is precisely what suppresses the "Allow this app to communicate on…" prompt that would otherwise be the only way in.
Six port groups were affected: GameStream (47984/47989/47998-48010/48010), native (9777), mgmt (47990), mDNS (5353), and the console pair (47992/47993). The data-plane rule added in #360 was already program-scoped — it is the pattern the rest now follow.
The change
Every rule is scoped to the executable that actually listens on it, keepinglocalport=. Program AND port is strictly tighter than either alone; the port is only ever dropped where it genuinely cannot be known in advance (the data plane binds 0.0.0.0:0 per session).
Host rules → the host exe, resolved once and shared with the data-plane rule.
Console rules (47992/47993) → the bundled <app>/bun/bun.exe, the same path service.rs's supervisor spawns. These listen under bun, not under the host.
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 the whole remove-then-add on every upgrade, so the path is refreshed, not left stale.
Fallbacks are deliberate and asymmetric:
rule
program unresolvable
fixed-port
falls back to the old any-program form — a looser rule still streams, no rule is a black screen
data plane
skips — it has no localport= to fall back to, so a program-less version isn't a looser rule, it's an open host
One visible behaviour change
5353 is punktfunk's alone now. Anything else on the machine that answered mDNS through our permissive rule needs its own rule. service install says so in its output, gated on the scoping actually having happened.
⚠ Verification — read before merging
No PR-triggered job compiles this code.windows/service.rs and windows/install.rs are behind #[cfg(target_os = "windows")]; ci / rust is Linux and skips them, and windows-host.yml triggers only on push to main and v* tags. macOS can't build punktfunk-host at all. So the green checks on this PR mean less than they usually do.
What I did instead:
Dispatched windows-host.yml manually against this branch — that is the real compile, and this stays draft until it is green.
Ran the new fw_add_rule_args and its three tests in a throwaway crate (3/3 pass): fixed-port rules carry both program= and localport=; the data-plane rule carries a program and no port; a missing program degrades to the port-only form.
rustfmt --check directly on both files (confirms they parse).
Worth fixing separately: that windows-host.yml doesn't run on pull_request means every Windows-only change merges uncompiled, which is how this file's blast radius stays invisible until main.
Reported by the user from #367, after the source-IP fix resolved their black screen. They are right.
## The hole
`service install` added inbound rules carrying only `localport=`:
```
netsh advfirewall firewall add rule name="Punktfunk UDP" dir=in action=allow \
protocol=UDP localport=47998-48010,9777,5353 profile=domain,private
```
A `dir=in action=allow` rule with no `program=` admits **any process on the machine** on those ports. Binding a high port on Windows needs no elevation, so any unprivileged program could take 9777, 47990, 5353 or the GameStream range and be reachable from the LAN just by binding first — and *silently*, because our rule is precisely what suppresses the "Allow this app to communicate on…" prompt that would otherwise be the only way in.
Six port groups were affected: GameStream (47984/47989/47998-48010/48010), native (9777), mgmt (47990), mDNS (5353), and the console pair (47992/47993). The data-plane rule added in #360 was already program-scoped — it is the pattern the rest now follow.
## The change
Every rule is scoped to the executable that actually listens on it, **keeping** `localport=`. Program AND port is strictly tighter than either alone; the port is only ever dropped where it genuinely cannot be known in advance (the data plane binds `0.0.0.0:0` per session).
- Host rules → the host exe, resolved once and shared with the data-plane rule.
- Console rules (47992/47993) → the bundled `<app>/bun/bun.exe`, the same path `service.rs`'s supervisor spawns. These listen under bun, not under the host.
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 the whole remove-then-add on every upgrade, so the path is refreshed, not left stale.
Fallbacks are deliberate and asymmetric:
| rule | program unresolvable |
|---|---|
| fixed-port | falls back to the old any-program form — a looser rule still streams, no rule is a black screen |
| data plane | **skips** — it has no `localport=` to fall back to, so a program-less version isn't a looser rule, it's an open host |
## One visible behaviour change
5353 is punktfunk's alone now. Anything else on the machine that answered mDNS through our permissive rule needs its own rule. `service install` says so in its output, gated on the scoping actually having happened.
## ⚠ Verification — read before merging
**No PR-triggered job compiles this code.** `windows/service.rs` and `windows/install.rs` are behind `#[cfg(target_os = "windows")]`; `ci / rust` is Linux and skips them, and `windows-host.yml` triggers only on push to `main` and `v*` tags. macOS can't build `punktfunk-host` at all. So the green checks on this PR mean less than they usually do.
What I did instead:
- Dispatched `windows-host.yml` manually against this branch — that is the real compile, and this stays draft until it is green.
- Ran the new `fw_add_rule_args` and its three tests in a throwaway crate (3/3 pass): fixed-port rules carry both `program=` and `localport=`; the data-plane rule carries a program and no port; a missing program degrades to the port-only form.
- `rustfmt --check` directly on both files (confirms they parse).
Worth fixing separately: that `windows-host.yml` doesn't run on `pull_request` means every Windows-only change merges uncompiled, which is how this file's blast radius stays invisible until main.
`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.
enricobuehler
marked the pull request as ready for review 2026-08-21 14:14:03 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Reported by the user from #367, after the source-IP fix resolved their black screen. They are right.
The hole
service installadded inbound rules carrying onlylocalport=:A
dir=in action=allowrule with noprogram=admits any process on the machine on those ports. Binding a high port on Windows needs no elevation, so any unprivileged program could take 9777, 47990, 5353 or the GameStream range and be reachable from the LAN just by binding first — and silently, because our rule is precisely what suppresses the "Allow this app to communicate on…" prompt that would otherwise be the only way in.Six port groups were affected: GameStream (47984/47989/47998-48010/48010), native (9777), mgmt (47990), mDNS (5353), and the console pair (47992/47993). The data-plane rule added in #360 was already program-scoped — it is the pattern the rest now follow.
The change
Every rule is scoped to the executable that actually listens on it, keeping
localport=. Program AND port is strictly tighter than either alone; the port is only ever dropped where it genuinely cannot be known in advance (the data plane binds0.0.0.0:0per session).<app>/bun/bun.exe, the same pathservice.rs's supervisor spawns. These listen under bun, not under the host.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 installre-runs the whole remove-then-add on every upgrade, so the path is refreshed, not left stale.Fallbacks are deliberate and asymmetric:
localport=to fall back to, so a program-less version isn't a looser rule, it's an open hostOne visible behaviour change
5353 is punktfunk's alone now. Anything else on the machine that answered mDNS through our permissive rule needs its own rule.
service installsays so in its output, gated on the scoping actually having happened.⚠ Verification — read before merging
No PR-triggered job compiles this code.
windows/service.rsandwindows/install.rsare behind#[cfg(target_os = "windows")];ci / rustis Linux and skips them, andwindows-host.ymltriggers only on push tomainandv*tags. macOS can't buildpunktfunk-hostat all. So the green checks on this PR mean less than they usually do.What I did instead:
windows-host.ymlmanually against this branch — that is the real compile, and this stays draft until it is green.fw_add_rule_argsand its three tests in a throwaway crate (3/3 pass): fixed-port rules carry bothprogram=andlocalport=; the data-plane rule carries a program and no port; a missing program degrades to the port-only form.rustfmt --checkdirectly on both files (confirms they parse).Worth fixing separately: that
windows-host.ymldoesn't run onpull_requestmeans every Windows-only change merges uncompiled, which is how this file's blast radius stays invisible until main.