ズンドコキヨシ with OpenFlow - RubyとTremaとOpen vSwitchでズンドコキヨシ変換プロキシを実装してみた

[ZUNDOKOプロトコル](http://qiita.com/kaitoy/items/17bd2d48ffd8120a3769)に触発され、このビッグウェーブにのるしかないと思いたち、OpenFlowでズンドコキヨシ変換プロキシを作成しました。

最近流行っているズンドコキヨシは下記のツイートが発端となって様々な言語で実装され、[ズンドコキヨシまとめ](http://qiita.com/shunsugai@github/items/971a15461de29563bf90)にまとめられています。プログラミング系だけと思っていたところ、ついにネットワークでも[ズンドコキヨシ with Pcap4J - ZUNDOKOプロトコルを実装してみた](http://qiita.com/kaitoy/items/17bd2d48ffd8120a3769)が登場しました。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Javaの講義、試験が「自作関数を作り記述しなさい」って問題だったから<br>「ズン」「ドコ」のいずれかをランダムで出力し続けて「ズン」「ズン」「ズン」「ズン」「ドコ」の配列が出たら「キ・ヨ・シ！」って出力した後終了って関数作ったら満点で単位貰ってた</p>&mdash; てくも (@kumiromilk) <a href="https://twitter.com/kumiromilk/status/707437861881180160">2016年3月9日</a></blockquote>

ネットワーク系のスキルを活かし、最近勉強し始めたOpenFlowのスキル向上のために、OpenFlowのコントローラであるTremaで変換プロキシを試行錯誤しながら実装して、このビックウェーブにのってみました。


# ズンドコキヨシ変換プロキシ
TCP通信のストリームに文字コードUTF-8で「ズンズンズンズンズンドコ」を見つけると、「ズンズンズンズンズンドコ  キ・ヨ・シ！」に変換（以下、ズンドコキヨシ変換）します。

今回はECHOプロトコル（RFC 862）を利用して、ズンドコキヨシ変換プロキシの動作を確認しました。

下図のように「ECHOクライアント」から「ECHOサーバ」のTCP通信を、OpenFlowで「ZUNDOKO TCPプロキシ」へ捻じ曲げます。「ZUNDOKO TCPプロキシ」では代理で「ECHOサーバ」へTCP通信し、受信したデータをズンドコキヨシ変換し、「ECHOクライアント」に送信します。

図. ズンドコキヨシ変換のTCP通信の流れ
![概要.PNG](https://qiita-image-store.s3.amazonaws.com/0/12026/0ff439fd-2781-6cd1-c1de-4dfb442f9a79.png)


## 実装
下図のように「OpenFlowスイッチ」で宛先アドレスポート変換（宛先NATP変換）して、宛先を「TCPプロキシ」に捻じ曲げます。
「OpenFlowコントローラ」に「TCP SYN」がPACKET_INすると、宛先NAPT変換のためにFLOW_MODで順方向のフローと逆方向のフローの登録をし、元のパケットをPACKET_OUTしています。フローの登録後は、すべて「OpenFlowスイッチ」で完結するため、PACKET_INは発生しません。

図. 実装した機能の通信概要
![構成.PNG](https://qiita-image-store.s3.amazonaws.com/0/12026/83f80051-1934-f348-4a44-b337351bbdb5.png)

### 宛先NAPTの問題点
普通に宛先NAPT変換してしまうと、元の宛先が失われてしまいTCPプロキシで宛先がわからなくなってしまいます。
HTTPプロキシでは、宛先情報がHTTPリクエストヘッダ内にあるため、リクエストヘッダを解析することで、宛先を特定できます。しかしながら、汎用的なTCPプロキシでは、解析するためのヘッダ情報が無いため、宛先を特定できません。

### 宛先NAPTの解決策
上記の問題の解決のために「宛先キャッシュ」を実装します。宛先NAPT変換前の宛先情報を「宛先キャッシュ」に登録し、TCPプロキシで参照し宛先を特定します。
「OpenFlowコントローラ」では、変換前の宛先IPアドレスとポート番号がわかります。「宛先キャッシュ」に「送信元IPアドレスとポート番号」をキーに、「宛先IPアドレスとポート番号」を登録します。
TCPプロキシは「宛先キャッシュ」を参照して、宛先を特定して汎用的なTCPプロキシとして、対象のサーバにアクセスします。

## 環境
主に使用したソフトウェアやライブラリは下記のとおりです。

- OpenFlow 1.0
  - コントローラ: [Trema](https://github.com/trema/trema) 0.10.1
  - スイッチ: [Open vSwitch](http://openvswitch.org/) 2.0.2
- Ubuntu 14.04
  - Memcached 1.4.14：宛先キャッシュ
  - xinetd：echoサーバ
- Ruby 2.2.4
  - trema 0.10.1 : OpenFlowコントローラ
  - memcache-client 1.8.5 : 宛先キャッシュ アクセス用

VirtulBox上に動作する4台のUbuntu端末で環境を構築しました。内部系と管理系でネットワークを分けています。

- u01ホスト
  - ECHOクライアント：zecho_client.rbで実装
- u02ホスト
  - ECHOサーバ：xinetdでTCP ECHOサーバを動作
- u03ホスト
  - ズンドコキヨシ変換プロキシ：zecho_proxy.rbで実装
- ovs1ホスト
  - OpenFlowコントローラ（Trema）：zecho_trema.rbで実装
  - OpenFlowスイッチ（Open vSwitch）：OVSでu01ホストと内部ネットワークをL2ブリッジ
  - 宛先キャッシュ（memcachedサーバ）

図. ホストの構成とデータ通信と制御通信
![環境.PNG](https://qiita-image-store.s3.amazonaws.com/0/12026/0a2f7335-449e-c957-dce3-1beecaaf989a.png)

## ovs1ホスト
ovs1ホストでは、下記のようにOpenFlowスイッチの設定します。OVSのスイッチとしてofs0を定義し、ポートと物理ポートを加えます。ローカルホストのOpenFlowのコントローラを指定しています。

```
sudo ovs-vsctl init
sudo ovs-vsctl add-br ofs0
sudo ifconfig eth1 up
sudo ifconfig eth2 up

sudo ovs-vsctl add-port ofs0 eth1
sudo ovs-vsctl add-port ofs0 eth2

sudo ovs-vsctl set bridge ofs0 protocols=OpenFlow10
sudo ovs-vsctl set-controller ofs0 tcp:127.0.0.1:6653
```

設定結果は下記のとおりとなりました。

```
$ sudo ovs-vsctl show
ce3126fd-82dd-4f67-abdd-a19aa23ef05e
    Bridge "ofs0"
        Controller "tcp:127.0.0.1:6653"
            is_connected: true
        Port "eth2"
            Interface "eth2"
        Port "ofs0"
            Interface "ofs0"
                type: internal
        Port "eth1"
            Interface "eth1"
    ovs_version: "2.0.2"
```

## ソースコード

### ECHOクライアント

TCP ECHOポート（TCP7番）にランダム生成した「ズン」と「ドコ」の文字列を送信し、受信結果を表示するシンプルなスクリプトです。

```rb:zecho_client.rb
require 'socket'
socket = TCPSocket.open('172.16.0.2', 7)
words = %w(ズン ドコ)
10.times do
  string = Array.new(5) { words.sample }.join
  puts "SEND: #{string}"
  socket.puts string
  puts "RECV: #{socket.gets}"
  puts
end
```

### OpenFlowコントローラ

switch_readyメソッド:
今回はu01ホストから送信されたパケットのみを処理対象とするため、u01以外のポートからの通信とブロードキャスト通信用の通信フローを設定しています。

packet_inメソッド:
u01ホストから受信したパケットを、NAPT変換対象かどうかsend_nat_packet_and_add_nat_flowで振り分けて、NAPT変換対象外だった場合は、通常通りPACKET_OUTします。

send_nat_packet_and_add_nat_flowメソッド:
NAPT変換対象のTCPパケットだった場合には、NAPT変換用の順方向フロート逆方向フローをOpenFlowスイッチに設定します。併せて「宛先キャッシュ」に宛先情報を設定します。その後PACKET_OUTします。

```rb:zecho_trema.rb
require 'memcache'

MEMCACHE = MemCache.new '192.168.88.4:11211'

PROXY_L1_PORT  = 2
PROXY_MAC      = '08:00:27:b6:63:f2'.freeze
PROXY_IP       = '172.16.0.3/32'.freeze
PROXY_TCP_PORT = 10000

INTERNAL_PORT = 1
EXTERNAL_PORT = 2
IP_PROTO_TCP = 6

# ZEcho Trema
class ZechoTrema < Trema::Controller
  def start(_args)
    logger.info "#{name} started."
  end

  def switch_ready(datapath_id)
    logger.info "switch_ready: #{datapath_id}"
    send_flow_mod_add(
      datapath_id,
      match: Match.new(in_port: EXTERNAL_PORT),
      actions: SendOutPort.new(INTERNAL_PORT)
    )
    send_flow_mod_add(
      datapath_id,
      match: Match.new(destination_mac_address: 'FF:FF:FF:FF:FF:FF'),
      actions: SendOutPort.new(:flood)
    )
  end

  def switch_disconnected(datapath_id)
    logger.info "switch_disconnected: #{datapath_id}"
  end

  def packet_in(datapath_id, message)
    packet_send = send_nat_packet_and_add_nat_flow(datapath_id, message)
    unless packet_send
      send_packet_out(
        datapath_id,
        packet_in: message,
        actions: SendOutPort.new(EXTERNAL_PORT)
      )
    end
  end

  private

  def send_nat_packet_and_add_nat_flow(datapath_id, message)
    return false unless message.in_port == INTERNAL_PORT
    return false unless Pio::Parser::IPv4Packet == message.data.class
    return false unless message.ip_protocol == IP_PROTO_TCP

    src_mac  = message.source_mac
    src_ip   = message.source_ip_address
    src_port = message.transport_source_port

    dst_mac  = message.destination_mac
    dst_ip   = message.destination_ip_address
    dst_port = message.transport_destination_port

    MEMCACHE.set "#{src_ip}:#{src_port}", "#{dst_ip}:#{dst_port}"
    logger.info "NAT #{src_ip}:#{src_port} => #{dst_ip}:#{dst_port} via #{PROXY_IP}:#{PROXY_TCP_PORT}"

    # forward rule
    forward_match = ExactMatch.new(message)
    forward_actions = [
      Pio::OpenFlow10::SetDestinationMacAddress.new(PROXY_MAC),
      Pio::OpenFlow10::SetDestinationIpAddress.new(PROXY_IP),
      Pio::OpenFlow10::SetTransportDestinationPort.new(PROXY_TCP_PORT),
      SendOutPort.new(PROXY_L1_PORT)
    ]
    send_flow_mod_add(
      datapath_id,
      priority: 1000,
      idle_timeout: 300,
      match: forward_match,
      actions: forward_actions
    )

    # reverse rule
    reverse_match = ExactMatch.new(message)
    reverse_match.in_port                    = PROXY_L1_PORT
    reverse_match.source_mac_address         = PROXY_MAC
    reverse_match.destination_mac_address    = src_mac
    reverse_match.source_ip_address          = PROXY_IP
    reverse_match.destination_ip_address     = src_ip
    reverse_match.transport_source_port      = PROXY_TCP_PORT
    reverse_match.transport_destination_port = src_port
    reverse_actions = [
      Pio::OpenFlow10::SetSourceMacAddress.new(dst_mac),
      Pio::OpenFlow10::SetSourceIpAddress.new(dst_ip),
      Pio::OpenFlow10::SetTransportSourcePort.new(dst_port),
      SendOutPort.new(message.in_port)
    ]
    send_flow_mod_add(
      datapath_id,
      priority: 1000,
      idle_timeout: 300,
      match: reverse_match,
      actions: reverse_actions
    )

    send_packet_out(
      datapath_id,
      packet_in: message,
      actions: forward_actions
    )

    return true
  rescue NotImplementedError => e
    logger.error "#{e}: #{message.data}"
  end

  false
end
```

### ズンドコキヨシ変換プロキシ

プロキシポートでlistenするZechoProxyServerクラスと、接続要求があるたびに生成されるZechoProxyClientクラスの2つで構成されています。

ZechoProxyServerクラス:
PROXY_TCP_PORTでlistenし、クライアントからの接続要求があると、ZechoProxyClientクラスのインスタンスを生成する単純な実装です。

ZechoProxyClientクラス:
送信元のIPアドレスとポート番号を元に「宛先キャッシュ」を参照し、接続先のIPアドレスとポート番号を取得します。その後、接続先にTCPSocket.newで接続します。
送信用と受信用のスレッドを生成し、サーバから受信したデータをzundokoメソッドに渡し、ズンドコキヨシ変換し、クライアントに送信します。


```rb:zecho_proxy.rb
require 'socket'
require 'memcache'

PROXY_TCP_PORT = 10_000
MEMCACHE = MemCache.new '192.168.88.4:11211'
ZUNDOKO_REGEXP = /((ズン\s*){4}ドコ)/

IPPort = Struct.new(:ip, :port)
Thread.abort_on_exception = true

# ZEchoProxy
class ZechoProxyClient
  def initialize(server_socket)
    @threads = []
    @server_socket = server_socket
    @client_socket, @src, @dst = lookup_nat_table(server_socket)
  end

  def run
    puts "#{self}: start proxy client"
    return unless @client_socket

    @threads << Thread.new do
      begin
        while data = @server_socket.recv(65535)
          break if data.size == 0
          @client_socket.write(data)
          @client_socket.flush
        end
      rescue IOError
      ensure
        close
      end
    end
    @threads << Thread.new do
      begin
        while data = @client_socket.recv(65535)
          break if data.size == 0
          data = zundoko(data)
          @server_socket.write(data)
          @server_socket.flush
        end
      rescue IOError
      ensure
        close
      end
    end
  end

  def close
    @client_socket.close unless @client_socket.closed?
    @server_socket.close unless @server_socket.closed?
  end

  def join
    @threads.map { |t| t.join if t }
  end

  def to_s
    "#{@src.ip}:#{@src.port} => #{@dst.ip}:#{@dst.port}"
  end

  private

  def zundoko(data)
    begin
      encoded_data = data.encode('UTF-8', 'UTF-8', invalid: :replace, undef: :replace)
      if ZUNDOKO_REGEXP === encoded_data
        data = encoded_data.gsub(ZUNDOKO_REGEXP) { "#{$1} キ・ヨ・シ！" }
      end
    rescue ArgumentError
    rescue => e
      puts "#{e.class} #{e}"
    end
    data
  end

  def lookup_nat_table(server_socket)
    _inet, src_port, _src_host, src_ip = server_socket.peeraddr
    client_socket = nil
    src = IPPort.new(src_ip, src_port.to_s)
    dst = IPPort.new
    ip_port_key = "#{src.ip}:#{src.port}"
    10.times do
      ip_port = MEMCACHE.get(ip_port_key)
      if ip_port
        MEMCACHE.delete(ip_port_key)
        dst.ip, dst.port = ip_port.split(/:/)
        client_socket = TCPSocket.new(dst.ip, dst.port)
        break
      end
      sleep 0.5
    end
    [client_socket, src, dst]
  end
end

# ZEchoProxyServer
class ZechoProxyServer
  def initialize(port = PROXY_TCP_PORT)
    @threads = []
    @port = port
  end

  def run
    puts "proxy server start: port:#{@port}"
    @threads << Thread.new do
      tcp_server = TCPServer.open(@port)
      loop do
        socket = tcp_server.accept
        client = ZechoProxyClient.new(socket)
        client.run
      end
    end
  end

  def join
    @threads.map { |t| t.join if t }
  end
end

if __FILE__ == $0
  server = ZechoProxyServer.new
  server.run
  server.join
end
```

## 実行結果
ECHOクライアントのコンソール画面では、ECHOクライアントからECHOサーバにランダムにズンドコを送り、条件に合致した場合、キ・ヨ・シ！と追加で表示されています。

図. ズンドコキヨシ変換をした結果
![実行結果CLI.png](https://qiita-image-store.s3.amazonaws.com/0/12026/cd0df3c6-ab27-b149-3144-ee3071ec9dfd.png)

# APPENDIX

今回は汎用的なTCPプロキシを実装したため、通常のWebブラウザからの通信もズンドコキヨシ変換できます。
u01ホストでWebブラウザを起動し、u02ホストで下記のWebサーバにアクセスした実行結果は下記のとおりになります。

```rb:zecho_web.rb
require 'sinatra'
set bind: '0.0.0.0'
get '/' do
  erb :index
end

__END__
@@ index
<% words = %w(ズン ドコ) %>
<html><head></head><body><ul>
<% 100.times do %>
<li><%= Array.new(5) { words.sample }.join %></li>
<% end %>
</ul></body></html>
```

図. u01ホストのWebブラウザで、u02ホストのWebサーバへアクセスした結果
![実行結果WEB.png](https://qiita-image-store.s3.amazonaws.com/0/12026/6f07c39e-104a-91f8-5f87-24f2ddf06e8e.png)

実際のインターネット上でも試してみましたが、メジャーなサイトはHTTPSのため、残念ながらズンドコキヨシ変換をかけることはできませんでした。このため、ローカルで簡易なWebサーバを立ち上げて実行しています。

# 参考
- [［増補改訂版］クラウド時代のネットワーク技術 OpenFlow実践入門](http://gihyo.jp/book/2016/978-4-7741-7983-4)
  - @qb0c80aEさん提供ありがとうございました！
- [TremaでOpenFlowプログラミング](http://yasuhito.github.io/trema-book/)
- [ズンドコキヨシまとめ](http://qiita.com/shunsugai@github/items/971a15461de29563bf90)