fix(airplay): stop refusing receivers by model string, and widen the auth retry - #44
fix(airplay): stop refusing receivers by model string, and widen the auth retry#44snadahalli wants to merge 2 commits into
Conversation
…auth retry Two checks were rejecting receivers on guesses rather than on what the receiver said. **The model-string refusal.** `AppleTV2,*`/`AppleTV3,*` were refused up front as FairPlay-only. The string is self-reported, and third-party receivers reuse it for client compatibility. A Vivitek NovoConnect on the test network advertises `AppleTV3,1` over mDNS, reports `AppleTV3,2` from GET /info, and is neither: `rmodel=AirReceiver3,1`, no HomeKit pairing, `pw=0`, and a `/fp-setup` that answers 200 to an empty body. Refusing it on sight meant never learning what it actually does. Probed directly, it authenticates fine over HTTP Digest with the on-screen PIN, then answers `501 Not Implemented` to `POST /stream` with any body — it serves `/play` and `/playback-info` but implements no screen mirroring, while its feature bits claim `mirroring: true`. Useful, specific, and completely invisible behind a model-string check. Genuine Apple TV 2/3 still cannot work. They will now say so themselves, which is accurate and debuggable rather than assumed. The check becomes a warning. **The auth retry missed the statuses receivers actually send.** The fallback into pairing triggered on `501` or `403` only. A Mac with AirPlay Receiver set to Everyone and no password answers **404** — the legacy AirPlay 1 endpoint does not exist there — and one with a password answers **470**. Receivers behind HTTP Digest answer **401**. None matched, so casting gave up without attempting to pair at all, which is why `pair_probe` could reach M4 while the app could not get near it. Matching on message text stays crude: the status is not carried through `AirPlayError::Negotiation`, so a body echoing "403" would trip it. Widening the net is still strictly better than skipping pairing silently. Threading a real status code through that error is worth doing on its own.
0051019 to
c1d6336
Compare
The widened retry set in this branch is right — 404 and 470 are what a Mac actually answers — but `wants_authentication` matched the codes against the whole error message, and `AirPlayError::Negotiation` carries the entire header block. So `Content-Length: 1401` in a permanent 500 read as a 401 and provoked a full SRP-6a pair-setup against a receiver that was never going to authenticate, replacing an accurate error with a misleading pairing one. `Server: AirTunes/470.x` and a Date containing "501" do the same. The code is now parsed from the first line only. The same substring bug was sitting one layer down in `http_session::post_stream`, where a 500 carrying `Content-Length: 1200` was accepted as success and the caller went on to write video into a failed connection; that reads the status line too now. The tests were shaped so they could not catch this: they passed single-line strings, while production passes a multi-line header block. They now build realistic responses, and the negative case asserts that a retryable code sitting in a header does not trigger a retry. Reverting the fix fails it. Also replaces the model-string refusal this branch removed with a check that predicts the same thing on evidence rather than on a name: feature bit 48, the receiver's own statement about transient-pairing support. A genuine Apple TV 2/3 does not set it and is turned away with a reason naming FairPlay and pointing at docs/crypto.md — reaching the user instead of a warn! nobody sees. A Vivitek that merely borrows the model string is not turned away. That makes the function's docstring true; it already claimed this gate existed. docs/crypto.md and CLAUDE.md said the refusal was intended behaviour. They now record what replaced it and why the model string was never evidence of anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed a follow-up commit (7d0c635) addressing the review findings before merge. The widened retry set is right — 404 and 470 are what a Mac actually answers, and the hardware evidence in the PR body is the good kind. But
Each provoked a full SRP-6a pair-setup against a receiver that was never going to authenticate, and replaced an accurate error with a misleading pairing one. The code is now parsed from the first line only. The same substring bug was one layer down in The tests could not have caught this — they passed single-line strings while production passes a multi-line header block. They now build realistic responses, and the negative case asserts a retryable code sitting in a header does not trigger a retry. Verified by reverting the fix: On removing the model-string refusal: agreed, and the Vivitek evidence is convincing — the string is self-reported and third-party receivers reuse it. But dropping it left nothing in its place, and the function's own docstring already claimed a transient-pairing gate that did not exist. That gate now exists: feature bit 48, the receiver's own statement about transient-pairing support. A genuine Apple TV 2/3 does not set it and is turned away with a reason naming FairPlay and pointing at
fmt/clippy/ |
Stacked on #43 — merge #43 first, then this. (See the note at the bottom; #42 was lost to exactly this hazard.)
Two checks were rejecting receivers on guesses rather than on what the receiver actually said.
1. The model-string refusal
AppleTV2,*/AppleTV3,*were refused up front as FairPlay-only. But the model string is self-reported, and third-party receivers reuse it for client compatibility.A Vivitek NovoConnect on the test network:
It is not an Apple TV. Refusing it on sight meant never learning what it does. Probed directly it turns out to:
WWW-Authenticate: Digest realm="airplay") with the on-screen PIN/playand/playback-info(200 OK once authenticated)501 Not ImplementedtoPOST /streamwith any body — original keys,type=110,streamType=110, or emptySo it implements AirPlay video playback but no screen mirroring at all — while its feature bits advertise
mirroring: true. That is specific, useful, and was completely invisible behind a model-string check.Genuine Apple TV 2/3 still cannot work. They will now say so themselves, which is accurate rather than assumed. The check becomes a
warn!.2. The auth retry missed the statuses receivers actually send
The fallback into pairing triggered on
501or403only. Observed in practice:/streamendpoint)None matched, so casting gave up without attempting to pair at all. That is why
pair_probecould reach M4 while the app could not get near it — the probe calls pair-setup directly and skips this gate entirely.Now retries on
401,403,404,470,501, with 3 unit tests covering the newly-handled statuses, the original two, and unrelated failures that must not retry.Known weakness, stated rather than hidden
Matching on message text is crude — a body echoing "403" would trip it. The status code is not carried through
AirPlayError::Negotiation, so there is nothing better to match on today. Widening the net is still strictly better than silently skipping pairing. Threading a real status through that error type is worth doing on its own.Verification
fmt --checkclean,clippy --all-targets --all-features -D warningsclean,cargo test --all236 passed / 0 failed, plus the hardware probes above.Merge order
This is stacked on #43. Merge #43 into master first, then merge this. Merging this into its base branch after #43 has already been merged would strand it exactly as #42 was stranded — see #43's description for what that looked like.