ZUNDOKOプロトコルに触発され、このビッグウェーブにのるしかないと思いたち、OpenFlowでズンドコキヨシ変換プロキシを作成しました。
最近流行っているズンドコキヨシは下記のツイートが発端となって様々な言語で実装され、ズンドコキヨシまとめにまとめられています。プログラミング系だけと思っていたところ、ついにネットワークでもズンドコキヨシ with Pcap4J - ZUNDOKOプロトコルを実装してみたが登場しました。
Javaの講義、試験が「自作関数を作り記述しなさい」って問題だったから
— てくも (@kumiromilk) 2016年3月9日
「ズン」「ドコ」のいずれかをランダムで出力し続けて「ズン」「ズン」「ズン」「ズン」「ドコ」の配列が出たら「キ・ヨ・シ!」って出力した後終了って関数作ったら満点で単位貰ってた
ネットワーク系のスキルを活かし、最近勉強し始めたOpenFlowのスキル向上のために、OpenFlowのコントローラであるTremaで変換プロキシを試行錯誤しながら実装して、このビックウェーブにのってみました。
ズンドコキヨシ変換プロキシ
TCP通信のストリームに文字コードUTF-8で「ズンズンズンズンズンドコ」を見つけると、「ズンズンズンズンズンドコ キ・ヨ・シ!」に変換(以下、ズンドコキヨシ変換)します。
今回はECHOプロトコル(RFC 862)を利用して、ズンドコキヨシ変換プロキシの動作を確認しました。
下図のように「ECHOクライアント」から「ECHOサーバ」のTCP通信を、OpenFlowで「ZUNDOKO TCPプロキシ」へ捻じ曲げます。「ZUNDOKO TCPプロキシ」では代理で「ECHOサーバ」へTCP通信し、受信したデータをズンドコキヨシ変換し、「ECHOクライアント」に送信します。
実装
下図のように「OpenFlowスイッチ」で宛先アドレスポート変換(宛先NATP変換)して、宛先を「TCPプロキシ」に捻じ曲げます。
「OpenFlowコントローラ」に「TCP SYN」がPACKET_INすると、宛先NAPT変換のためにFLOW_MODで順方向のフローと逆方向のフローの登録をし、元のパケットをPACKET_OUTしています。フローの登録後は、すべて「OpenFlowスイッチ」で完結するため、PACKET_INは発生しません。
宛先NAPTの問題点
普通に宛先NAPT変換してしまうと、元の宛先が失われてしまいTCPプロキシで宛先がわからなくなってしまいます。
HTTPプロキシでは、宛先情報がHTTPリクエストヘッダ内にあるため、リクエストヘッダを解析することで、宛先を特定できます。しかしながら、汎用的なTCPプロキシでは、解析するためのヘッダ情報が無いため、宛先を特定できません。
宛先NAPTの解決策
上記の問題の解決のために「宛先キャッシュ」を実装します。宛先NAPT変換前の宛先情報を「宛先キャッシュ」に登録し、TCPプロキシで参照し宛先を特定します。
「OpenFlowコントローラ」では、変換前の宛先IPアドレスとポート番号がわかります。「宛先キャッシュ」に「送信元IPアドレスとポート番号」をキーに、「宛先IPアドレスとポート番号」を登録します。
TCPプロキシは「宛先キャッシュ」を参照して、宛先を特定して汎用的なTCPプロキシとして、対象のサーバにアクセスします。
環境
主に使用したソフトウェアやライブラリは下記のとおりです。
- OpenFlow 1.0
- コントローラ: Trema 0.10.1
- スイッチ: Open vSwitch 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サーバ)
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番)にランダム生成した「ズン」と「ドコ」の文字列を送信し、受信結果を表示するシンプルなスクリプトです。
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します。
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メソッドに渡し、ズンドコキヨシ変換し、クライアントに送信します。
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サーバにランダムにズンドコを送り、条件に合致した場合、キ・ヨ・シ!と追加で表示されています。
APPENDIX
今回は汎用的なTCPプロキシを実装したため、通常のWebブラウザからの通信もズンドコキヨシ変換できます。
u01ホストでWebブラウザを起動し、u02ホストで下記のWebサーバにアクセスした実行結果は下記のとおりになります。
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サーバへアクセスした結果
実際のインターネット上でも試してみましたが、メジャーなサイトはHTTPSのため、残念ながらズンドコキヨシ変換をかけることはできませんでした。このため、ローカルで簡易なWebサーバを立ち上げて実行しています。
参考
-
[増補改訂版]クラウド時代のネットワーク技術 OpenFlow実践入門
- @qb0c80aEさん提供ありがとうございました!
- TremaでOpenFlowプログラミング
- ズンドコキヨシまとめ