Android-AirPlay-Client icon indicating copy to clipboard operation
Android-AirPlay-Client copied to clipboard

Display server-info to aid debugging server problems (was: all commands failed for me with HTTP 403 status)

Open mjray opened this issue 2 years ago • 12 comments

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.

mjray avatar Jul 26 '23 12:07 mjray

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;
            }
          }
      
        }
      

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.

warren-bank avatar Jul 26 '23 22:07 warren-bank

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 $?

warren-bank avatar Jul 27 '23 00:07 warren-bank

regarding something you said..

your AirPlay receiver is returning a 403 Forbidden status, rather than a 401 Unauthorized?

..that's problematic

warren-bank avatar Jul 27 '23 01:07 warren-bank

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.obj passes references to both:
    • File
    • Context
  • client.playVideo adds parameter
    • Context
  • 401 status
    • show dialog to ask user password for selected AirPlay receiver
    • resend /play request with properly formed Authorization: Digest header

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

warren-bank avatar Jul 27 '23 02:07 warren-bank

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.

mjray avatar Jul 31 '23 17:07 mjray

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

mjray avatar Aug 03 '23 23:08 mjray

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

warren-bank avatar Aug 04 '23 03:08 warren-bank

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.

warren-bank avatar Aug 04 '23 07:08 warren-bank

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.

mjray avatar Aug 19 '23 00:08 mjray

Does this resolved or any other alternative for this ?

knishant362 avatar Oct 24 '23 05:10 knishant362

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

warren-bank avatar Nov 10 '23 20:11 warren-bank

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!

mjray avatar Nov 11 '23 00:11 mjray