CLOVER🍀

That was when it all began.

JettyでEmbeddedなプロキシサーバーを書く

ちょっと、プロキシサーバーを簡単に書けないかなぁと思いまして。できれば、Embeddedaleで。

と調べていると、どうもJettyができそうな感じなので試してみました。

Jetty - Servlet Engine and Http Server

JettyにProxyServletなるものがあるらしいので、こちらを使用してみることにします。

Proxy Servlet

Embededd向けドキュメント。

Embedding Jetty

Chapter 21. Embedding

何気に、初Jettyです。

ドキュメントが上記だけだとちょっと心許ないので、ソースコードも入手。

Eclipse Git repositories

org.eclipse.jetty.project.git - Jetty: primary project repository

今回は、こちらからwget

$ wget http://git.eclipse.org/c/jetty/org.eclipse.jetty.project.git/snapshot/jetty-9.3.6.v20151106.tar.gz

準備

Maven依存関係は、以下のように宣言。

        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-proxy</artifactId>
            <version>9.3.6.v20151106</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-deploy</artifactId>
            <version>9.3.6.v20151106</version>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>3.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>1.7.13</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.13</version>
        </dependency>

SLF4Jは、ロギング用です。

ProxyServletを書いて試す

それでは、
src/main/java/org/littlewings/proxy/jetty/MyProxyServlet.java

package org.littlewings.proxy.jetty;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.client.api.Result;
import org.eclipse.jetty.proxy.ProxyServlet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyProxyServlet extends ProxyServlet {
    protected Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    protected void service(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException {
        // 何か処理
        super.service(request, response);
    }

    @Override
    protected HttpClient newHttpClient() {
        // HTTPクライアントをカスタマイズする場合は、ここで
        return new HttpClient();
    }

    @Override
    protected String rewriteTarget(HttpServletRequest clientRequest) {
        // アクセスパスなどをRewriteする場合は、ここで
        return super.rewriteTarget(clientRequest);
    }

    @Override
    protected Response.Listener newProxyResponseListener(HttpServletRequest request, HttpServletResponse response) {
        // レスポンス受信時の挙動をカスタマイズする場合は、ここで
        return new MyProxyResponseListener(LocalDateTime.now(), request, response);
    }

    class MyProxyResponseListener extends ProxyResponseListener {
        LocalDateTime now;

        MyProxyResponseListener(LocalDateTime now, HttpServletRequest request, HttpServletResponse response) {
            super(request, response);
            this.now = now;
        }

        @Override
        public void onComplete(Result result) {
            // 完了時の処理
            super.onComplete(result);

            Request req = result.getRequest();
            Response res = result.getResponse();

            logger.info("[{}] {}://{} \"{} {} {}\" {} \"{}\"",
                    now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
                    req.getScheme(),
                    req.getHost(),
                    req.getMethod(),
                    req.getPath() + (req.getQuery() == null ? "" : "?" + req.getQuery()),
                    req.getVersion(),
                    res.getStatus(),
                    req.getHeaders().get("User-Agent"));
        }
    }
}

けっこういろんな箇所で、カスタマイズができそうな感じですね。書いていることはソースコードのまんまですが、今回はリクエスト完了時にアクセスログ的なものを出力してみました。

その他は、ProxyServletのソースコードを直接確認してみるとよいでしょう。幸い、パッケージも大きくありませんし。

それでは、このプロキシサーバーを使ってHTTPアクセスしてみます。
src/main/java/org/littlewings/proxy/jetty/SimpleHttpProxy.java

package org.littlewings.proxy.jetty;

import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;

import org.eclipse.jetty.proxy.ConnectHandler;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.Slf4jRequestLog;
import org.eclipse.jetty.servlet.ServletContextHandler;

public class SimpleHttpProxy {
    public static void main(String... args) throws Exception {
        int proxyPort = 8080;

        Server server = new Server(proxyPort);
        server.setRequestLog(new Slf4jRequestLog());

        ServletContextHandler contextHandler = new ServletContextHandler();
        contextHandler.addServlet(MyProxyServlet.class, "/");
        server.setHandler(contextHandler);

        server.start();
        // server.join();

        Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyPort));

        String hatenaUrl = "http://d.hatena.ne.jp/Kazuhira/";
        HttpURLConnection hatenaConnection = (HttpURLConnection) new URL(hatenaUrl).openConnection(proxy);

        try {
            System.out.println(hatenaUrl + " = " + hatenaConnection.getResponseCode());
        } finally {
            hatenaConnection.disconnect();
        }

        server.stop();
    }
}

Jettyに作成したProxyServletを設定して、それをProxyとしてURLConnectionのオープンに利用しています。

仕込んだログが出力した内容は、こんな感じ。

[main] INFO org.littlewings.proxy.jetty.MyProxyServlet - [2015-12-30 20:50:15] http://d.hatena.ne.jp "GET /Kazuhira/ HTTP/1.1" 200 "Java/1.8.0_66"

とはいえ、このくらいだとSlf4jRequestLogを仕込んでいるので

        server.setRequestLog(new Slf4jRequestLog());

あまり意味がなかったりしますが。

[main] INFO org.eclipse.jetty.server.RequestLog - 127.0.0.1 - - [30/12/2015:11:50:15 +0000] "GET http://d.hatena.ne.jp/Kazuhira/ HTTP/1.1" 200 53518

Configuring Jetty Request Logs

CONNECTメソッドを扱う

ProxyServletを使った場合、HTTPのプロキシはできますが、HTTPSのプロキシはできません。この場合、ConnectHandlerを使うみたいです。
src/main/java/org/littlewings/proxy/jetty/MyConnectHandler.java

package org.littlewings.proxy.jetty;

import java.util.concurrent.ConcurrentMap;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.eclipse.jetty.io.EndPoint;
import org.eclipse.jetty.proxy.ConnectHandler;
import org.eclipse.jetty.server.Request;

public class MyConnectHandler extends ConnectHandler {
    @Override
    protected void handleConnect(Request baseRequest, HttpServletRequest request, HttpServletResponse response, String serverAddress) {
        // 何か処理
        super.handleConnect(baseRequest, request, response, serverAddress);
    }
}

とはいえ、CONNECTの時は中身に干渉できないから、別に拡張する必要が…?

利用する時は、こんな感じ。
src/main/java/org/littlewings/proxy/jetty/SimpleSslProxy.java

package org.littlewings.proxy.jetty;

import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;

import org.eclipse.jetty.proxy.ConnectHandler;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.Slf4jRequestLog;
import org.eclipse.jetty.servlet.ServletContextHandler;

public class SimpleSslProxy {
    public static void main(String... args) throws Exception {
        int proxyPort = 8080;

        Server server = new Server(proxyPort);
        server.setRequestLog(new Slf4jRequestLog());

        ConnectHandler connectHandler = new MyConnectHandler();
        server.setHandler(connectHandler);

        server.start();
        // server.join();

        Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyPort));

        String googleUrl = "https://www.google.co.jp/?gws_rd=ssl";
        HttpURLConnection googleConnection = (HttpURLConnection) new URL(googleUrl).openConnection(proxy);

        try {
            System.out.println(googleUrl + " = " + googleConnection.getResponseCode());
        } finally {
            googleConnection.disconnect();
        }

        server.stop();
    }
}

合わせ技+α

ProxyServletとConnectHandlerを、合わせて使うことも可能です。
src/main/java/org/littlewings/proxy/jetty/App.java

package org.littlewings.proxy.jetty;

import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;

import org.eclipse.jetty.proxy.ConnectHandler;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;

public class App {
    public static void main(String... args) throws Exception {
        int proxyPort = 8080;

        Server server = new Server(proxyPort);
        MySlf4jRequestLog requestLog = new MySlf4jRequestLog();
        requestLog.setExtended(true);
        server.setRequestLog(requestLog);

        ConnectHandler connectHandler = new MyConnectHandler();
        server.setHandler(connectHandler);

        ServletContextHandler contextHandler = new ServletContextHandler(connectHandler, "/");
        contextHandler.addServlet(MyProxyServlet.class, "/");

        server.start();
        // server.join();

        Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyPort));

        String hatenaUrl = "http://d.hatena.ne.jp/Kazuhira/";
        HttpURLConnection hatenaConnection = (HttpURLConnection) new URL(hatenaUrl).openConnection(proxy);

        try {
            System.out.println(hatenaUrl + " = " + hatenaConnection.getResponseCode());
        } finally {
            hatenaConnection.disconnect();
        }

        try {
            System.out.println(hatenaUrl + " = " + hatenaConnection.getResponseCode());
        } finally {
            hatenaConnection.disconnect();
        }

        String googleUrl = "https://www.google.co.jp/?gws_rd=ssl";
        HttpURLConnection googleConnection = (HttpURLConnection) new URL(googleUrl).openConnection(proxy);

        try {
            System.out.println(googleUrl + " = " + googleConnection.getResponseCode());
        } finally {
            googleConnection.disconnect();
        }

        server.stop();
    }
}

ConnectHandlerの上に、ProxyServletを乗せればいい、と。

あと、このサンプルではRequestLogも拡張しています。

        MySlf4jRequestLog requestLog = new MySlf4jRequestLog();
        requestLog.setExtended(true);
        server.setRequestLog(requestLog);

extendedを有効にして、logExtendedメソッドをオーバーライド。
src/main/java/org/littlewings/proxy/jetty/MySlf4jRequestLog.java

package org.littlewings.proxy.jetty;

import java.io.IOException;
import java.util.Collections;
import java.util.StringJoiner;

import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Slf4jRequestLog;

public class MySlf4jRequestLog extends Slf4jRequestLog {
    protected void logExtended(Request request,
                               StringBuilder b) throws IOException {
        super.logExtended(request, b);

        b.append(" ");
        b.append("[");
        b.append(String.join(", ", Collections.list(request.getHeaderNames())));
        b.append("]");
    }
}

あまり意味はありませんが、HTTPヘッダ名を全部出力してみました。

こんな感じのアクセスログになります。

[qtp205797316-25] INFO org.eclipse.jetty.server.RequestLog - 127.0.0.1 - - [30/12/2015:12:05:47 +0000] "GET http://d.hatena.ne.jp/Kazuhira/ HTTP/1.1" 200 71551 "-" "Java/1.8.0_66" [User-Agent, Host, Accept, Proxy-Connection]

以上でしたー。