20
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-03-21

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サーバを立ち上げて実行しています。

参考

20
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?