CLOVER🍀

That was when it all began.

NettyのProxyのサンプル実装を、Groovyスクリプトに書き換えてみました

ちょっとしたことでプロキシサーバが必要になりそうで(TCPがプロキシできればよい)、そういえばNettyにプロキシのサンプル実装があったなぁと思い、これをGroovyスクリプトに移植してみました。

Netty Documentation
http://netty.io/wiki/

Package io.netty.example.proxy
http://netty.io/4.0/xref/io/netty/example/proxy/package-summary.html

久々にNettyを使ったコードを書いたというか、Netty 4系になってから直接Nettyのコードを扱うのは初めてだと思います。

ほぼベタベタの移植ですが、コマンドライン引数処理をCliBuilderにしたり、ところどころGroovyっぽくして書き直しました。もともと4つクラス定義されていたのですが、全部分解して1スクリプトにしています。

結果はこちら。
netty_proxy.groovy

@Grab('io.netty:netty-all:4.0.26.Final')
import io.netty.bootstrap.*
import io.netty.buffer.Unpooled
import io.netty.channel.*
import io.netty.channel.nio.NioEventLoopGroup
import io.netty.channel.socket.SocketChannel
import io.netty.channel.socket.nio.NioServerSocketChannel
import io.netty.handler.logging.*

def cli = new CliBuilder(usage: "groovy ${new File(this.class.protectionDomain.codeSource.location.file).name} [options]")

cli.localport(args: 1, argName: 'localport', 'bind local-port')
cli.remotehost(args: 1, required: true, argName: 'remote-host', 'proxy target remote host')
cli.remoteport(args: 1, required: true, argName: 'remote-port', 'proxy target remote port')

def options = cli.parse(args)

if (!options) {
  System.exit(1)
}

def localPort = options.localport ? options.localport as int : 8080
def remoteHost = options.remotehost
def remotePort = options.remoteport as int

def bossGroup = new NioEventLoopGroup()
def workerGroup = new NioEventLoopGroup()

try {
  def serverBootstrap = new ServerBootstrap()

  serverBootstrap
    .group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)
    .handler(new LoggingHandler(LogLevel.INFO))
    .childHandler(hexDumpProxyInitializer(remoteHost, remotePort))
    .childOption(ChannelOption.AUTO_READ, false)
    .bind(localPort).sync().channel().closeFuture().sync();
} finally {
  bossGroup.shutdownGracefully()
  workerGroup.shutdownGracefully()
}

def hexDumpProxyInitializer(remoteHost, remotePort) {
  new ChannelInitializer<SocketChannel>() {
    @Override
    public void initChannel(SocketChannel ch) {
      ch.pipeline().addLast(
        new LoggingHandler(LogLevel.INFO),
        hexDumpProxyFrontendHandler(remoteHost, remotePort))
    }
  }
}

def hexDumpProxyFrontendHandler(remoteHost, remotePort) {
  new ChannelInboundHandlerAdapter() {
    def volatile Channel outboundChannel

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
      def inboundChannel = ctx.channel()

      def bootstrap = new Bootstrap()
      bootstrap
        .group(inboundChannel.eventLoop())
        .channel(ctx.channel().getClass())
        .handler(hexDumpProxyBackendHandler(inboundChannel))
        .option(ChannelOption.AUTO_READ, false)

      def f = bootstrap.connect(remoteHost, remotePort)
      outboundChannel = f.channel()
      f.addListener( { future -> 
        if (future.isSuccess()) {
          // connection complete start to read first data
          inboundChannel.read()
        } else {
          // Close the connection if the connection attempt has failed.
          inboundChannel.close()
        }
      } as ChannelFutureListener)
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
      if (outboundChannel.isActive()) {
        outboundChannel
          .writeAndFlush(msg)
          .addListener( { future ->
            if (future.isSuccess()) {
              // was able to flush out data, start to read the next chunk
              ctx.channel().read()
            } else {
              future.channel().close()
            }
          } as ChannelFutureListener)
      }
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
      if (outboundChannel != null) {
        closeOnFlush(outboundChannel);
      }
    }
 
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
      cause.printStackTrace()
      closeOnFlush(ctx.channel())
    }
  }
}

def hexDumpProxyBackendHandler(inboundChannel) {
  new ChannelInboundHandlerAdapter() {
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
      ctx.read()
      ctx.write(Unpooled.EMPTY_BUFFER)
    }
 
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
      inboundChannel
        .writeAndFlush(msg)
        .addListener( { future ->
          if (future.isSuccess()) {
            ctx.channel().read()
          } else {
            future.channel().close()
          }
        } as ChannelFutureListener)
     }
 
    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
      closeOnFlush(inboundChannel)
    }
 
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
      cause.printStackTrace()
      closeOnFlush(ctx.channel())
    }
  }
}

/**
 * Closes the specified channel after all queued write requests are flushed.
 */
def closeOnFlush(ch) {
  if (ch.isActive()) {
    ch.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE)
  }
}

バインドするローカルポート、プロキシ先のリモートホスト、リモートポートを引数に取ります。ローカルポートのみ任意引数で、デフォルトは8080を使うようにしました。

リモートホスト、リモートポートは必須なので、何も指定しないとusageを出力して終了します。

$ groovy netty_proxy.groovy
error: Missing required options: remotehost, remoteport
usage: groovy netty_proxy.groovy [options]
 -localport <localport>      bind local-port
 -remotehost <remote-host>   proxy target remote host
 -remoteport <remote-port>   proxy target remote port

では、ちょっと使ってみます。

自分のはてなのブログを見てみましょう。

$ groovy netty_proxy.groovy --remotehost d.hatena.ne.jp --remoteport 80
5 02, 2015 9:50:41 午後 io.netty.handler.logging.LoggingHandler channelRegistered
情報: [id: 0xe579c81c] REGISTERED
5 02, 2015 9:50:41 午後 io.netty.handler.logging.LoggingHandler bind
情報: [id: 0xe579c81c] BIND(0.0.0.0/0.0.0.0:8080)
5 02, 2015 9:50:41 午後 io.netty.handler.logging.LoggingHandler channelActive
情報: [id: 0xe579c81c, /0:0:0:0:0:0:0:0:8080] ACTIVE

結果は長いので端折りますが、プロキシできているようです。
※Hostヘッダの指定が必要

$ curl -H 'Host: d.hatena.ne.jp' http://localhost:8080/Kazuhira/
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html xmlns:og="http://ogp.me/ns#">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=euc-jp">
<meta http-equiv="Content-Style-Type" content="text/css">
<meta http-equiv="Content-Script-Type" content="text/javascript">
<title>CLOVER</title>
<link rel="start" href="./" title="CLOVER">
<link rel="help" href="/help" title="&#65533;&#1573;&#65533;&#65533;&#65533;">
<link rel="prev" href="/Kazuhira/?of=5" title="&#65533;&#65533;&#65533;&#65533;5&#65533;&#65533;&#684;">

<link rel="stylesheet" href="http://d.st-hatena.com/statics/css/base.css?845afa966323b83dfb25972c9ad94129f2586c5e" type="text/css" media="all">
<link rel="stylesheet" href="http://d.st-hatena.com/statics/css/headerstyle_bl.css?031d9f2299a3235ea02565e84936ee0c39ee837b" type="text/css" media="all">
<link rel="stylesheet" href="http://d.st-hatena.com/statics/theme/monotone-flower/monotone-flower.css?ce325ac2661691df491af4bf345b96f01406af7f" type="text/css" media="all">


<link rel="alternate" type="application/rss+xml" title="RSS" href="http://d.hatena.ne.jp/Kazuhira/rss">
<link rel="alternate" type="application/rss+xml" title="RSS 2.0" href="http://d.hatena.ne.jp/Kazuhira/rss2">

〜省略〜

この時、起動したプロキシ側にはPipelineにLoggingHandlerが入っているので、こんな感じでダンプされます。

5 02, 2015 9:54:23 午後 io.netty.handler.logging.LoggingHandler logMessage
情報: [id: 0x2c4662e4, /0:0:0:0:0:0:0:0:8080] RECEIVED: [id: 0xc936e659, /127.0.0.1:34328 => /127.0.0.1:8080]
5 02, 2015 9:54:23 午後 io.netty.handler.logging.LoggingHandler channelRegistered
情報: [id: 0xc936e659, /127.0.0.1:34328 => /127.0.0.1:8080] REGISTERED
5 02, 2015 9:54:23 午後 io.netty.handler.logging.LoggingHandler channelActive
情報: [id: 0xc936e659, /127.0.0.1:34328 => /127.0.0.1:8080] ACTIVE
5 02, 2015 9:54:23 午後 io.netty.handler.logging.LoggingHandler logMessage
情報: [id: 0xc936e659, /127.0.0.1:34328 => /127.0.0.1:8080] RECEIVED(87B)
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 47 45 54 20 2f 4b 61 7a 75 68 69 72 61 2f 20 48 |GET /Kazuhira/ H|
|00000010| 54 54 50 2f 31 2e 31 0d 0a 55 73 65 72 2d 41 67 |TTP/1.1..User-Ag|
|00000020| 65 6e 74 3a 20 63 75 72 6c 2f 37 2e 33 35 2e 30 |ent: curl/7.35.0|
|00000030| 0d 0a 41 63 63 65 70 74 3a 20 2a 2f 2a 0d 0a 48 |..Accept: */*..H|
|00000040| 6f 73 74 3a 20 64 2e 68 61 74 65 6e 61 2e 6e 65 |ost: d.hatena.ne|
|00000050| 2e 6a 70 0d 0a 0d 0a                            |.jp....         |
+--------+-------------------------------------------------+----------------+
5 02, 2015 9:54:24 午後 io.netty.handler.logging.LoggingHandler logMessage
情報: [id: 0xc936e659, /127.0.0.1:34328 => /127.0.0.1:8080] WRITE(1024B)
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 54 54 50 2f 31 2e 31 20 32 30 30 20 4f 4b 0d |HTTP/1.1 200 OK.|
|00000010| 0a 44 61 74 65 3a 20 53 61 74 2c 20 30 32 20 4d |.Date: Sat, 02 M|
|00000020| 61 79 20 32 30 31 35 20 31 32 3a 35 34 3a 32 31 |ay 2015 12:54:21|
|00000030| 20 47 4d 54 0d 0a 53 65 72 76 65 72 3a 20 41 70 | GMT..Server: Ap|
|00000040| 61 63 68 65 0d 0a 58 2d 53 65 72 76 65 72 3a 20 |ache..X-Server: |
|00000050| 64 69 61 72 79 62 61 63 6b 65 6e 64 37 39 0d 0a |diarybackend79..|
|00000060| 58 2d 72 75 6e 74 69 6d 65 3a 20 34 39 31 6d 73 |X-runtime: 491ms|

〜省略〜

他には、うちのローカルにはMySQLが入っているので、これをプロキシしてみましょう。

$ groovy netty_proxy.groovy --remotehost localhost --remoteport 3306
5 02, 2015 9:56:05 午後 io.netty.handler.logging.LoggingHandler channelRegistered
情報: [id: 0x1b2d1b0a] REGISTERED
5 02, 2015 9:56:05 午後 io.netty.handler.logging.LoggingHandler bind
情報: [id: 0x1b2d1b0a] BIND(0.0.0.0/0.0.0.0:8080)
5 02, 2015 9:56:05 午後 io.netty.handler.logging.LoggingHandler channelActive
情報: [id: 0x1b2d1b0a, /0:0:0:0:0:0:0:0:8080] ACTIVE

ローカルの3306をターゲットに指定。

mysqlコマンドで、ローカルの8080ポートに対して普通に使えます。
※「localhost」指定はダメ

$ mysql --host=127.0.0.1 --port=8080 -ukazuhira -p
Enter password: 
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 5.6.23 MySQL Community Server (GPL)

Copyright (c) 2000, 2015, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| practice           |
+--------------------+
2 rows in set (0.00 sec)

mysql> 

単に写しただけですが、スクリプトひとつで動かせるので良いかなーと思うのですが。

一応、Gistにも置いています。
https://gist.github.com/kazuhira-r/7e7c514f25c06cd41407