Продолжаем знакомиться с netty. Представим себе такую задачу. Есть компонент системы, которые гоняется в облаке Amazon. Он может принимать HTTPS соединения, и мы хотим этим воспользоваться для шифрования передаваемого трафика. Но другой компонент системы умеет посылать только HTTP запросы и по независящим от нас причинам (к примеру, нет исходников) мы не можем научить его работать с HTTPS. Что тут можно сделать?
Я решил написать на netty своего рода прокси (или mapper), который возьмет на себя шифрование HTTP запросов и расшифровывание HTTPS ответов. Его можно запустить в своей собственной сети и тем самым добиться того, что в публичной сети трафик будет шифрованный.
Необходимая зависимость в POM:
<dependency> <groupId>io.netty</groupId> <artifactId>netty</artifactId> <version>3.6.6.Final</version> </dependency> |
Код класса, в котором запускается main():
package ru.outofrange.sslmapper; import java.net.InetSocketAddress; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import org.jboss.netty.bootstrap.ServerBootstrap; import org.jboss.netty.channel.socket.nio.NioServerSocketChannelFactory; public class HttpsMapper { public static void main(String[] args) { int localPort = 8100; // default value, our application will attempt to run on this port String remoteHost = ""; int remotePort = 443; // default port for HTTPS HttpHost destHost = null; if (args.length >= 3) { localPort = Integer.parseInt(args[0]); // our JAR will listen on this port remoteHost = args[1]; // our JAR will send requests there remotePort = Integer.parseInt(args[2]); } else { System.out.println("Expecting three command line arguments: 1.listen port 2. Destination host 3. Destination port"); // exit early System.exit(1); } System.out.println("Listening on port " + localPort); System.out.println("Getting data from [" + remoteHost + "]:[" + remotePort +"]"); destHost = new HttpHost(remoteHost, remotePort); Executor threadPool = Executors.newCachedThreadPool(); ServerBootstrap bootstrap = new ServerBootstrap(new NioServerSocketChannelFactory(threadPool, threadPool)); // creating a pipelineFactory bootstrap.setPipelineFactory(new ProxyServerPipelineFactory(threadPool, destHost)); bootstrap.bind(new InetSocketAddress(localPort)); } static HttpHost getUpstreamProxy() { // I assume it's needed for a proxy chain, I don't need to implement it return null; } } |
Класс, который обслуживает http запросы от клиента:
package ru.outofrange.sslmapper; import java.util.concurrent.Executor; import org.jboss.netty.channel.Channel; import org.jboss.netty.channel.Channels; import org.jboss.netty.channel.ChannelPipeline; import org.jboss.netty.channel.ChannelPipelineFactory; import org.jboss.netty.handler.codec.http.HttpRequestDecoder; import org.jboss.netty.handler.codec.http.HttpResponseEncoder; import org.jboss.netty.handler.codec.http.HttpChunkAggregator; // this class handles requests from the client public class ProxyServerPipelineFactory implements ChannelPipelineFactory { private Executor threadPool = null; private HttpHost destHost = null; public ProxyServerPipelineFactory(Executor threadPool, HttpHost destHost) { this.threadPool = threadPool; this.destHost = destHost; } @Override public ChannelPipeline getPipeline() throws Exception { ChannelPipeline p = Channels.pipeline(); p.addLast("decoder", new HttpRequestDecoder()); p.addLast("aggregator", new HttpChunkAggregator(1048576)); p.addLast("encoder", new HttpResponseEncoder()); // that's our custom handler p.addLast("handler", new ProxyServerHandler(threadPool, destHost)); return p; } } |
Код хэндлера и необходимые пояснения к нему. При получении http запроса от клиента вызывается метод messageReceived(). В нем создается новый канал (pipeline), в который наряду с типовыми хэндлерами добавлен пользовательский RemoteHostHandler. Получение ответа от хэндлера происходит в вызове writeResponseAndClose(remoteHostHandler.getResponse()); . Собственно, код:
package ru.outofrange.sslmapper; import java.net.*; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicLong; import org.jboss.netty.bootstrap.ClientBootstrap; import org.jboss.netty.buffer.*; import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.channel.ExceptionEvent; import org.jboss.netty.channel.MessageEvent; import org.jboss.netty.channel.SimpleChannelUpstreamHandler; import org.jboss.netty.channel.socket.nio.NioClientSocketChannelFactory; import org.jboss.netty.channel.Channel; import org.jboss.netty.channel.ChannelPipeline; import org.jboss.netty.channel.ChannelFuture; import org.jboss.netty.channel.ChannelFutureListener; import org.jboss.netty.channel.ChannelPipelineCoverage; import org.jboss.netty.handler.codec.http.HttpRequest; import org.jboss.netty.handler.codec.http.HttpResponse; import org.jboss.netty.handler.codec.http.HttpVersion; import org.jboss.netty.handler.codec.http.HttpHeaders; import org.jboss.netty.handler.codec.http.DefaultHttpResponse; import org.jboss.netty.handler.codec.http.DefaultHttpRequest; import org.jboss.netty.handler.codec.http.HttpResponseStatus; import org.jboss.netty.handler.codec.http.HttpResponseDecoder; import org.jboss.netty.handler.codec.http.HttpRequestEncoder; import org.jboss.netty.handler.codec.http.HttpChunkAggregator; import javax.net.ssl.SSLEngine; import org.jboss.netty.handler.ssl.SslHandler; /* Handler of the incoming connections Receives the request, transforms to HTTP 1.1 if needed, establishes an outgoing connection to OS and requests data from the OS. OS's response is unzipped if necessary and is forwarded to the client. There will be a redirect if OS sends response 301 or 302. */ @ChannelPipelineCoverage("one") public class ProxyServerHandler extends SimpleChannelUpstreamHandler { private volatile Channel proxyServerChannel = null; private Executor threadPool = null; private String remoteHost = null; private int remotePort = 80; // default value private RemoteHostHandler remoteHostHandler = null; private static AtomicLong requestCount = new AtomicLong(0); public ProxyServerHandler(Executor threadPool) { this.threadPool = threadPool; } public ProxyServerHandler(Executor threadPool, HttpHost destHost) { this.threadPool = threadPool; this.remoteHost = destHost.getHost(); this.remotePort = destHost.getPort(); } @Override public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { HttpRequest request = (HttpRequest) e.getMessage(); long currRequestValue = requestCount.incrementAndGet(); if (currRequestValue % 100 == 0) { // reporting every 100th request System.out.println("Num Of HTTP Requests: " + currRequestValue); } // keep alive not supported request.removeHeader("Proxy-Connection"); request.setHeader("Connection", "close"); if (request.getProtocolVersion() == HttpVersion.HTTP_1_0) { request = convertRequestTo1_1(request); } // here we can process the request, e.g. detect specific URI and respond using // writeResponseAndClose(ErrorResponse.create("unsupported uri")); and stop the processing ClientBootstrap bootstrap = new ClientBootstrap(new NioClientSocketChannelFactory(threadPool, threadPool)); ChannelPipeline p = bootstrap.getPipeline(); // Add SSL handler first to encrypt and decrypt everything. // In this example, we use a bogus certificate in the server side // and accept any invalid certificates in the client side. // You will need something more complicated to identify both // and server in the real world SSLEngine engine = HttpsMapperSslContextFactory.getClientContext().createSSLEngine(); engine.setUseClientMode(true); // this one provides coding / encoding p.addLast("ssl", new SslHandler(engine)); p.addLast("decoder", new HttpResponseDecoder()); p.addLast("aggregator", new HttpChunkAggregator(1048576)); p.addLast("encoder", new HttpRequestEncoder()); remoteHostHandler = new RemoteHostHandler(request); // depends on the client's request p.addLast("handler", remoteHostHandler); proxyServerChannel = e.getChannel(); HttpHost proxy = HttpsMapper.getUpstreamProxy(); // currently returns null, always InetSocketAddress dest = proxy == null ? new InetSocketAddress(remoteHost, remotePort) : new InetSocketAddress(proxy.getHost(), proxy.getPort()); ChannelFuture connectFuture = bootstrap.connect(dest); connectFuture.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { if (future.isSuccess()) { future.getChannel().getCloseFuture().addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { writeResponseAndClose(remoteHostHandler.getResponse()); // here we get the response from OS and forward it to the client } }); } else { future.getChannel().close(); writeResponseAndClose(createErrorResponse("Could not connect to " + remoteHost + ":" + remotePort)); } } }); } @Override public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { System.out.println("Unexpected exception from proxy server handler: " + e.getCause()); e.getChannel().close(); } // sends the generated response into incoming connection (channel between HttpsMapper and the client) private void writeResponseAndClose(HttpResponse response) { if (response != null) { response.setHeader("Connection", "close"); proxyServerChannel.write(response).addListener(ChannelFutureListener.CLOSE); } else { proxyServerChannel.close(); } } // converts HTTP 1.0 requests into HTTP 1.1 private HttpRequest convertRequestTo1_1(HttpRequest request) throws URISyntaxException { DefaultHttpRequest newReq = new DefaultHttpRequest(HttpVersion.HTTP_1_1, request.getMethod(), request.getUri()); if (!request.getHeaderNames().isEmpty()) { for (String name : request.getHeaderNames()) { newReq.setHeader(name, request.getHeaders(name)); } } if (!newReq.containsHeader(HttpHeaders.Names.HOST)) { URI url = new URI(newReq.getUri()); String host = url.getHost(); if (url.getPort() != -1) { host += ":" + url.getPort(); } newReq.setHeader(HttpHeaders.Names.HOST, host); } newReq.setContent(request.getContent()); return newReq; } // creates an http response with a specified text public static HttpResponse createErrorResponse(String errorText) { HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); response.setHeader(HttpHeaders.Names.CONTENT_TYPE, "text/html; charset=utf-8"); ChannelBuffer buf = ChannelBuffers.copiedBuffer("<html><body><h3>" + errorText + "</h3></body></html>", "utf-8"); response.setContent(buf); response.setHeader("Content-Length", String.valueOf(buf.readableBytes())); return response; } } |
Код хэндлера, который общается с OS (origin server):
package ru.outofrange.sslmapper; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.List; import java.util.zip.GZIPInputStream; import org.jboss.netty.buffer.ChannelBuffer; import org.jboss.netty.buffer.ChannelBuffers; import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.channel.ExceptionEvent; import org.jboss.netty.channel.MessageEvent; import org.jboss.netty.channel.SimpleChannelUpstreamHandler; import org.jboss.netty.channel.ChannelPipelineCoverage; import org.jboss.netty.channel.ChannelStateEvent; import org.jboss.netty.handler.codec.http.HttpRequest; import org.jboss.netty.handler.codec.http.HttpResponse; import org.jboss.netty.handler.codec.http.HttpResponseStatus; import org.jboss.netty.handler.codec.http.HttpHeaders; // this class handles outgoing connections to OS @ChannelPipelineCoverage("one") public class RemoteHostHandler extends SimpleChannelUpstreamHandler { private HttpRequest request = null; private HttpResponse response = null; public RemoteHostHandler(HttpRequest request) { this.request = request; } public HttpResponse getResponse() { return response; } @Override public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { // Connection to OS established, forwarding the client's request into it e.getChannel().write(request); } @Override public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { response = (HttpResponse) e.getMessage(); // Got response from OS, unzipping if necessary List<String> encodings = response.getHeaders(HttpHeaders.Names.CONTENT_ENCODING); if (response.getStatus().equals(HttpResponseStatus.OK) && !encodings.isEmpty() && encodings.contains(HttpHeaders.Values.GZIP)) { ChannelBuffer in = response.getContent(); int compressedLength = in.readableBytes(); GZIPInputStream gzip = new GZIPInputStream( new ByteArrayInputStream(in.toByteBuffer().array(), 0, compressedLength)); ChannelBuffer out = ChannelBuffers.dynamicBuffer(); byte[] buf = new byte[1024]; int read; try { while ((read = gzip.read(buf)) > 0) { out.writeBytes(buf, 0, read); } } catch (IOException ex) {} in.clear(); response.setContent(out); encodings.remove(HttpHeaders.Values.GZIP); if (encodings.isEmpty()) { response.removeHeader(HttpHeaders.Names.CONTENT_ENCODING); } response.setHeader(HttpHeaders.Names.CONTENT_LENGTH, String.valueOf(out.readableBytes())); } // we can process the http response here, say inject some html markup // logging System.err.println(""); System.err.println(request); System.err.println(""); System.err.println(response); System.err.println(""); e.getChannel().close(); } @Override public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { System.out.println("Unexpected exception from remote host handler: " + e.getCause()); response = ProxyServerHandler.createErrorResponse("Error while processing request"); e.getChannel().close(); } } |
Классы HttpsMapperSslContextFactory и HttpsMapperTrustManagerFactory выполняют работу по шифрованию/дешифрованию:
package ru.outofrange.sslmapper; import javax.net.ssl.ManagerFactoryParameters; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactorySpi; import javax.net.ssl.X509TrustManager; import java.security.InvalidAlgorithmParameterException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.cert.X509Certificate; public class HttpsMapperTrustManagerFactory extends TrustManagerFactorySpi { private static final TrustManager DUMMY_TRUST_MANAGER = new X509TrustManager() { public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { // Always trust - it is an example. // You should do something in the real world. // You will reach here only if you enabled client certificate auth, // as described in SecureChatSslContextFactory. // System.err.println( // "UNKNOWN CLIENT CERTIFICATE: " + chain[0].getSubjectDN()); // need to override this method, but will live it empty - we don't use it since the app acts only as SSL client } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) { // Always trust - it is an example. // You should do something in the real world. System.err.println( "Got Server Certificate: " + chain[0].getSubjectDN()); } }; public static TrustManager[] getTrustManagers() { return new TrustManager[] { DUMMY_TRUST_MANAGER }; } @Override protected TrustManager[] engineGetTrustManagers() { return getTrustManagers(); } @Override protected void engineInit(KeyStore keystore) throws KeyStoreException { // Unused } @Override protected void engineInit(ManagerFactoryParameters managerFactoryParameters) throws InvalidAlgorithmParameterException { // Unused } } |
public final class HttpsMapperSslContextFactory { private static final String PROTOCOL = "TLS"; private static final SSLContext CLIENT_CONTEXT; static { SSLContext clientContext; try { clientContext = SSLContext.getInstance(PROTOCOL); clientContext.init(null, HttpsMapperTrustManagerFactory.getTrustManagers(), null); } catch (Exception e) { throw new Error( "Failed to initialize the client-side SSLContext", e); } CLIENT_CONTEXT = clientContext; } public static SSLContext getClientContext() { return CLIENT_CONTEXT; } private HttpsMapperSslContextFactory() { // Unused } } |
Вспомогательный класс для передачи данных:
public class HttpHost { private String host; private int port; public HttpHost(String host, int port) { this.host = host; this.port = port; } public String getHost() { return host; } public int getPort() { return port; } } |
Приложение тестировалось на сайте eff.org (в дополнение к обязательному тестированию на облаке Амазона). Подходящий сайт оказалось найти не так просто, многие присылают в ответ код редиректа.
Пример строки для запуска:
java -jar server-0.1-jar-with-dependencies.jar 9026 eff.org 443 |
В данном случае мы указываем, что приложение должно запуститься на порте 9026 и отображать запросы на него в хост eff.org на порт 443. В случае с eff.org можно было тестировать прямо в браузере.
Приложение показало себя полностью работоспособным при порядочной нагрузке.
Полезные ссылки: http://seeallhearall.blogspot.ru/2012/05/netty-tutorial-part-1-introduction-to.html http://seeallhearall.blogspot.ru/2012/06/netty-tutorial-part-15-on-channel.html