Clients on the network cannot find the server: nothing answers UDP autodiscovery #112
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.mdwill 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 fromJellyfin.Server/Startup.cs; the port and whether it is on come out ofNetworkConfiguration(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 sameconfiguration
/System/Info/Publicis served from.Two things it is not: it is not in the OpenAPI spec, so
ENDPOINTS.mdwill notcount 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.mdis the short version and these are the parts that bite: tests rununder
cargo nextest; upstream's source is the checkout inupstream/at thepin (
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
Dnreferences stay out of code and comments.ARCHITECTURE.mddecides which crate a thing goes in and which dependency edgesare illegal,
DECISIONS.mdsays why a rule exists.The reference pair is
cargo xtask reference up(andreference provision,which is idempotent); a run is
cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR,with
diff-dbfor the two databases.crates/jellyfin-harness/README.mdsayswhat 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.
Done, on
main.5699e4631239f0cc6a5a309064649c646cac807107The 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: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
ServerDiscoveryInfocannot express this payload and was deliberately not used.crates/jellyfin-dto/src/generated/models.rs:8645orders the fields alphabetically and carriesskip_serializing_ifonendpoint_address, because it describes what the HTTP API's serializer emits; this socket callsSerializeToUtf8Byteswith 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 whenGetSmartApiUrlcomes 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 untestedlisten(), 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
LocalNetworkAddresses = ["127.0.0.1"], upstream'sMatchesBindInterface(NetworkManager.cs:1199) treats loopback as in-local-network and would hand a LAN client127.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.portfield. It was in the first pass and is gone: upstream's port is aconstand 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_onis the seam the tests use instead, and the README no longer claims the port is how two servers share a machine.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
255.255.255.255broadcast withSO_BROADCASTfrom the host's LAN address, which is the same datagram on the same wire path.ENETUNREACHwas 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 sameNoneat the same call site by a different errno.IP_RECVERR, sorecv_fromcould not be made to fail on demand.loandeth0. 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 --workspaceonmainwith the fixtures present: 2103 passed, 0 skipped. Clippy clean.Two things this turned up, filed separately: #119 (a wildcard bind makes
/System/Inforeporthttp://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
/socketretries before the 101 upgrade.