PINGアスキーアート with OpenFlow - TremaとOpen vSwitchでICMPを制御してみた

Ciscoルータのpingコマンドでアスキーアートを描いてみました。
OpenFlowでICMPパケットを許可・遮断を細かく制御して実現しています。OpenFlowコントローラはTrema、OpenFlowスイッチはOpen vSwitchを利用してます。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">OpenFlowをTremaとOVSで試してみました！Ciscoルータから送信されたICMPをOFスイッチで制御してアスキーアートを描きました。 <a href="https://t.co/TsX1q1F7Bz">pic.twitter.com/TsX1q1F7Bz</a></p>&mdash; kooshin (@kooshin) <a href="https://twitter.com/kooshin/status/709147416763637760">2016年3月13日</a></blockquote>


# PINGアスキーアート
Ciscoルータのpingコマンドは、宛先にICMPパケットを送信し、その結果に応じて1文字で表現されます。正常時は「!」、到達不可時は「.」など、結果に応じて文字は変わります([参考](http://www.cisco.com/cisco/web/support/JP/100/1008/1008447_ping_traceroute-j.html#ping_com))。1行あたり70回分の実行結果が表示されます。PINGアスキーアートではこのCiscoルータの仕様を利用し、OpenFlowでICMPパケットを制御し、pingの実行結果を変化させてアスキーアートを描きます。

図. Ciscoルータのpingコマンドの実行結果（1行70文字）
![Cisco PING.png](https://qiita-image-store.s3.amazonaws.com/0/12026/24567b6f-7c73-3f1f-40f7-500182ff538a.png)

## 全体の構成
PINGアスキーアートを実現するために、OpenFlowでICMPパケットを制御します。CiscoルータにOpenFlowスイッチを下図のように接続します。ルータから送信されるICMPパケットはOpenFlowスイッチを経由し、OpenFlowコントローラに伝えられ、許可と遮断を制御します。

図. 全体の構成
![全体構成.PNG](https://qiita-image-store.s3.amazonaws.com/0/12026/5ef2616b-362d-7c4f-df78-e83f9ed8ea0f.png)


## アスキーアートの元画像
OpenFlowコントローラでは、アスキーアートとして描画する下図のような画像を読み込み2値化し、黒ピクセルは許可、白ピクセルは遮断として読み込みます。読み込む画像はpingコマンドの実行結果に合わせて幅70ピクセルとなります。
サンプルで用意した画像は下図になります。幅70ピクセル、高さ50ピクセルで合計3500ピクセルあります。pingコマンドで繰り返す回数（pingのrepeatオプション）に3500を指定すればちょうどです。

図. アスキーアートにする画像
![img.png](https://qiita-image-store.s3.amazonaws.com/0/12026/fc4475e2-3696-180d-19a2-69e668de3fd1.png)

## 制御方法
OpenFlowスイッチから送信されたICMP Echo Requestパケットを、OpenFlowコントローラが受信（PACKET_IN）するたびに、許可と遮断を判断し、OpenFlowスイッチに結果を送信（PACKET_OUT）します。当初に実装した方式は、許可の場合はPACKET_INしたICMPパケットをそのままPACKET_OUTし、遮断の場合はPACKET_OUTしないでICMPパケットをドロップさせる方式でした。

### ドロップ方式の問題点
ICMPパケットをドロップさせる方式は実装が単純ですが、実行時間が凄まじく時間がかかることが問題となりました。
CiscoルータのPINGのタイムアウトは最低0秒に設定できますが、タイムアウトが早すぎて、ほとんどがタイムアウトしてドロップとなり、意図した動作をしません。そのため、実質的に設定できるタイムアウトは最低1秒です。
上図を2値化した場合、黒ピクセルが774ピクセル、白ピクセルが2726ピクセルです。白ピクセルはドロップ対象となり、1秒のタイムアウトが発生するため、約45分かかることになります。逆に黒ピクセルをドロップ対象にしても約12分かかり、時間がかかりすぎます。

### 到達不能方式による改善案
ドロップさせる方式ではCiscoのpingコマンドのタイムアウトが問題になります。このため、タイムアウトに依存しない方法で実行時間を短くします。
今回は遮断の場合、ICMP Unreachableを生成し、送信元に返送する方式にします。Ciscoルータのpingの場合、ICMP Unreachableは「U」と表されます。こうすることで、即時結果に反映されるため、実行時間が飛躍的に短縮できます。
なお、pingコマンドの実行結果が、今までは「.」だった箇所が「U」となってしまい、微妙に見栄えが変わります。

## 実装
OpenFlowスイッチ、OpenFlowコントローラ、Ciscoルータは下記の通り実装しています。
開発の都合から、OpenFlowスイッチとOpenFlowコントローラは別筐体で実行しています。

図. 環境
![環境.PNG](https://qiita-image-store.s3.amazonaws.com/0/12026/f23d7f57-b5ae-74a6-8b64-d435c79bfdce.png)

### OpenFlowスイッチ
OpenFlowスイッチは下記のとおり、LIVA本体にUSB型のLANアダプタを2つ接続し、合計3つのNICを持っています。LIVAにUbuntu14.04とOpen vSwitchをインストールして、OpenFlowスイッチとして動作させます。

- ハード本体：[LIVA](http://www.ecsjpn.co.jp/liva/liva2.htm) LIVA-B3-2G-32G
- LANアダプタ：[Logitech LAN-GTJU3H3](http://www.logitec.co.jp/products/lan/langtju3h3/)
- LANアダプタ：[Logitech LAN-GTJU3](http://www.logitec.co.jp/products/lan/langtju3/)
- OS：Ubuntu 14.04
- ソフト：[Open vSwitch](http://openvswitch.org/) 2.5.0

NIC
eth1とeth2をOpenFlowスイッチのポートとし、レイヤ2ブリッジさせます。OpenFlowスイッチの名前はofs0としています。

- eth0: 管理ポート
- eth1: 内部ポート
- eth2: 外部ポート

図. OpenFlowスイッチの物理構成
![環境.png](https://qiita-image-store.s3.amazonaws.com/0/12026/60e88645-cdfc-2355-4788-f6a7a1dfefa0.png)

```bash:設定コマンド
$ sudo ovs-vsctl add-br ofs0
$ sudo ovs-vsctl set bridge ofs0 protocols=OpenFlow10
$ sudo ovs-vsctl add-port ofs0 eth1
$ sudo ovs-vsctl add-port ofs0 eth2
$ sudo ifconfig eth1 up
$ sudo ifconfig eth2 up
$ sudo ovs-vsctl set-controller ofs0 tcp:192.168.88.17:6653
$ sudo ovs-vsctl show
428db0fc-4015-4a6e-86f5-9d3dfba68904
    Bridge "ofs0"
        Controller "tcp:192.168.88.17:6653"
        Port "eth2"
            Interface "eth2"
        Port "eth1"
            Interface "eth1"
        Port "ofs0"
            Interface "ofs0"
                type: internal
    ovs_version: "2.5.0"
$ sudo ovs-ofctl dump-flows ofs0
NXST_FLOW reply (xid=0x4):
 cookie=0x0, duration=51.458s, table=0, n_packets=5, n_bytes=310, idle_age=8, priority=0,in_port=1 actions=output:2
 cookie=0x0, duration=51.420s, table=0, n_packets=0, n_bytes=0, idle_age=51, priority=0,dl_dst=ff:ff:ff:ff:ff:ff actions=FLOOD
$ sudo ovs-ofctl dump-ports ofs0
OFPST_PORT reply (xid=0x2): 3 ports
  port  1: rx pkts=1164747, bytes=347301435, drop=0, errs=0, frame=0, over=0, crc=0
           tx pkts=122833, bytes=14868218, drop=0, errs=0, coll=0
  port LOCAL: rx pkts=0, bytes=0, drop=0, errs=0, frame=0, over=0, crc=0
           tx pkts=0, bytes=0, drop=0, errs=0, coll=0
  port  2: rx pkts=295161, bytes=26161341, drop=0, errs=0, frame=0, over=0, crc=0
           tx pkts=473559, bytes=126584447, drop=0, errs=0, coll=0
```

### OpenFlowコントローラ
OpenFlowコントローラは、OpenFlowフレームワークのTremaを用いて実装しました。

- ハード本体：Intel NUC NUC5i5RY
- OS：Ubuntu 14.04
- ソフト：ImageMagick 6.7.7-10
- ソフト：Ruby 2.2.4
  - trema 0.10.1 : OpenFlowコントローラ
  - rmagick 2.15.4 : 画像操作ライブラリ


各メソッドの簡易的な説明です。

``switch_readyメソッド``：
逆方向のフロー（外部から内部へのパケット送信）とブロードキャストのフローをFLOW_MODでOpenFlowスイッチに設定しています。今回必要なのは内部から外部へのICMPパケットのため、それ以外のフローを事前に設定しています。

``packet_in``メソッド：
PACKET_INの処理をするメソッドです。``send_bitmap_icmp_reply``メソッドの実行で例外が発生すると、``send_normal_packet``でそのままPACKET_OUTします。

``send_normal_packet``メソッド：
PACKET_INで内部ポートから入ってきたパケットを外部ポートへ送信（PACKET_OUT）します。

``send_bitmap_icmp_reply``メソッド：
PACKET_INしたパケットがICMP Echo Requestかどうかの判定と、ICMP Unreachableを送信する対象化の判断をします。

``send_icmp_unreachable``メソッド：
ICMP Echo Requestに対するICMP Unreachableを生成し、送信元に送信しています。``Pio::Icmp::Reply``で生成し、``echo_data``に元のICMP Echo RequestのIPヘッダとIPペイロードを入れています（イーサネットヘッダを取り除いた部分）。

``bitmap``メソッド：
宛先・送信元IPをキーに``@bitmap``から``@counter``に基づいて許可・遮断の条件を読み出します。読みだした後にカウンタの``@counter``に1を足します。``@counter``にはICMPの受信回数が入ります。

``load_bitmap_image``メソッド：
画像を読み込み2値化します。白はfalse、黒はtrueの2値化結果を``@bitmap``という名の1次元配列に保存します。画像の2値化の方法は[このサイト](http://blog.livedoor.jp/itukano/archives/51838000.html)を参考にさせていただきました。

```rb:trema_controller.rb
require 'rmagick'

INTERNAL_PORT = 2
EXTERNAL_PORT = 1
ETHERNET_HEADER_SIZE = 14

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

    @bitmap = load_bitmap_image('img.png')    # 2値化した画像の1次元配列
    @counter = Hash.new { |h, k| h[k] = 0 }   # ICMPパケットの送信カウンタ
  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)
    begin
      # 条件に一致した場合、ICMP Unreachableを送信する
      send_bitmap_icmp_reply(datapath_id, message)
      return
    rescue => e
    end
    # 内部ポートから外部ポートへパケットを送信する
    send_normal_packet(datapath_id, message)
  end

  private

  def send_normal_packet(datapath_id, message)
    send_packet_out(
      datapath_id,
      packet_in: message,
      actions: SendOutPort.new(EXTERNAL_PORT)
    )
  end

  def send_bitmap_icmp_reply(datapath_id, message)
    request = Pio::Icmp.read(message.raw_data)
    raise 'NotIcmpRequest' unless request.class == Pio::Icmp::Request
    raise 'NotTargetRequest' if bitmap(
      request.destination_ip_address,
      request.source_ip_address
    )
    send_icmp_unreachable(datapath_id, request)
  end

  def send_icmp_unreachable(datapath_id, req)
    reply = Pio::Icmp::Reply.new(
      source_mac:             req.destination_mac,
      destination_mac:        req.source_mac,
      source_ip_address:      req.destination_ip_address,
      destination_ip_address: req.source_ip_address,
      identifier:             req.icmp_identifier,
      sequence_number:        req.icmp_sequence_number,
      echo_data:              req.to_binary[ETHERNET_HEADER_SIZE..-1]
    )
    reply.icmp_type = 3
    reply.icmp_code = 1

    send_packet_out(
      datapath_id,
      raw_data: reply.to_binary,
      actions: SendOutPort.new(INTERNAL_PORT)
    )
  end

  def bitmap(dst_ip, src_ip)
    result = @bitmap[@counter[[dst_ip, src_ip]] % @bitmap.size]
    @counter[[dst_ip, src_ip]] += 1
    result
  end

  def load_bitmap_image(filepath)
    # 参考: http://blog.livedoor.jp/itukano/archives/51838000.html
    bitmap = []
    img = Magick::ImageList.new(filepath)
    img.rows.times do |row|
      img.columns.times do |col|
        px = img.export_pixels(col, row, 1, 1)
        # フルカラー画像を2値化
        t_px = (px[0] * 0.30 + px[1] * 0.59 + px[2] * 0.11)
        bitmap << (t_px > 170 * 257 ? false : true)
      end
    end
    bitmap
  end
end
```

```bash:実行結果
$ trema --version
trema version 0.10.1
$ trema run trema_controller.rb
TremaController started.
switch_ready: 57818231803846
```

### Ciscoルータ
CISCO1812JでVRFを設定し、1台で送信元ルータと宛先ルータとして動作させています。

- ハード本体：Cisco 1812-J
- バージョン：15.1(4)M4

ルータのポート

- FastEthernet0：内部ポート、192.168.1.1/30、VRF：VPN1
- FastEthernet1：外部ポート、192.168.1.2/30、VRF：VPN2
- FastErhernet9(Vlan1)：管理ポート、192.168.88.2（DHCP割り当て）、VRF：Global


内部ポートから外部ポートへPINGを実行
VRFを指定して、VPN1からVPN2へPINGを実行しています。

```txt:pingコマンドの実行方法
c1812j#ping vrf VPN1 192.168.1.2 repeat 3500
```


```txt:CISCO1812-Jコンフィグ
version 15.1
service timestamps debug datetime msec
service timestamps log datetime msec
no service password-encryption
!
hostname c1812j
!
boot-start-marker
boot-end-marker
!
!
vrf definition VPN1
 !
 address-family ipv4
 exit-address-family
!
vrf definition VPN2
 !
 address-family ipv4
 exit-address-family
!
enable secret 4 tnhtc92DXBhelxjYk8LWJrPV36S2i4ntXrpb4RFmfqY
!
aaa new-model
!
!
!
!
!
!
!
aaa session-id common
!
crypto pki token default removal timeout 0
!
!
dot11 syslog
ip source-route
!
!
!
!
!
ip cef
ip domain name local
no ipv6 cef
!
multilink bundle-name authenticated
!
!
!
license udi pid CISCO1812-J/K9 sn **********
username cisco password 0 cisco
!
!
! 
!
!
!
!
!
!
interface BRI0
 no ip address
 encapsulation hdlc
 shutdown
!
interface FastEthernet0
 vrf forwarding VPN1
 ip address 192.168.1.1 255.255.255.252
 duplex auto
 speed auto
!
interface FastEthernet1
 vrf forwarding VPN2
 ip address 192.168.1.2 255.255.255.252
 duplex auto
 speed auto
!
interface FastEthernet2
 no ip address
!
interface FastEthernet3
 no ip address
!
interface FastEthernet4
 no ip address
!
interface FastEthernet5
 no ip address
!
interface FastEthernet6
 no ip address
!
interface FastEthernet7
 no ip address
!
interface FastEthernet8
 no ip address
!
interface FastEthernet9
 no ip address
!
interface Vlan1
 ip address dhcp
!
ip forward-protocol nd
no ip http server
no ip http secure-server
!
!
ip route vrf VPN1 0.0.0.0 0.0.0.0 FastEthernet0 192.168.1.2
ip route vrf VPN2 0.0.0.0 0.0.0.0 FastEthernet1 192.168.1.1
!
no cdp run
!
!
!
!
!
!
!
!
control-plane
!
!
!
line con 0
line aux 0
line vty 0 4
 transport input telnet ssh
line vty 5 15
 transport input telnet ssh
!
end
```

## 実行結果
下記の動画が実行結果になります。

[![実行結果](https://asciinema.org/a/3tt9dnlvxf2k2c7si8wzsdf5c.png)](https://asciinema.org/a/3tt9dnlvxf2k2c7si8wzsdf5c)

# 参考文献
- [ping および traceroute コマンドについて](http://www.cisco.com/cisco/web/support/JP/100/1008/1008447_ping_traceroute-j.html#ping_com) Cisco Systems
- [[Ruby]Rubyで画像処理をやってみる[RMagick][CentOS]](http://blog.livedoor.jp/itukano/archives/51838000.html) -ε-いつかのブログ-з-
- [［増補改訂版］クラウド時代のネットワーク技術 OpenFlow実践入門](http://gihyo.jp/book/2016/978-4-7741-7983-4) - 技術評論社
- [TremaでOpenFlowプログラミング](http://yasuhito.github.io/trema-book/) - 上記のWeb版