1. Qiita
  2. 投稿
  3. ズンドコキヨシ

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

  • 19
    いいね
  • 0
    コメント

ZUNDOKOプロトコルに触発され、このビッグウェーブにのるしかないと思いたち、OpenFlowでズンドコキヨシ変換プロキシを作成しました。

最近流行っているズンドコキヨシは下記のツイートが発端となって様々な言語で実装され、ズンドコキヨシまとめにまとめられています。プログラミング系だけと思っていたところ、ついにネットワークでもズンドコキヨシ with Pcap4J - ZUNDOKOプロトコルを実装してみたが登場しました。

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

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

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

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

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

図. ズンドコキヨシ変換のTCP通信の流れ
概要.PNG

実装

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

図. 実装した機能の通信概要
構成.PNG

宛先NAPTの問題点

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

宛先NAPTの解決策

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

環境

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

  • OpenFlow 1.0
  • 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

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番)にランダム生成した「ズン」と「ドコ」の文字列を送信し、受信結果を表示するシンプルなスクリプトです。

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します。

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メソッドに渡し、ズンドコキヨシ変換し、クライアントに送信します。

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

APPENDIX

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

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のため、残念ながらズンドコキヨシ変換をかけることはできませんでした。このため、ローカルで簡易なWebサーバを立ち上げて実行しています。

参考

Comments Loading...