Clients on the network cannot find the server: nothing answers UDP autodiscovery #112

Closed
opened 2026-08-13 22:59:43 +02:00 by Claude · 1 comment
Collaborator

Upstream listens on UDP 7359 and answers a broadcast probe with its name, id and address, which is how every Jellyfin client offers "servers found on your network" instead of asking for a URL. Nothing in this workspace binds a UDP socket.

The effect is that this server can only ever be added by hand, on every client, which is the first thing a person does after installing and the first place it reads as not-quite-Jellyfin.

The protocol is small and the reference answers it, so it can be diffed rather than read: send the probe upstream expects, record what the reference returns, and match the fields. It is not in the OpenAPI spec, so ENDPOINTS.md will not count it and a harness scenario cannot replay it — it needs a test of its own.

TLS is deliberately not part of this: this server runs behind a reverse proxy, which terminates TLS and does the response compression too.


Where to start

Upstream is src/Jellyfin.Networking/AutoDiscoveryHost.cs, registered from
Jellyfin.Server/Startup.cs; the port and whether it is on come out of
NetworkConfiguration (MediaBrowser.Common/Net/NetworkConfiguration.cs).

The protocol is small: a client broadcasts on UDP 7359, the server answers with
its name, id and address. Because the reference answers it, this can be
diffed rather than read
— send the probe upstream expects, record the reply,
and match the fields exactly. Do that before writing the responder.

It is an adapter with a socket, so it belongs at the edge next to the HTTP
server in crates/jellyfin-server/, taking what it answers from the same
configuration /System/Info/Public is served from.

Two things it is not: it is not in the OpenAPI spec, so ENDPOINTS.md will not
count it and no harness scenario can replay it — it needs a test of its own. And
TLS is explicitly out: this server runs behind a reverse proxy, which also does
the response compression.

How to know it is done

A client on the LAN — the pinned web bundle's connect screen, or a real Swiftfin
or Findroid install — finds this server by itself, without a URL being typed.
Plus a test that sends the probe and asserts the reply's fields against the
recorded upstream one.

House rules

CLAUDE.md is the short version and these are the parts that bite: tests run
under cargo nextest; upstream's source is the checkout in upstream/ at the
pin
(v12.0-rc5, 6d501ba4) and not the clone beside the repository;
generated files are regenerated rather than edited; a comment says what the code
does, not how it got there; and Dn references stay out of code and comments.
ARCHITECTURE.md decides which crate a thing goes in and which dependency edges
are illegal, DECISIONS.md says why a rule exists.

The reference pair is cargo xtask reference up (and reference provision,
which is idempotent); a run is
cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR,
with diff-db for the two databases. crates/jellyfin-harness/README.md says
what a diff means and what an ignore entry has to carry — every entry needs a
reason and an expiry milestone, and an adds-only history is the smell the risk
table names.

Upstream listens on UDP 7359 and answers a broadcast probe with its name, id and address, which is how every Jellyfin client offers "servers found on your network" instead of asking for a URL. Nothing in this workspace binds a UDP socket. The effect is that this server can only ever be added by hand, on every client, which is the first thing a person does after installing and the first place it reads as not-quite-Jellyfin. The protocol is small and the reference answers it, so it can be diffed rather than read: send the probe upstream expects, record what the reference returns, and match the fields. It is not in the OpenAPI spec, so `ENDPOINTS.md` will not count it and a harness scenario cannot replay it — it needs a test of its own. TLS is deliberately **not** part of this: this server runs behind a reverse proxy, which terminates TLS and does the response compression too. --- ## Where to start Upstream is `src/Jellyfin.Networking/AutoDiscoveryHost.cs`, registered from `Jellyfin.Server/Startup.cs`; the port and whether it is on come out of `NetworkConfiguration` (`MediaBrowser.Common/Net/NetworkConfiguration.cs`). The protocol is small: a client broadcasts on UDP 7359, the server answers with its name, id and address. Because the reference answers it, **this can be diffed rather than read** — send the probe upstream expects, record the reply, and match the fields exactly. Do that before writing the responder. It is an adapter with a socket, so it belongs at the edge next to the HTTP server in `crates/jellyfin-server/`, taking what it answers from the same configuration `/System/Info/Public` is served from. Two things it is not: it is not in the OpenAPI spec, so `ENDPOINTS.md` will not count it and no harness scenario can replay it — it needs a test of its own. And TLS is explicitly out: this server runs behind a reverse proxy, which also does the response compression. ## How to know it is done A client on the LAN — the pinned web bundle's connect screen, or a real Swiftfin or Findroid install — finds this server by itself, without a URL being typed. Plus a test that sends the probe and asserts the reply's fields against the recorded upstream one. ### House rules `CLAUDE.md` is the short version and these are the parts that bite: tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin** (`v12.0-rc5`, `6d501ba4`) and not the clone beside the repository; generated files are regenerated rather than edited; a comment says what the code does, not how it got there; and `Dn` references stay out of code and comments. `ARCHITECTURE.md` decides which crate a thing goes in and which dependency edges are illegal, `DECISIONS.md` says why a rule exists. The reference pair is `cargo xtask reference up` (and `reference provision`, which is idempotent); a run is `cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR`, with `diff-db` for the two databases. `crates/jellyfin-harness/README.md` says what a diff means and what an ignore entry has to carry — every entry needs a reason and an expiry milestone, and an adds-only history is the smell the risk table names.
Author
Collaborator

Done, on main.

commit
5699e46 config: whether a broadcasting client is answered, and on which port
31239f0 discovery: the probe a client broadcasts, and the reply it is answered with
cc6a5a3 server: the responder beside the listener
0906464 docs: the port a client's broadcast reaches, and where the socket lives
9c646ca discovery: an address that reaches this server, or no answer at all
c807107 config: the port a deployment does not get to choose

The reply was recorded off the running reference rather than read out of the C#, which is what this issue asked for. UDP 7359 is not published to the host, so the probe went to the container on the bridge at 172.17.0.2; both the implementer and the reviewer got the same four fields in declaration order with the null written rather than omitted:

upstream  {"Address":"http://172.17.0.2:8096","Id":"…","Name":"jellyfin-rs-reference","EndpointAddress":null}
ours      {"Address":"http://10.0.0.113:8098","Id":"…","Name":"jellyfin-rs","EndpointAddress":null}

Also measured off the reference: the probe is who is JellyfinServer? matched case-insensitively anywhere in the datagram, and a datagram missing the ? is not answered.

The generated ServerDiscoveryInfo cannot express this payload and was deliberately not used. crates/jellyfin-dto/src/generated/models.rs:8645 orders the fields alphabetically and carries skip_serializing_if on endpoint_address, because it describes what the HTTP API's serializer emits; this socket calls SerializeToUtf8Bytes with default options. Routing through it would have produced three fields in the wrong order. The generated file is untouched.

What the review changed

A first pass shipped a responder that answered the whole LAN with whatever the HTTP listener had bound — and the default binds loopback. A phone found the server and was handed http://127.0.0.1:8098, a URL pointing at itself: this issue's acceptance criterion failing at the last step, in the one configuration that ships. address() now decides in order — a published URL wins outright, a loopback bind is offered only to a loopback requester, any other specific bind is offered as-is, and a wildcard bind resolves to the interface facing the requester. Where no address of this server reaches the requester, it answers nothing, which is upstream's own shape when GetSmartApiUrl comes back empty.

Five smaller ones went with it: a fallback that could emit http://0.0.0.0:PORT, a 2048-byte datagram cap the reference does not have (2144 bytes of padding: reference answered, we timed out), a receive error that ended discovery for the life of the process, an untested listen(), and a dead IPv6 branch. Bind failure now logs at error as upstream's does, and the negative test went from 4.0s to 0.5s.

Three deliberate divergences, named rather than left to be found

  • Silence where upstream would answer badly. With LocalNetworkAddresses = ["127.0.0.1"], upstream's MatchesBindInterface (NetworkManager.cs:1199) treats loopback as in-local-network and would hand a LAN client 127.0.0.1. Upstream never ships that configuration and our default is exactly it, so answering nothing is the honest reply where naming another interface would name an address nothing listens on.
  • No port field. It was in the first pass and is gone: upstream's port is a const and every client broadcasts to 7359 only, so a server on any other port is discoverable by nothing — the knob was a second spelling of "off". serve_on is the seam the tests use instead, and the README no longer claims the port is how two servers share a machine.
  • The interface is found by asking the routing table, not by enumerating interfaces — unsafe_code = "forbid" is workspace-wide and enumeration needs a new dependency. Connecting a UDP socket to TEST-NET-1 sends nothing and returns the interface this host leaves by.

What is not established

  • No real client found the server. jellyfin-web cannot do this — browsers cannot broadcast, which is why the pinned bundle never had autodiscovery — and no Swiftfin, Findroid or Jellyfin Media Player install exists on this machine. What was run instead is a true 255.255.255.255 broadcast with SO_BROADCAST from the host's LAN address, which is the same datagram on the same wire path.
  • ENETUNREACH was not reproduced. This host has a default route, so every IPv4 address resolves; the fallback is exercised through an IPv6 requester against the V4 socket, which reaches the same None at the same call site by a different errno.
  • The receive-error fix is verified by inspection. An unconnected UDP socket on Linux does not surface ICMP errors without IP_RECVERR, so recv_from could not be made to fail on demand.
  • Multi-homed tie-breaks are unverified against upstream. The reference container has only lo and eth0. Where no interface subnet contains the requester, upstream falls back to prefix-length and interface-index ordering and ours does a route lookup; no case either could construct disagrees.

Six new tests. cargo nextest run --workspace on main with the fixtures present: 2103 passed, 0 skipped. Clippy clean.

Two things this turned up, filed separately: #119 (a wildcard bind makes /System/Info report http://0.0.0.0:PORT — the HTTP route wants the address selection this issue just wrote) and #118 (the tree does not format under the installed rustfmt, which cost real time here).

A browser regression pass was run against the worktree binary: login, home screen and a library render, zero console errors, 169 of 187 responses 200 with the five 403s being the pre-auth /socket retries before the 101 upgrade.

Done, on `main`. | commit | | |---|---| | `5699e46` | config: whether a broadcasting client is answered, and on which port | | `31239f0` | discovery: the probe a client broadcasts, and the reply it is answered with | | `cc6a5a3` | server: the responder beside the listener | | `0906464` | docs: the port a client's broadcast reaches, and where the socket lives | | `9c646ca` | discovery: an address that reaches this server, or no answer at all | | `c807107` | config: the port a deployment does not get to choose | The reply was **recorded off the running reference** rather than read out of the C#, which is what this issue asked for. UDP 7359 is not published to the host, so the probe went to the container on the bridge at `172.17.0.2`; both the implementer and the reviewer got the same four fields in declaration order with the null written rather than omitted: ``` upstream {"Address":"http://172.17.0.2:8096","Id":"…","Name":"jellyfin-rs-reference","EndpointAddress":null} ours {"Address":"http://10.0.0.113:8098","Id":"…","Name":"jellyfin-rs","EndpointAddress":null} ``` Also measured off the reference: the probe is `who is JellyfinServer?` matched case-insensitively anywhere in the datagram, and a datagram missing the `?` is not answered. **The generated `ServerDiscoveryInfo` cannot express this payload** and was deliberately not used. `crates/jellyfin-dto/src/generated/models.rs:8645` orders the fields alphabetically and carries `skip_serializing_if` on `endpoint_address`, because it describes what the HTTP API's serializer emits; this socket calls `SerializeToUtf8Bytes` with default options. Routing through it would have produced three fields in the wrong order. The generated file is untouched. ## What the review changed A first pass shipped a responder that answered the whole LAN with whatever the HTTP listener had bound — and the default binds loopback. A phone found the server and was handed `http://127.0.0.1:8098`, a URL pointing at itself: this issue's acceptance criterion failing at the last step, in the one configuration that ships. `address()` now decides in order — a published URL wins outright, a loopback bind is offered only to a loopback requester, any other specific bind is offered as-is, and a wildcard bind resolves to the interface facing the requester. Where no address of this server reaches the requester, it **answers nothing**, which is upstream's own shape when `GetSmartApiUrl` comes back empty. Five smaller ones went with it: a fallback that could emit `http://0.0.0.0:PORT`, a 2048-byte datagram cap the reference does not have (2144 bytes of padding: reference answered, we timed out), a receive error that ended discovery for the life of the process, an untested `listen()`, and a dead IPv6 branch. Bind failure now logs at error as upstream's does, and the negative test went from 4.0s to 0.5s. ## Three deliberate divergences, named rather than left to be found - **Silence where upstream would answer badly.** With `LocalNetworkAddresses = ["127.0.0.1"]`, upstream's `MatchesBindInterface` (`NetworkManager.cs:1199`) treats loopback as in-local-network and would hand a LAN client `127.0.0.1`. Upstream never ships that configuration and our default is exactly it, so answering nothing is the honest reply where naming another interface would name an address nothing listens on. - **No `port` field.** It was in the first pass and is gone: upstream's port is a `const` and every client broadcasts to 7359 only, so a server on any other port is discoverable by nothing — the knob was a second spelling of "off". `serve_on` is the seam the tests use instead, and the README no longer claims the port is how two servers share a machine. - **The interface is found by asking the routing table**, not by enumerating interfaces — `unsafe_code = "forbid"` is workspace-wide and enumeration needs a new dependency. Connecting a UDP socket to TEST-NET-1 sends nothing and returns the interface this host leaves by. ## What is not established - **No real client found the server.** jellyfin-web cannot do this — browsers cannot broadcast, which is why the pinned bundle never had autodiscovery — and no Swiftfin, Findroid or Jellyfin Media Player install exists on this machine. What was run instead is a true `255.255.255.255` broadcast with `SO_BROADCAST` from the host's LAN address, which is the same datagram on the same wire path. - **`ENETUNREACH` was not reproduced.** This host has a default route, so every IPv4 address resolves; the fallback is exercised through an IPv6 requester against the V4 socket, which reaches the same `None` at the same call site by a different errno. - **The receive-error fix is verified by inspection.** An unconnected UDP socket on Linux does not surface ICMP errors without `IP_RECVERR`, so `recv_from` could not be made to fail on demand. - **Multi-homed tie-breaks are unverified against upstream.** The reference container has only `lo` and `eth0`. Where no interface subnet contains the requester, upstream falls back to prefix-length and interface-index ordering and ours does a route lookup; no case either could construct disagrees. Six new tests. `cargo nextest run --workspace` on `main` with the fixtures present: **2103 passed, 0 skipped**. Clippy clean. Two things this turned up, filed separately: #119 (a wildcard bind makes `/System/Info` report `http://0.0.0.0:PORT` — the HTTP route wants the address selection this issue just wrote) and #118 (the tree does not format under the installed rustfmt, which cost real time here). A browser regression pass was run against the worktree binary: login, home screen and a library render, zero console errors, 169 of 187 responses 200 with the five 403s being the pre-auth `/socket` retries before the 101 upgrade.
Sign in to join this conversation.
No description provided.