package com.koushikdutta.async.http.spdy;
import android.net.Uri;
import android.text.TextUtils;
import com.koushikdutta.async.AsyncSSLSocket;
import com.koushikdutta.async.AsyncSSLSocketWrapper;
import com.koushikdutta.async.AsyncSocket;
import com.koushikdutta.async.ByteBufferList;
import com.koushikdutta.async.DataEmitter;
import com.koushikdutta.async.callback.ConnectCallback;
import com.koushikdutta.async.future.Cancellable;
import com.koushikdutta.async.future.FutureCallback;
import com.koushikdutta.async.future.MultiFuture;
import com.koushikdutta.async.future.SimpleCancellable;
import com.koushikdutta.async.future.TransformFuture;
import com.koushikdutta.async.http.AsyncHttpClient;
import com.koushikdutta.async.http.AsyncHttpRequest;
import com.koushikdutta.async.http.AsyncSSLEngineConfigurator;
import com.koushikdutta.async.http.AsyncSSLSocketMiddleware;
import com.koushikdutta.async.http.Headers;
import com.koushikdutta.async.http.HttpUtil;
import com.koushikdutta.async.http.Multimap;
import com.koushikdutta.async.http.Protocol;
import com.koushikdutta.async.http.body.AsyncHttpRequestBody;
import com.koushikdutta.async.util.Charsets;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
public class SpdyMiddleware extends AsyncSSLSocketMiddleware {
public SpdyMiddleware(AsyncHttpClient client) {
super(client);
addEngineConfigurator(new AsyncSSLEngineConfigurator() {
@Override
public void configureEngine(SSLEngine engine, GetSocketData data, String host, int port) {
configure(engine, data, host, port);
}
});
}
private void configure(SSLEngine engine, GetSocketData data, String host, int port) {
if (!initialized && spdyEnabled) {
initialized = true;
try {
peerHost = engine.getClass().getSuperclass().getDeclaredField("peerHost");
peerPort = engine.getClass().getSuperclass().getDeclaredField("peerPort");
sslParameters = engine.getClass().getDeclaredField("sslParameters");
npnProtocols = sslParameters.getType().getDeclaredField("npnProtocols");
alpnProtocols = sslParameters.getType().getDeclaredField("alpnProtocols");
useSni = sslParameters.getType().getDeclaredField("useSni");
sslNativePointer = engine.getClass().getDeclaredField("sslNativePointer");
String nativeCryptoName = sslParameters.getType().getPackage().getName() + ".NativeCrypto";
nativeGetNpnNegotiatedProtocol = Class.forName(nativeCryptoName, true, sslParameters.getType().getClassLoader())
.getDeclaredMethod("SSL_get_npn_negotiated_protocol", long.class);
nativeGetAlpnNegotiatedProtocol = Class.forName(nativeCryptoName, true, sslParameters.getType().getClassLoader())
.getDeclaredMethod("SSL_get0_alpn_selected", long.class);
peerHost.setAccessible(true);
peerPort.setAccessible(true);
sslParameters.setAccessible(true);
npnProtocols.setAccessible(true);
alpnProtocols.setAccessible(true);
useSni.setAccessible(true);
sslNativePointer.setAccessible(true);
nativeGetNpnNegotiatedProtocol.setAccessible(true);
nativeGetAlpnNegotiatedProtocol.setAccessible(true);
}
catch (Exception e) {
sslParameters = null;
npnProtocols = null;
alpnProtocols = null;
useSni = null;
sslNativePointer = null;
nativeGetNpnNegotiatedProtocol = null;
nativeGetAlpnNegotiatedProtocol = null;
}
}
// TODO: figure out why POST does not work if sending content-length header
// see above regarding app engine comment as to why: drive requires content-length
// but app engine sends a GO_AWAY if it sees a content-length...
if (!canSpdyRequest(data))
return;
if (sslParameters != null) {
try {
byte[] protocols = concatLengthPrefixed(
Protocol.SPDY_3
);
peerHost.set(engine, host);
peerPort.set(engine, port);
Object sslp = sslParameters.get(engine);
// npnProtocols.set(sslp, protocols);
alpnProtocols.set(sslp, protocols);
useSni.set(sslp, true);
}
catch (Exception e ) {
e.printStackTrace();
}
}
}
boolean initialized;
Field peerHost;
Field peerPort;
Field sslParameters;
Field npnProtocols;
Field alpnProtocols;
Field sslNativePointer;
Field useSni;
Method nativeGetNpnNegotiatedProtocol;
Method nativeGetAlpnNegotiatedProtocol;
Hashtable<String, SpdyConnectionWaiter> connections = new Hashtable<String, SpdyConnectionWaiter>();
boolean spdyEnabled;
private static class SpdyConnectionWaiter extends MultiFuture<AsyncSpdyConnection> {
SimpleCancellable originalCancellable = new SimpleCancellable();
}
public boolean getSpdyEnabled() {
return spdyEnabled;
}
public void setSpdyEnabled(boolean enabled) {
spdyEnabled = enabled;
}
@Override
public void setSSLContext(SSLContext sslContext) {
super.setSSLContext(sslContext);
initialized = false;
}
static byte[] concatLengthPrefixed(Protocol... protocols) {
ByteBuffer result = ByteBuffer.allocate(8192);
for (Protocol protocol: protocols) {
if (protocol == Protocol.HTTP_1_0) continue; // No HTTP/1.0 for NPN.
result.put((byte) protocol.toString().length());
result.put(protocol.toString().getBytes(Charsets.UTF_8));
}
result.flip();
byte[] ret = new ByteBufferList(result).getAllByteArray();
return ret;
}
private static String requestPath(Uri uri) {
String pathAndQuery = uri.getEncodedPath();
if (pathAndQuery == null)
pathAndQuery = "/";
else if (!pathAndQuery.startsWith("/"))
pathAndQuery = "/" + pathAndQuery;
if (!TextUtils.isEmpty(uri.getEncodedQuery()))
pathAndQuery += "?" + uri.getEncodedQuery();
return pathAndQuery;
}
private static class NoSpdyException extends Exception {
}
private static final NoSpdyException NO_SPDY = new NoSpdyException();
private void noSpdy(String key) {
SpdyConnectionWaiter conn = connections.remove(key);
if (conn != null)
conn.setComplete(NO_SPDY);
}
private void invokeConnect(String key, final ConnectCallback callback, Exception e, AsyncSSLSocket socket) {
SpdyConnectionWaiter waiter = connections.get(key);
if (waiter == null || waiter.originalCancellable.setComplete())
callback.onConnectCompleted(e, socket);
}
@Override
protected AsyncSSLSocketWrapper.HandshakeCallback createHandshakeCallback(final GetSocketData data, final ConnectCallback callback) {
final String key = data.state.get("spdykey");
if (key == null)
return super.createHandshakeCallback(data, callback);
return new AsyncSSLSocketWrapper.HandshakeCallback() {
@Override
public void onHandshakeCompleted(Exception e, AsyncSSLSocket socket) {
data.request.logv("checking spdy handshake");
if (e != null || nativeGetAlpnNegotiatedProtocol == null) {
invokeConnect(key, callback, e, socket);
noSpdy(key);
return;
}
String protoString;
try {
long ptr = (Long)sslNativePointer.get(socket.getSSLEngine());
byte[] proto = (byte[])nativeGetAlpnNegotiatedProtocol.invoke(null, ptr);
if (proto == null) {
invokeConnect(key, callback, null, socket);
noSpdy(key);
return;
}
protoString = new String(proto);
Protocol p = Protocol.get(protoString);
if (p == null || !p.needsSpdyConnection()) {
invokeConnect(key, callback, null, socket);
noSpdy(key);
return;
}
}
catch (Exception ex) {
throw new AssertionError(ex);
}
final AsyncSpdyConnection connection = new AsyncSpdyConnection(socket, Protocol.get(protoString)) {
boolean hasReceivedSettings;
@Override
public void settings(boolean clearPrevious, Settings settings) {
super.settings(clearPrevious, settings);
if (!hasReceivedSettings) {
hasReceivedSettings = true;
SpdyConnectionWaiter waiter = connections.get(key);
if (waiter.originalCancellable.setComplete()) {
data.request.logv("using new spdy connection for host: " + data.request.getUri().getHost());
newSocket(data, this, callback);
}
waiter.setComplete(this);
}
}
};
try {
connection.sendConnectionPreface();
} catch (IOException e1) {
e1.printStackTrace();
}
}
};
}
private void newSocket(GetSocketData data, final AsyncSpdyConnection connection, final ConnectCallback callback) {
final AsyncHttpRequest request = data.request;
data.protocol = connection.protocol.toString();
final AsyncHttpRequestBody requestBody = data.request.getBody();
// this causes app engine to shit a brick, but if it is missing,
// drive shits the bed
// if (requestBody != null) {
// if (requestBody.length() >= 0) {
// request.getHeaders().set("Content-Length", String.valueOf(requestBody.length()));
// }
// }
final ArrayList<Header> headers = new ArrayList<Header>();
headers.add(new Header(Header.TARGET_METHOD, request.getMethod()));
headers.add(new Header(Header.TARGET_PATH, requestPath(request.getUri())));
String host = request.getHeaders().get("Host");
if (Protocol.SPDY_3 == connection.protocol) {
headers.add(new Header(Header.VERSION, "HTTP/1.1"));
headers.add(new Header(Header.TARGET_HOST, host));
} else if (Protocol.HTTP_2 == connection.protocol) {
headers.add(new Header(Header.TARGET_AUTHORITY, host)); // Optional in HTTP/2
} else {
throw new AssertionError();
}
headers.add(new Header(Header.TARGET_SCHEME, request.getUri().getScheme()));
final Multimap mm = request.getHeaders().getMultiMap();
for (String key: mm.keySet()) {
if (SpdyTransport.isProhibitedHeader(connection.protocol, key))
continue;
for (String value: mm.get(key)) {
headers.add(new Header(key.toLowerCase(Locale.US), value));
}
}
request.logv("\n" + request);
final AsyncSpdyConnection.SpdySocket spdy = connection.newStream(headers, requestBody != null, true);
callback.onConnectCompleted(null, spdy);
}
private boolean canSpdyRequest(GetSocketData data) {
// TODO: figure out why POST does not work if sending content-length header
// see above regarding app engine comment as to why: drive requires content-length
// but app engine sends a GO_AWAY if it sees a content-length...
return data.request.getBody() == null;
}
@Override
protected ConnectCallback wrapCallback(final GetSocketData data, final Uri uri, final int port, final boolean proxied, ConnectCallback callback) {
final ConnectCallback superCallback = super.wrapCallback(data, uri, port, proxied, callback);
final String key = data.state.get("spdykey");
if (key == null)
return superCallback;
// new outgoing connection, try to make this a spdy connection
return new ConnectCallback() {
@Override
public void onConnectCompleted(Exception ex, AsyncSocket socket) {
// an exception here is an ssl or network exception... don't rule spdy out yet, but
// trigger the waiters
if (ex != null) {
final SpdyConnectionWaiter conn = connections.remove(key);
if (conn != null)
conn.setComplete(ex);
}
superCallback.onConnectCompleted(ex, socket);
}
};
}
@Override
public Cancellable getSocket(final GetSocketData data) {
final Uri uri = data.request.getUri();
final int port = getSchemePort(data.request.getUri());
if (port == -1) {
return null;
}
if (!spdyEnabled)
return super.getSocket(data);
// TODO: figure out why POST does not work if sending content-length header
// see above regarding app engine comment as to why: drive requires content-length
// but app engine sends a GO_AWAY if it sees a content-length...
if (!canSpdyRequest(data))
return super.getSocket(data);
// can we use an existing connection to satisfy this, or do we need a new one?
String key = uri.getHost() + port;
SpdyConnectionWaiter conn = connections.get(key);
if (conn != null) {
if (conn.tryGetException() instanceof NoSpdyException)
return super.getSocket(data);
// dead connection check
if (conn.tryGet() != null && !conn.tryGet().socket.isOpen()) {
// old spdy connection is derped, kill it with fire.
connections.remove(key);
conn = null;
}
}
if (conn == null) {
// no connection has ever been attempted (or previous one had a network death), so attempt one
data.state.put("spdykey", key);
// if we got something back synchronously, it's a keep alive socket
Cancellable ret = super.getSocket(data);
if (ret.isDone() || ret.isCancelled())
return ret;
conn = new SpdyConnectionWaiter();
connections.put(key, conn);
return conn.originalCancellable;
}
data.request.logv("waiting for potential spdy connection for host: " + data.request.getUri().getHost());
final SimpleCancellable ret = new SimpleCancellable();
conn.setCallback(new FutureCallback<AsyncSpdyConnection>() {
@Override
public void onCompleted(Exception e, AsyncSpdyConnection conn) {
if (e instanceof NoSpdyException) {
data.request.logv("spdy not available");
ret.setParent(SpdyMiddleware.super.getSocket(data));
return;
}
if (e != null) {
if (ret.setComplete())
data.connectCallback.onConnectCompleted(e, null);
return;
}
data.request.logv("using existing spdy connection for host: " + data.request.getUri().getHost());
if (ret.setComplete())
newSocket(data, conn, data.connectCallback);
}
});
return ret;
}
@Override
public boolean exchangeHeaders(final OnExchangeHeaderData data) {
if (!(data.socket instanceof AsyncSpdyConnection.SpdySocket))
return super.exchangeHeaders(data);
AsyncHttpRequestBody requestBody = data.request.getBody();
if (requestBody != null) {
data.response.sink(data.socket);
}
// headers were already sent as part of the socket being opened.
data.sendHeadersCallback.onCompleted(null);
final AsyncSpdyConnection.SpdySocket spdySocket = (AsyncSpdyConnection.SpdySocket)data.socket;
spdySocket.headers()
.then(new TransformFuture<Headers, List<Header>>() {
@Override
protected void transform(List<Header> result) throws Exception {
Headers headers = new Headers();
for (Header header: result) {
String key = header.name.utf8();
String value = header.value.utf8();
headers.add(key, value);
}
String status = headers.remove(Header.RESPONSE_STATUS.utf8());
String[] statusParts = status.split(" ", 2);
data.response.code(Integer.parseInt(statusParts[0]));
if (statusParts.length == 2)
data.response.message(statusParts[1]);
data.response.protocol(headers.remove(Header.VERSION.utf8()));
data.response.headers(headers);
setComplete(headers);
}
})
.setCallback(new FutureCallback<Headers>() {
@Override
public void onCompleted(Exception e, Headers result) {
data.receiveHeadersCallback.onCompleted(e);
DataEmitter emitter = HttpUtil.getBodyDecoder(spdySocket, spdySocket.getConnection().protocol, result, false);
data.response.emitter(emitter);
}
});
return true;
}
@Override
public void onRequestSent(OnRequestSentData data) {
if (!(data.socket instanceof AsyncSpdyConnection.SpdySocket))
return;
if (data.request.getBody() != null)
data.response.sink().end();
}
}