Private
Public Access
Import minamp 1.0 source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0df8124629
194 files changed
+7319
No files matched your search
@@ -0,0 +1,226 @@
|
||||
package com.minamp.net;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import com.minamp.bc.asn1.ASN1Encoding;
|
||||
import com.minamp.bc.asn1.ASN1ObjectIdentifier;
|
||||
import com.minamp.bc.asn1.ASN1OctetString;
|
||||
import com.minamp.bc.asn1.pkcs.PKCSObjectIdentifiers;
|
||||
import com.minamp.bc.asn1.x500.RDN;
|
||||
import com.minamp.bc.asn1.x500.X500Name;
|
||||
import com.minamp.bc.asn1.x500.style.BCStyle;
|
||||
import com.minamp.bc.asn1.x500.style.IETFUtils;
|
||||
import com.minamp.bc.asn1.x509.BasicConstraints;
|
||||
import com.minamp.bc.asn1.x509.Certificate;
|
||||
import com.minamp.bc.asn1.x509.Extension;
|
||||
import com.minamp.bc.asn1.x509.Extensions;
|
||||
import com.minamp.bc.asn1.x509.GeneralName;
|
||||
import com.minamp.bc.asn1.x509.GeneralNames;
|
||||
import com.minamp.bc.asn1.x9.X9ObjectIdentifiers;
|
||||
import com.minamp.bc.crypto.Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA1Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA256Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA384Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA512Digest;
|
||||
import com.minamp.bc.crypto.params.AsymmetricKeyParameter;
|
||||
import com.minamp.bc.crypto.signers.DSADigestSigner;
|
||||
import com.minamp.bc.crypto.signers.ECDSASigner;
|
||||
import com.minamp.bc.crypto.signers.RSADigestSigner;
|
||||
import com.minamp.bc.crypto.util.PublicKeyFactory;
|
||||
|
||||
/**
|
||||
* Minimal Web PKI path validation for the bundled TLS stack:
|
||||
* hostname match, validity window, signature chain up to a bundled Mozilla root.
|
||||
*/
|
||||
public final class CertVerifier {
|
||||
|
||||
/** Lookup of trusted root certificates (DER) by the DER encoding of their subject name. */
|
||||
public interface RootStore {
|
||||
List<byte[]> find(byte[] subjectDer);
|
||||
}
|
||||
|
||||
private static RootStore roots;
|
||||
|
||||
public static void setRootStore(RootStore store) { roots = store; }
|
||||
|
||||
private CertVerifier() {}
|
||||
|
||||
public static void verify(List<byte[]> chainDer, String host) throws IOException {
|
||||
if (chainDer == null || chainDer.isEmpty()) throw new IOException("Server sent no certificate");
|
||||
if (roots == null) throw new IOException("No trust store configured");
|
||||
List<Certificate> chain = new ArrayList<Certificate>();
|
||||
for (int i = 0; i < chainDer.size(); i++) chain.add(Certificate.getInstance(chainDer.get(i)));
|
||||
|
||||
Certificate leaf = chain.get(0);
|
||||
if (!hostMatches(leaf, host)) {
|
||||
throw new IOException("Certificate does not match " + host);
|
||||
}
|
||||
|
||||
Date now = new Date();
|
||||
// An unset clock on an old phone (e.g. 1980 after a battery pull) would make every
|
||||
// certificate look "not yet valid"; only enforce dates when the clock looks sane.
|
||||
boolean clockSane = now.getTime() > 1704067200000L; // 2024-01-01
|
||||
|
||||
Certificate current = leaf;
|
||||
for (int depth = 0; depth < 8; depth++) {
|
||||
if (clockSane) checkDates(current, now);
|
||||
|
||||
byte[] issuerDer = current.getIssuer().getEncoded(ASN1Encoding.DER);
|
||||
|
||||
// 1. Anchored directly by a trusted root?
|
||||
List<byte[]> anchors = roots.find(issuerDer);
|
||||
if (anchors != null) {
|
||||
for (int i = 0; i < anchors.size(); i++) {
|
||||
Certificate root = Certificate.getInstance(anchors.get(i));
|
||||
if (signatureValid(current, root)) return; // trusted
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Otherwise find the issuing intermediate among what the server sent.
|
||||
Certificate next = null;
|
||||
for (int i = 1; i < chain.size(); i++) {
|
||||
Certificate cand = chain.get(i);
|
||||
if (cand == current) continue;
|
||||
if (!java.util.Arrays.equals(cand.getSubject().getEncoded(ASN1Encoding.DER), issuerDer)) continue;
|
||||
if (!isCa(cand)) continue;
|
||||
if (signatureValid(current, cand)) { next = cand; break; }
|
||||
}
|
||||
if (next == null) throw new IOException("Untrusted certificate chain for " + host);
|
||||
current = next;
|
||||
}
|
||||
throw new IOException("Certificate chain too long");
|
||||
}
|
||||
|
||||
private static void checkDates(Certificate c, Date now) throws IOException {
|
||||
Date from = c.getStartDate().getDate();
|
||||
Date to = c.getEndDate().getDate();
|
||||
if (now.before(from) || now.after(to)) {
|
||||
throw new IOException("Certificate expired or not yet valid (check the phone's date)");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCa(Certificate c) {
|
||||
Extensions ext = c.getTBSCertificate().getExtensions();
|
||||
if (ext == null) return c.getTBSCertificate().getVersionNumber() < 3; // v1 roots/intermediates
|
||||
BasicConstraints bc = BasicConstraints.fromExtensions(ext);
|
||||
return bc != null && bc.isCA();
|
||||
}
|
||||
|
||||
static boolean signatureValid(Certificate subject, Certificate issuer) {
|
||||
try {
|
||||
ASN1ObjectIdentifier alg = subject.getSignatureAlgorithm().getAlgorithm();
|
||||
byte[] tbs = subject.getTBSCertificate().getEncoded(ASN1Encoding.DER);
|
||||
byte[] sig = subject.getSignature().getOctets();
|
||||
AsymmetricKeyParameter key = PublicKeyFactory.createKey(issuer.getSubjectPublicKeyInfo());
|
||||
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha256WithRSAEncryption)) return rsa(new SHA256Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha384WithRSAEncryption)) return rsa(new SHA384Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha512WithRSAEncryption)) return rsa(new SHA512Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha1WithRSAEncryption)) return rsa(new SHA1Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA256)) return ecdsa(new SHA256Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA384)) return ecdsa(new SHA384Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA512)) return ecdsa(new SHA512Digest(), key, tbs, sig);
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean rsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
|
||||
RSADigestSigner s = new RSADigestSigner(d);
|
||||
s.init(false, key);
|
||||
s.update(tbs, 0, tbs.length);
|
||||
return s.verifySignature(sig);
|
||||
}
|
||||
|
||||
private static boolean ecdsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
|
||||
DSADigestSigner s = new DSADigestSigner(new ECDSASigner(), d);
|
||||
s.init(false, key);
|
||||
s.update(tbs, 0, tbs.length);
|
||||
return s.verifySignature(sig);
|
||||
}
|
||||
|
||||
// ---- hostname checking ----
|
||||
|
||||
static boolean hostMatches(Certificate leaf, String host) {
|
||||
host = host.toLowerCase(Locale.US);
|
||||
boolean ip = isIpLiteral(host);
|
||||
boolean sawDns = false;
|
||||
Extensions ext = leaf.getTBSCertificate().getExtensions();
|
||||
if (ext != null) {
|
||||
GeneralNames names = GeneralNames.fromExtensions(ext, Extension.subjectAlternativeName);
|
||||
if (names != null) {
|
||||
GeneralName[] arr = names.getNames();
|
||||
for (int i = 0; i < arr.length; i++) {
|
||||
GeneralName n = arr[i];
|
||||
if (n.getTagNo() == GeneralName.dNSName) {
|
||||
sawDns = true;
|
||||
if (!ip && dnsMatch(n.getName().toString(), host)) return true;
|
||||
} else if (n.getTagNo() == GeneralName.iPAddress && ip) {
|
||||
byte[] a = ASN1OctetString.getInstance(n.getName()).getOctets();
|
||||
if (ipString(a).equals(host)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sawDns && !ip) {
|
||||
X500Name subject = X500Name.getInstance(leaf.getSubject());
|
||||
RDN[] cns = subject.getRDNs(BCStyle.CN);
|
||||
for (int i = 0; i < cns.length; i++) {
|
||||
String cn = IETFUtils.valueToString(cns[i].getFirst().getValue());
|
||||
if (dnsMatch(cn, host)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean dnsMatch(String pattern, String host) {
|
||||
pattern = pattern.toLowerCase(Locale.US);
|
||||
if (pattern.endsWith(".")) pattern = pattern.substring(0, pattern.length() - 1);
|
||||
if (host.endsWith(".")) host = host.substring(0, host.length() - 1);
|
||||
if (pattern.startsWith("*.")) {
|
||||
String suffix = pattern.substring(1); // ".example.com"
|
||||
if (!host.endsWith(suffix)) return false;
|
||||
String label = host.substring(0, host.length() - suffix.length());
|
||||
return label.length() > 0 && label.indexOf('.') < 0;
|
||||
}
|
||||
return pattern.equals(host);
|
||||
}
|
||||
|
||||
static boolean isIpLiteral(String host) {
|
||||
if (host.indexOf(':') >= 0) return true;
|
||||
for (int i = 0; i < host.length(); i++) {
|
||||
char c = host.charAt(i);
|
||||
if (!(c == '.' || (c >= '0' && c <= '9'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String ipString(byte[] a) {
|
||||
if (a.length == 4) {
|
||||
return (a[0] & 255) + "." + (a[1] & 255) + "." + (a[2] & 255) + "." + (a[3] & 255);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public static String sha1Hex(byte[] data) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
byte[] h = md.digest(data);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < h.length; i++) {
|
||||
String s = Integer.toHexString(h[i] & 255);
|
||||
if (s.length() == 1) sb.append('0');
|
||||
sb.append(s);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
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 = "Minamp/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<String, TlsSession> sessions = new HashMap<String, TlsSession>();
|
||||
private static final Map<String, List<Conn>> pool = new HashMap<String, List<Conn>>();
|
||||
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<Conn> 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<Conn> list = pool.get(c.key);
|
||||
if (list == null) { list = new ArrayList<Conn>(); pool.put(c.key, list); }
|
||||
if (list.size() >= 4) { list.remove(0).close(); }
|
||||
list.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop all pooled connections (e.g. after the network changed). */
|
||||
public static void evictAll() {
|
||||
synchronized (pool) {
|
||||
for (Iterator<List<Conn>> it = pool.values().iterator(); it.hasNext();) {
|
||||
List<Conn> l = it.next();
|
||||
for (int i = 0; i < l.size(); i++) l.get(i).close();
|
||||
}
|
||||
pool.clear();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TlsSession getSessionToResume() {
|
||||
synchronized (sessions) { return sessions.get(sessionKey); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyHandshakeComplete() throws IOException {
|
||||
super.notifyHandshakeComplete();
|
||||
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<byte[]> chain = new ArrayList<byte[]>();
|
||||
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<String, String> 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<String, String> 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<String, String> headers, int timeoutMs) throws IOException {
|
||||
return request("GET", url, headers, null, timeoutMs);
|
||||
}
|
||||
|
||||
public static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
|
||||
for (int redirects = 0; ; redirects++) {
|
||||
Response r = requestOnce(method, url, headers, body, timeoutMs);
|
||||
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<String, String> 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<String, String> 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<String, String> 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<String, String> h = new LinkedHashMap<String, String>();
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.minamp.net;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Loopback HTTP server that hands the platform MediaPlayer a plain http://127.0.0.1 URL and
|
||||
* fetches the real stream through {@link Http} (our own TLS, plex.direct, relay...).
|
||||
* It also turns chunked transcoder output into a simple read-until-close response, which
|
||||
* Android 2.x's media stack copes with much better.
|
||||
*/
|
||||
public final class StreamProxy implements Runnable {
|
||||
|
||||
private ServerSocket server;
|
||||
private Thread thread;
|
||||
private volatile boolean running;
|
||||
private int counter;
|
||||
private final Map<String, String> routes = new HashMap<String, String>();
|
||||
private final Map<String, List<Http.Response>> live = new HashMap<String, List<Http.Response>>();
|
||||
|
||||
/** Called with how many bytes have arrived for a route (for buffering UI / diagnostics). */
|
||||
public interface Listener { void onError(String routeId, String message); }
|
||||
public volatile Listener listener;
|
||||
|
||||
public synchronized void start() throws IOException {
|
||||
if (running) return;
|
||||
server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"));
|
||||
running = true;
|
||||
thread = new Thread(this, "stream-proxy");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public synchronized void shutdown() {
|
||||
running = false;
|
||||
try { if (server != null) server.close(); } catch (IOException ignored) {}
|
||||
synchronized (routes) {
|
||||
for (String id : new ArrayList<String>(live.keySet())) abortLocked(id);
|
||||
routes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public int port() { return server == null ? -1 : server.getLocalPort(); }
|
||||
|
||||
/** @return the local URL to give MediaPlayer, ending in a fake extension it may sniff. */
|
||||
public String register(String id, String remoteUrl, String ext) {
|
||||
synchronized (routes) {
|
||||
routes.put(id, remoteUrl);
|
||||
}
|
||||
return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext;
|
||||
}
|
||||
|
||||
public String newId() {
|
||||
synchronized (routes) { return Integer.toString(++counter) + "x" + Long.toString(System.currentTimeMillis() % 100000, 36); }
|
||||
}
|
||||
|
||||
/** Forget a route and cut any upstream connections still feeding it. */
|
||||
public void unregister(String id) {
|
||||
if (id == null) return;
|
||||
synchronized (routes) {
|
||||
routes.remove(id);
|
||||
abortLocked(id);
|
||||
}
|
||||
}
|
||||
|
||||
private void abortLocked(String id) {
|
||||
List<Http.Response> l = live.remove(id);
|
||||
if (l == null) return;
|
||||
for (int i = 0; i < l.size(); i++) Http.abort(l.get(i));
|
||||
}
|
||||
|
||||
public void run() {
|
||||
while (running) {
|
||||
final Socket s;
|
||||
try {
|
||||
s = server.accept();
|
||||
} catch (IOException e) {
|
||||
if (!running) return;
|
||||
continue;
|
||||
}
|
||||
Thread t = new Thread("proxy-conn") {
|
||||
public void run() { handle(s); }
|
||||
};
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(Socket s) {
|
||||
Http.Response up = null;
|
||||
String id = null;
|
||||
try {
|
||||
s.setSoTimeout(60000);
|
||||
InputStream in = new BufferedInputStream(s.getInputStream(), 2048);
|
||||
OutputStream out = s.getOutputStream();
|
||||
String reqLine = Http.readLine(in);
|
||||
if (reqLine == null) return;
|
||||
Map<String, String> reqHeaders = new HashMap<String, String>();
|
||||
String line;
|
||||
while ((line = Http.readLine(in)) != null && line.length() > 0) {
|
||||
int c = line.indexOf(':');
|
||||
if (c > 0) reqHeaders.put(line.substring(0, c).trim().toLowerCase(Locale.US), line.substring(c + 1).trim());
|
||||
}
|
||||
String[] parts = reqLine.split(" ");
|
||||
String method = parts.length > 0 ? parts[0] : "GET";
|
||||
String path = parts.length > 1 ? parts[1] : "/";
|
||||
// /s/<id>/stream.mp3
|
||||
if (path.startsWith("/s/")) {
|
||||
int slash = path.indexOf('/', 3);
|
||||
id = slash < 0 ? path.substring(3) : path.substring(3, slash);
|
||||
}
|
||||
String target;
|
||||
synchronized (routes) { target = id == null ? null : routes.get(id); }
|
||||
if (target == null) {
|
||||
out.write("HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes("ISO-8859-1"));
|
||||
out.flush();
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> h = new LinkedHashMap<String, String>();
|
||||
h.put("Accept", "*/*");
|
||||
String range = reqHeaders.get("range");
|
||||
if (range != null) h.put("Range", range);
|
||||
boolean head = "HEAD".equals(method);
|
||||
up = Http.request(head ? "HEAD" : "GET", target, h, null, 30000);
|
||||
synchronized (routes) {
|
||||
if (!routes.containsKey(id)) { Http.abort(up); return; }
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l == null) { l = new ArrayList<Http.Response>(); live.put(id, l); }
|
||||
l.add(up);
|
||||
}
|
||||
|
||||
StringBuilder resp = new StringBuilder(256);
|
||||
boolean known = up.contentLength >= 0 && !up.chunked;
|
||||
resp.append(known ? "HTTP/1.1 " : "HTTP/1.0 ").append(up.code).append(' ').append(reason(up.code)).append("\r\n");
|
||||
String ct = up.header("content-type");
|
||||
if (ct == null || ct.startsWith("application/octet") || ct.startsWith("text/")) ct = guessType(path);
|
||||
if (up.code >= 400) ct = "text/plain";
|
||||
resp.append("Content-Type: ").append(ct).append("\r\n");
|
||||
if (known) resp.append("Content-Length: ").append(up.contentLength).append("\r\n");
|
||||
String cr = up.header("content-range");
|
||||
if (cr != null) resp.append("Content-Range: ").append(cr).append("\r\n");
|
||||
String ar = up.header("accept-ranges");
|
||||
if (ar != null && known) resp.append("Accept-Ranges: ").append(ar).append("\r\n");
|
||||
resp.append("Connection: close\r\n\r\n");
|
||||
out.write(resp.toString().getBytes("ISO-8859-1"));
|
||||
out.flush();
|
||||
if (up.code >= 400) {
|
||||
Listener l = listener;
|
||||
if (l != null) l.onError(id, "Server said " + up.code);
|
||||
}
|
||||
if (head) { up.close(); return; }
|
||||
|
||||
byte[] buf = new byte[16384];
|
||||
int n;
|
||||
while ((n = up.body.read(buf)) > 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
out.flush();
|
||||
up.close();
|
||||
synchronized (routes) {
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l != null) l.remove(up);
|
||||
}
|
||||
up = null;
|
||||
} catch (IOException e) {
|
||||
// player went away, track skipped, or the network dropped: just close
|
||||
} finally {
|
||||
if (up != null) Http.abort(up);
|
||||
if (id != null && up != null) {
|
||||
synchronized (routes) {
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l != null) l.remove(up);
|
||||
}
|
||||
}
|
||||
try { s.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private static String reason(int code) {
|
||||
switch (code) {
|
||||
case 200: return "OK";
|
||||
case 206: return "Partial Content";
|
||||
case 404: return "Not Found";
|
||||
case 416: return "Range Not Satisfiable";
|
||||
default: return code < 400 ? "OK" : "Error";
|
||||
}
|
||||
}
|
||||
|
||||
private static String guessType(String path) {
|
||||
String p = path.toLowerCase(Locale.US);
|
||||
if (p.endsWith(".m4a") || p.endsWith(".mp4")) return "audio/mp4";
|
||||
if (p.endsWith(".ogg")) return "audio/ogg";
|
||||
return "audio/mpeg";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user