package com.minamp.net; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; import java.security.SecureRandom; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Vector; import com.minamp.bc.tls.DefaultTlsClient; import com.minamp.bc.tls.NameType; import com.minamp.bc.tls.ServerName; import com.minamp.bc.tls.TlsAuthentication; import com.minamp.bc.tls.TlsClientProtocol; import com.minamp.bc.tls.TlsCredentials; import com.minamp.bc.tls.TlsServerCertificate; import com.minamp.bc.tls.TlsSession; import com.minamp.bc.tls.CertificateRequest; import com.minamp.bc.tls.crypto.TlsCrypto; import com.minamp.bc.tls.crypto.impl.bc.BcTlsCrypto; /** * Small HTTP/1.1 client with its own TLS 1.2/1.3 (Bouncy Castle), keep-alive pooling and * session resumption. Android 2.x's built-in SSL can't talk to plex.tv or *.plex.direct, * so every request in the app goes through here. */ public final class Http { public static final String USER_AGENT = "PlexampLite/1.0"; /** Optional HTTP CONNECT proxy, only used when running the networking code on a desktop JVM. */ public static String proxyHost; public static int proxyPort; private static TlsCrypto crypto; private static final Map sessions = new HashMap(); private static final Map> pool = new HashMap>(); private static final long IDLE_MS = 25000; private Http() {} private static synchronized TlsCrypto crypto() { if (crypto == null) crypto = new BcTlsCrypto(new SecureRandom()); return crypto; } // ------------------------------------------------------------------ URL public static final class Url { public final boolean https; public final String host; public final int port; public final String path; // includes query public Url(String s) throws IOException { String lower = s.toLowerCase(Locale.US); int start; if (lower.startsWith("https://")) { https = true; start = 8; } else if (lower.startsWith("http://")) { https = false; start = 7; } else throw new IOException("Bad URL: " + s); int slash = s.indexOf('/', start); String hostPort = slash < 0 ? s.substring(start) : s.substring(start, slash); path = slash < 0 ? "/" : s.substring(slash); int colon = hostPort.lastIndexOf(':'); if (colon > 0 && hostPort.indexOf(']') < colon) { host = hostPort.substring(0, colon); try { port = Integer.parseInt(hostPort.substring(colon + 1)); } catch (NumberFormatException e) { throw new IOException("Bad port in " + s); } } else { host = hostPort; port = https ? 443 : 80; } } String key() { return (https ? "s:" : "p:") + host + ":" + port; } String hostHeader() { boolean dflt = (https && port == 443) || (!https && port == 80); return dflt ? host : host + ":" + port; } } // ------------------------------------------------------------------ connection static final class Conn { final String key; Socket socket; InputStream in; OutputStream out; TlsClientProtocol tls; long idleSince; Conn(String key) { this.key = key; } void close() { try { if (tls != null) tls.close(); } catch (Exception ignored) {} try { if (socket != null) socket.close(); } catch (Exception ignored) {} } } private static Conn take(String key) { synchronized (pool) { List list = pool.get(key); if (list == null) return null; long now = System.currentTimeMillis(); while (!list.isEmpty()) { Conn c = list.remove(list.size() - 1); if (now - c.idleSince < IDLE_MS && !c.socket.isClosed()) return c; c.close(); } return null; } } static void release(Conn c) { c.idleSince = System.currentTimeMillis(); synchronized (pool) { List list = pool.get(c.key); if (list == null) { list = new ArrayList(); pool.put(c.key, list); } if (list.size() >= 4) { list.remove(0).close(); } list.add(c); } } /** Forget resumable TLS sessions (next connection to each host does a full, verified handshake). */ static void forgetSessions() { synchronized (sessions) { sessions.clear(); } evictAll(); } /** Drop all pooled connections (e.g. after the network changed). */ public static void evictAll() { final List doomed = new ArrayList(); synchronized (pool) { for (Iterator> it = pool.values().iterator(); it.hasNext();) doomed.addAll(it.next()); pool.clear(); } if (doomed.isEmpty()) return; // closing TLS sends an encrypted alert: keep that off the (small-stack) caller thread Threads.start(new Runnable() { public void run() { for (int i = 0; i < doomed.size(); i++) doomed.get(i).close(); } }, "http-evict"); } private static Conn connect(final Url u, int timeoutMs) throws IOException { Conn c = new Conn(u.key()); Socket s = new Socket(); boolean ok = false; try { if (proxyHost != null) { s.connect(new InetSocketAddress(proxyHost, proxyPort), timeoutMs); s.setSoTimeout(timeoutMs); OutputStream po = s.getOutputStream(); String req = "CONNECT " + u.host + ":" + u.port + " HTTP/1.1\r\nHost: " + u.host + ":" + u.port + "\r\n\r\n"; po.write(req.getBytes("ISO-8859-1")); po.flush(); InputStream pi = s.getInputStream(); String status = readLine(pi); if (status == null || status.indexOf(" 200") < 0) throw new IOException("Proxy refused: " + status); String line; while ((line = readLine(pi)) != null && line.length() > 0) { /* skip headers */ } } else { s.connect(new InetSocketAddress(u.host, u.port), timeoutMs); s.setSoTimeout(timeoutMs); } s.setTcpNoDelay(true); c.socket = s; if (u.https) { TlsClientProtocol proto = new TlsClientProtocol(s.getInputStream(), s.getOutputStream()); proto.connect(new Client(crypto(), u.host, u.key())); c.tls = proto; c.in = new BufferedInputStream(proto.getInputStream(), 8192); c.out = new BufferedOutputStream(proto.getOutputStream(), 4096); } else { c.in = new BufferedInputStream(s.getInputStream(), 8192); c.out = new BufferedOutputStream(s.getOutputStream(), 4096); } ok = true; return c; } finally { if (!ok) { try { s.close(); } catch (Exception ignored) {} } } } private static final class Client extends DefaultTlsClient { private final String host; private final String sessionKey; private final long started = System.currentTimeMillis(); Client(TlsCrypto crypto, String host, String sessionKey) { super(crypto); this.host = host; this.sessionKey = sessionKey; } @Override protected Vector getSNIServerNames() { if (CertVerifier.isIpLiteral(host)) return null; Vector v = new Vector(); try { v.addElement(new ServerName(NameType.host_name, host.getBytes("US-ASCII"))); } catch (java.io.UnsupportedEncodingException e) { return null; } return v; } /** * TLS 1.2 only: this TLS stack can resume 1.2 sessions but not 1.3 ones, and on a 600 MHz * phone a resumed handshake (no key exchange, no signature checks) is the difference * between ~10 s and well under a second for every new connection. */ @Override protected com.minamp.bc.tls.ProtocolVersion[] getSupportedVersions() { return com.minamp.bc.tls.ProtocolVersion.TLSv12.only(); } @Override public TlsSession getSessionToResume() { synchronized (sessions) { return sessions.get(sessionKey); } } @Override public void notifyHandshakeComplete() throws IOException { super.notifyHandshakeComplete(); boolean resumed = context.getSecurityParametersConnection().isResumedSession(); DiagLog.log("TLS " + (resumed ? "resumed" : "full handshake") + " with " + host + " in " + (System.currentTimeMillis() - started) + "ms"); TlsSession s = context.getSession(); if (s != null && s.isResumable()) { synchronized (sessions) { sessions.put(sessionKey, s); } } } public TlsAuthentication getAuthentication() throws IOException { return new TlsAuthentication() { public void notifyServerCertificate(TlsServerCertificate sc) throws IOException { com.minamp.bc.tls.Certificate cert = sc.getCertificate(); List chain = new ArrayList(); for (int i = 0; i < cert.getLength(); i++) chain.add(cert.getCertificateAt(i).getEncoded()); CertVerifier.verify(chain, host); } public TlsCredentials getClientCredentials(CertificateRequest req) { return null; } }; } } // ------------------------------------------------------------------ request / response public static final class Response { public final int code; public final String status; public final Map headers; // lower-case names public final InputStream body; public final long contentLength; // -1 if unknown public final boolean chunked; Response(int code, String status, Map headers, InputStream body, long len, boolean chunked) { this.code = code; this.status = status; this.headers = headers; this.body = body; this.contentLength = len; this.chunked = chunked; } public String header(String name) { return headers.get(name.toLowerCase(Locale.US)); } public void close() { try { body.close(); } catch (IOException ignored) {} } public byte[] readAll() throws IOException { try { ByteArrayOutputStream bos = new ByteArrayOutputStream(contentLength > 0 && contentLength < 4000000 ? (int) contentLength : 8192); byte[] buf = new byte[8192]; int n; while ((n = body.read(buf)) > 0) bos.write(buf, 0, n); return bos.toByteArray(); } finally { close(); } } public String readString() throws IOException { return new String(readAll(), "UTF-8"); } } public static Response get(String url, Map headers, int timeoutMs) throws IOException { return request("GET", url, headers, null, timeoutMs); } public static Response request(String method, String url, Map headers, byte[] body, int timeoutMs) throws IOException { for (int redirects = 0; ; redirects++) { long t0 = System.currentTimeMillis(); Response r; try { r = requestOnce(method, url, headers, body, timeoutMs); } catch (IOException e) { DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED after " + (System.currentTimeMillis() - t0) + "ms: " + Threads.describe(e)); throw e; } catch (RuntimeException e) { DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED: " + Threads.describe(e)); throw e; } if (!url.contains("/photo/:/transcode")) { // artwork would drown out everything else DiagLog.log(method + " " + DiagLog.redact(url) + " -> " + r.code + " (" + (System.currentTimeMillis() - t0) + "ms)"); } if ((r.code == 301 || r.code == 302 || r.code == 307 || r.code == 308) && redirects < 4 && ("GET".equals(method) || "HEAD".equals(method))) { String loc = r.header("location"); r.close(); if (loc == null) throw new IOException("Redirect without location"); if (loc.startsWith("/")) { Url u = new Url(url); loc = (u.https ? "https://" : "http://") + u.hostHeader() + loc; } url = loc; continue; } return r; } } private static Response requestOnce(String method, String url, Map headers, byte[] body, int timeoutMs) throws IOException { Url u = new Url(url); // A pooled connection may have been closed by the server; retry once on a fresh one. for (int attempt = 0; attempt < 2; attempt++) { Conn c = take(u.key()); boolean pooled = c != null; if (c == null) c = connect(u, timeoutMs); try { c.socket.setSoTimeout(timeoutMs); writeRequest(c, u, method, headers, body); return readResponse(c, method); } catch (IOException e) { c.close(); if (!pooled) throw e; } } throw new IOException("Request failed"); } private static void writeRequest(Conn c, Url u, String method, Map headers, byte[] body) throws IOException { StringBuilder sb = new StringBuilder(512); sb.append(method).append(' ').append(u.path).append(" HTTP/1.1\r\n"); sb.append("Host: ").append(u.hostHeader()).append("\r\n"); boolean hasUa = false; if (headers != null) { for (Map.Entry e : headers.entrySet()) { if (e.getValue() == null) continue; if ("user-agent".equalsIgnoreCase(e.getKey())) hasUa = true; sb.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n"); } } if (!hasUa) sb.append("User-Agent: ").append(USER_AGENT).append("\r\n"); sb.append("Connection: keep-alive\r\n"); if (body != null || "POST".equals(method) || "PUT".equals(method)) { sb.append("Content-Length: ").append(body == null ? 0 : body.length).append("\r\n"); } sb.append("\r\n"); c.out.write(sb.toString().getBytes("UTF-8")); if (body != null) c.out.write(body); c.out.flush(); } private static Response readResponse(Conn c, String method) throws IOException { String statusLine = readLine(c.in); if (statusLine == null) throw new IOException("Connection closed"); // HTTP/1.1 200 OK int sp = statusLine.indexOf(' '); if (sp < 0) throw new IOException("Bad status line"); int sp2 = statusLine.indexOf(' ', sp + 1); int code; try { code = Integer.parseInt(sp2 < 0 ? statusLine.substring(sp + 1).trim() : statusLine.substring(sp + 1, sp2)); } catch (NumberFormatException e) { throw new IOException("Bad status line"); } boolean http10 = statusLine.startsWith("HTTP/1.0"); Map h = new LinkedHashMap(); String line; while ((line = readLine(c.in)) != null && line.length() > 0) { int colon = line.indexOf(':'); if (colon <= 0) continue; String name = line.substring(0, colon).trim().toLowerCase(Locale.US); String val = line.substring(colon + 1).trim(); String prev = h.get(name); h.put(name, prev == null ? val : prev + ", " + val); } String te = h.get("transfer-encoding"); String cl = h.get("content-length"); String conn = h.get("connection"); boolean keepAlive = !http10 && (conn == null || conn.toLowerCase(Locale.US).indexOf("close") < 0); boolean chunked = te != null && te.toLowerCase(Locale.US).indexOf("chunked") >= 0; long len = -1; if (!chunked && cl != null) { try { len = Long.parseLong(cl.trim()); } catch (NumberFormatException ignored) {} } InputStream body; boolean noBody = "HEAD".equals(method) || code == 204 || code == 304 || (code >= 100 && code < 200); if (noBody) { body = new FixedStream(c, 0, keepAlive); len = "HEAD".equals(method) ? len : 0; } else if (chunked) { body = new ChunkedStream(c, keepAlive); } else if (len >= 0) { body = new FixedStream(c, len, keepAlive); } else { body = new EofStream(c); } return new Response(code, statusLine, h, body, len, chunked); } static String readLine(InputStream in) throws IOException { StringBuilder sb = new StringBuilder(64); int b; while ((b = in.read()) >= 0) { if (b == '\n') { int n = sb.length(); if (n > 0 && sb.charAt(n - 1) == '\r') sb.setLength(n - 1); return sb.toString(); } sb.append((char) b); if (sb.length() > 16384) throw new IOException("Header line too long"); } return sb.length() == 0 ? null : sb.toString(); } // ------------------------------------------------------------------ body streams private static final class FixedStream extends InputStream { private final Conn c; private long left; private final boolean keepAlive; private boolean closed; FixedStream(Conn c, long len, boolean keepAlive) { this.c = c; this.left = len; this.keepAlive = keepAlive; if (len == 0) finish(true); } private void finish(boolean complete) { if (closed) return; closed = true; if (complete && keepAlive) release(c); else c.close(); } @Override public int read() throws IOException { byte[] one = new byte[1]; int n = read(one, 0, 1); return n <= 0 ? -1 : one[0] & 255; } @Override public int read(byte[] b, int off, int len) throws IOException { if (left <= 0) { finish(true); return -1; } if (closed) return -1; int n = c.in.read(b, off, (int) Math.min(len, left)); if (n < 0) { finish(false); throw new IOException("Unexpected end of stream"); } left -= n; if (left == 0) finish(true); return n; } @Override public int available() throws IOException { if (closed) return 0; return (int) Math.min(c.in.available(), left); } @Override public void close() { finish(left == 0); } } private static final class ChunkedStream extends InputStream { private final Conn c; private final boolean keepAlive; private long chunkLeft = 0; private boolean eof; private boolean closed; ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; } private void finish(boolean complete) { if (closed) return; closed = true; if (complete && keepAlive) release(c); else c.close(); } private boolean nextChunk() throws IOException { if (eof) return false; String line = readLine(c.in); if (line == null) throw new IOException("Unexpected end of chunked stream"); if (line.length() == 0) { line = readLine(c.in); if (line == null) throw new IOException("Bad chunk"); } int semi = line.indexOf(';'); if (semi >= 0) line = line.substring(0, semi); try { chunkLeft = Long.parseLong(line.trim(), 16); } catch (NumberFormatException e) { throw new IOException("Bad chunk size"); } if (chunkLeft == 0) { String t; while ((t = readLine(c.in)) != null && t.length() > 0) { /* trailers */ } eof = true; finish(true); return false; } return true; } @Override public int read() throws IOException { byte[] one = new byte[1]; int n = read(one, 0, 1); return n <= 0 ? -1 : one[0] & 255; } @Override public int read(byte[] b, int off, int len) throws IOException { if (closed) return -1; if (chunkLeft == 0) { if (!nextChunk()) return -1; } int n = c.in.read(b, off, (int) Math.min(len, chunkLeft)); if (n < 0) { finish(false); throw new IOException("Unexpected end of chunk"); } chunkLeft -= n; if (chunkLeft == 0) { // consume CRLF after chunk data String crlf = readLine(c.in); if (crlf == null) { finish(false); } else if (crlf.length() != 0) { finish(false); throw new IOException("Bad chunk terminator"); } } return n; } @Override public int available() throws IOException { if (closed) return 0; return (int) Math.min(c.in.available(), chunkLeft); } @Override public void close() { finish(eof); } } private static final class EofStream extends InputStream { private final Conn c; private boolean closed; EofStream(Conn c) { this.c = c; } @Override public int read() throws IOException { if (closed) return -1; int b = c.in.read(); if (b < 0) close(); return b; } @Override public int read(byte[] b, int off, int len) throws IOException { if (closed) return -1; int n = c.in.read(b, off, len); if (n < 0) close(); return n; } @Override public void close() { if (!closed) { closed = true; c.close(); } } } /** Close one in-flight response's socket from another thread (used to abort streams). */ public static void abort(Response r) { if (r == null) return; InputStream b = r.body; Conn c = null; if (b instanceof FixedStream) c = ((FixedStream) b).c; else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c; else if (b instanceof EofStream) c = ((EofStream) b).c; if (c != null) { try { c.socket.close(); } catch (Exception ignored) {} } } }