HTTPS прокси на netty

Продолжаем знакомиться с 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

You can leave a response, or trackback from your own site.

Leave a Reply