Display server-info to aid debugging server problems (was: all commands failed for me with HTTP 403 status)
The airplay target I'm trying to control has only options to ask for a PIN or password on first connection or every connection, with no option to never ask for one, so I can't control it because everything fails with a 403 HTTP status code (thanks for the toast saying that, rather than a less clear error!). I also can't hack the device because it's not mine (and its developers suck anyway).
Is it possible to add a PIN or password as a setting in this client, please? Either per-device or even systemwide would solve my problem.
Or is there even a default hardcoded? I browsed the code and didn't find it, but I'm not great at Android coding.
You make a good point.
According to this unofficial spec, AirPlay v1 did support Digest access authentication.
Here is a super quick (untested) example of how it could be implemented..
- code
-
unmodified method that bootstraps playback
public void playVideo(URL location, ServiceInfo serviceInfo) throws Exception { if (serviceInfo == null) { throw new Exception("Not connected to AirPlay service"); } es.submit(new PlayVideoTask(location, serviceInfo)); } -
modified method that handles authentication
import java.security.MessageDigest; import java.util.HashMap; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; private class PlayVideoTask implements Runnable { private URL location; private ServiceInfo serviceInfo; public PlayVideoTask(URL location, ServiceInfo serviceInfo) { this.location = location; this.serviceInfo = serviceInfo; } @Override public void run() { sendRequest(null); } private void sendRequest(Map<String, String> reqHeaders) { try { StringBuilder content = new StringBuilder(); content.append("Content-Location: "); content.append(location.toString()); content.append("\n"); content.append("Start-Position: 0\n"); URL url = new URL(serviceInfo.getURL() + "/play"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setDoInput(true); conn.setDoOutput(true); conn.setConnectTimeout(15 * 1000); conn.setReadTimeout(15 * 1000); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Length", "" + content.length()); conn.setRequestProperty("Content-Type", "text/parameters"); conn.setRequestProperty("X-Apple-AssetKey", UUID.randomUUID().toString()); conn.setRequestProperty("X-Apple-Session-ID", UUID.randomUUID().toString()); conn.setRequestProperty("User-Agent", "MediaControl/1.0"); if (reqHeaders != null) { for (Map.Entry<String, String> reqHeader : reqHeaders.entrySet()) { conn.setRequestProperty(reqHeader.getKey(), reqHeader.getValue()); } } BufferedOutputStream out = new BufferedOutputStream(conn.getOutputStream()); out.write(content.toString().getBytes()); out.close(); int status = conn.getResponseCode(); if (status == 401) { sendAuthRequest(reqHeaders, conn); return; } if (callback != null) { if (status == 200) callback.onPlayVideoSuccess(location); else callback.onPlayVideoError(location, "AirPlay service responded HTTP " + status); } } catch (Exception e) { if (callback != null) callback.onPlayVideoError(location, e.getMessage()); } } private void sendAuthRequest(Map<String, String> reqHeaders, HttpURLConnection conn) { // TODO: replace hard coded password with a UI prompt String password = "1234"; if (reqHeaders == null) { reqHeaders = (Map<String, String>) new HashMap<String, String>(); } // https://developer.android.com/reference/java/net/URLConnection#getHeaderField(java.lang.String) String resHeaderAuth = conn.getHeaderField("WWW-Authenticate"); if (resHeaderAuth != null) { Pattern regex = Pattern.compile("^.*nonce=\"([^\"]+)\".*$"); Matcher match = regex.matcher(resHeaderAuth); if (match.find()) { String nonce = match.group(1); // HA1 = MD5(username:realm:password) // HA2 = MD5(method:digestURI) // response = MD5(HA1:nonce:HA2) String HA1 = MD5("AirPlay:AirPlay:" + password); String HA2 = MD5("POST:/play"); String response = MD5(HA1 + ":" + nonce + ":" + HA2); String reqHeaderAuthName = "Authorization"; String reqHeaderAuthValue = "Digest username=\"AirPlay\", realm=\"AirPlay\", nonce=\"" + nonce + "\", uri=\"/play\", response=\"" + response + "\""; reqHeaders.put(reqHeaderAuthName, reqHeaderAuthValue); sendRequest(reqHeaders); } } } // http://fancifulandroid.blogspot.com/2014/01/android-convert-string-to-md5-properly.html // https://stackoverflow.com/a/21333739 private static String MD5(String md5) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] array = md.digest(md5.getBytes("UTF-8")); StringBuffer sb = new StringBuffer(); for (int i = 0; i < array.length; ++i) { sb.append(Integer.toHexString((array[i] & 0xFF) | 0x100).substring(1,3)); } return sb.toString(); } catch (Exception e) { return null; } } }
-
unmodified method that bootstraps playback
You could test whether or not Digest authentication will work with your target receiver by hand crafting the "Authorization" request header and using curl for a command-line client.
Such a test would be super fast and a good way to test what does (and doesn't) work. Once a solution is found that works when constructed manually.. the same methodology could be applied by the app. Conversely, the methodology that I implemented above could be easily converted to a bash script and tested without any build step.
Here is a bash script that can be used to test..
#!/usr/bin/env bash
debug='1'
username='AirPlay'
password='1234'
function sendRequest() {
if [ "$debug" == '1' ]; then
sendTestRequest
else
sendRealRequest
fi
return $?
}
function sendTestRequest() {
protected_url="https://httpbin.org/digest-auth/auth/${username}/${password}"
http_method='HEAD'
http_uri="/digest-auth/auth/${username}/${password}"
http_response=$(curl --silent -I -H "$http_auth_header" "$protected_url")
#echo "$http_response"
process_http_response
return $?
}
function sendRealRequest() {
airplay_ip='192.168.1.100:8192'
video_url='https://www.cbsnews.com/common/video/cbsn_header_prod.m3u8'
http_method='POST'
http_uri='/play'
http_response=$(curl --silent --include -X "$http_method" \
-H "$http_auth_header" \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_url}\nStart-Position: 0" \
"http://${airplay_ip}${http_uri}" \
)
#echo "$http_response"
process_http_response
return $?
}
function process_http_response() {
http_status=$(echo "$http_response" | head -n 1 | grep -s -o -P '\d{3}')
echo "$http_status"
if [ "$http_status" == '200' ];then
echo 'OK'
return 0
fi
if [ "$http_status" == '401' ];then
nonce=$(echo "$http_response" | grep -i 'WWW-Authenticate' | grep -s -o -P 'nonce="[^"]+')
nonce=${nonce:7}
#echo "$nonce"
HA1=$(MD5 "${username}:AirPlay:${password}")
HA2=$(MD5 "${http_method}:${http_uri}")
response=$(MD5 "${HA1}:${nonce}:${HA2}")
http_auth_header="Authorization: Digest username=\"${username}\", realm=\"AirPlay\", nonce=\"${nonce}\", uri=\"${http_uri}\", response=\"${response}\""
echo "$http_auth_header"
sendRequest
return $?
fi
return 1
}
function MD5() {
text="$1"
hash=$(echo -n "$text" | md5sum | awk '{print $1}')
echo "$hash"
}
http_auth_header='X-Foo: Bar'
sendRequest
exit $?
regarding something you said..
your AirPlay receiver is returning a 403 Forbidden status, rather than a 401 Unauthorized?
..that's problematic
note to self.. regarding touchpoints to integrate a new dialog into the UI:
https://github.com/warren-bank/Android-AirPlay-Client/blob/v0.5.5/android-studio-project/AirPlay-Client/src/main/java/com/github/warren_bank/airplay_client/ui/DroidPlayActivity.java#L351
https://github.com/warren-bank/Android-AirPlay-Client/blob/v0.5.5/android-studio-project/AirPlay-Client/src/main/java/com/github/warren_bank/airplay_client/service/MyMessageHandler.java#L81
TODO:
-
msg.objpasses references to both:- File
- Context
-
client.playVideoadds parameter- Context
- 401 status
- show dialog to ask user password for selected AirPlay receiver
- resend
/playrequest with properly formedAuthorization: Digestheader
XREF.. this is very similar to:
https://github.com/warren-bank/Android-AirPlay-Client/blob/v0.5.5/android-studio-project/AirPlay-Client/src/main/java/com/github/warren_bank/airplay_client/ui/DroidPlayActivity.java#L187
https://github.com/warren-bank/Android-AirPlay-Client/blob/v0.5.5/android-studio-project/AirPlay-Client/src/main/java/com/github/warren_bank/airplay_client/service/MyMessageHandler.java#L51
https://github.com/warren-bank/Android-AirPlay-Client/blob/v0.5.5/android-studio-project/AirPlay-Client/src/main/java/com/github/warren_bank/airplay_client/service/NetworkingService.java#L243
https://github.com/warren-bank/Android-AirPlay-Client/blob/v0.5.5/android-studio-project/AirPlay-Client/src/main/java/com/github/warren_bank/airplay_client/ui/dialogs/ConnectDialog.java#L49
https://github.com/warren-bank/Android-AirPlay-Client/blob/v0.5.5/android-studio-project/AirPlay-Client/src/main/java/com/github/warren_bank/airplay_client/service/NetworkingService.java#L351
regarding something you said..
your AirPlay receiver is returning a 403 Forbidden status, rather than a 401 Unauthorized?
..that's problematic
Yes, I've double checked this today. The toast says 403. Might that mean it's implementing an unsupported version of AirPlay?
Thanks for the bash script. I'll run that when I get time.
The bash script returns 403. Modifying it to display the full response revealed this, in case it helps:
HTTP/1.1 403 Forbidden Content-Length: 0 Server: AirTunes/377.40.00
hmm.. well, I'm no expert on Apple devices.. in fact, I'm pretty much whatever the exact opposite of that would be :smiley: I don't own any Apple products, and I don't have any desire to ever do so.. that being said, I'm not sure what device that server identification string represents.. but to determine whether or not it supports the AirPlay version 1.0 protocol.. the following might provide some added insight:
airplay_ip='192.168.1.100:8192'
curl "http://${airplay_ip}/server-info"
for example, I'm running ExoAirPlayer, and this request:
curl 'http://192.168.0.3:8192/server-info'
returns:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>deviceid</key>
<string>10:D0:7A:BB:D3:A7</string>
<key>features</key>
<integer>10623</integer>
<key>model</key>
<string>AppleTV2,1</string>
<key>protovers</key>
<string>1.0</string>
<key>srcvers</key>
<string>130.14</string>
</dict>
</plist>
the relevant part of the response is:
<key>protovers</key>
<string>1.0</string>
which identifies that the protocol version is 1.0
something else that seems very relevant.. this shows what ports are used by AirTunes (rtsp server) and AirPlay (http server) in MacOS, starting with OSX Yosemite.. this shows that the AirTunes (rtsp server) returns 403 when sent an http request.. which appears to be what you're seeing.
It doesn't look like a rtsp reply to me and GET isn't an rtsp method, so I'd expect 501 Not Implemented or some other 5xx reply. But some servers do strange things, so 403 wouldn't surprise me too much.
It's not an Apple device. It's some sort of Roku TV. However, it returns 403 Forbidden in reply to the GET /server-info too! I found a page which makes me strongly suspect it only supports AirPlay 2 https://community.roku.com/t5/Features-settings-updates/Which-devices-are-compatible-with-Apple-Airplay/td-p/691587
So unless you've plans to support later protocol versions, I guess that means I'm out of luck and still looking for a F-Droid-ish way to playback better than DLNA can.
Thanks for your help debugging this. Maybe it would be good if the app could request and display the server-info? Then if that fails, the user will know easily something basic is not as it needs to be.
Does this resolved or any other alternative for this ?
Allow me to quickly summarize the discussion in this issue up to this point:
- OP asked me to implement client authorization for an AirPlay v1 receiver that doesn't accept non-authorized HTTP requests
- I found that AirPlay v1 does support Digest authorization (via an HTTP request header) and wrote a sample implementation that could easily be integrated into the client app
- when I asked OP to run a few quick tests on his receiver, it didn't behave as an AirPlay v1 receiver should do.. and we concluded that it must actually be running AirPlay v2 protocols
- since this client is AirPlay v1 only.. the issue was paused
- if you (or anyone else) has an AirPlay v1 receiver that supports restricting its access only to authorized users (via password/PIN) and would be willing to test a build of this client that includes experimental support for this feature.. then I'd be willing to play around with it some more, until we can get it working
- however, I'm not going to implement any kind of pre-emptive handshake with every server discovered on the network.. to confirm whether it's v1 or v2
- I'll leave that to the users.. to know what devices they own.. and what capabilities they support.. when selecting a receiver that's present on their LAN
and we concluded that it must actually be running AirPlay v2 protocols
I'd rather say we just concluded it's not running AirPlay v1. The lack of documentation from Roku about their devices, the strange behaviour under test and not having any AirPlay v2 software to test it with means I'm not confident to say it's a woking implementation of anything!
Otherwise, great summary, thanks!