Amazon Dash Button を押したら Slack にポストする

 12月5日に日本でも Amazon Dash Button の提供が始まりましたが、すぐに下記の記事を投稿されている方がいて、面白そうだったので私もやってみました。

qiita.com

f:id:akanuma-hiroaki:20161217181325j:plain:w300:left

 他にもすでに同様のことを行われている記事はあるのですが、パケットキャプチャ部分を Ruby で書いている記事は見つからなかったので、 Ruby で書いてみました。ボタンの設定は上記ページなどですでに詳しく紹介されていて、同様に行いましたのでここでは割愛します。

 ちなみに本当は AWS IoT Button を使ってみたいのですが、まだ日本では発売されていないので、ひとまず Dash Button で我慢します。

AWS IoT ボタン - AWS IoT | AWS

処理の流れ

 先ほどの記事でも紹介されていますが、 Dash Button が押された時の処理の流れは下記のようになります。

  1. 電源ON(通常時はOFFになっている)
  2. 設定されたWi-Fiネットワークに接続
  3. IP重複検知のために ARP Probe もしくは UDP Broadcast 送信
  4. Amazonへの購入処理実行
  5. 電源OFF

 試しにボタンのIPアドレスにpingしてみたところ、ボタンを押すと電源が入ってpingが返ってくるようになり、処理が終わるとまた電源がOFFになってpingが返らなくなりました。

 $ ping 192.168.10.10
PING 192.168.10.10 (192.168.10.10): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1
Request timeout for icmp_seq 2
Request timeout for icmp_seq 3
ping: sendto: No route to host
Request timeout for icmp_seq 4
ping: sendto: Host is down
Request timeout for icmp_seq 5
64 bytes from 192.168.10.10: icmp_seq=0 ttl=64 time=6763.680 ms
64 bytes from 192.168.10.10: icmp_seq=1 ttl=64 time=5758.602 ms
64 bytes from 192.168.10.10: icmp_seq=2 ttl=64 time=4753.781 ms
64 bytes from 192.168.10.10: icmp_seq=3 ttl=64 time=3752.679 ms
64 bytes from 192.168.10.10: icmp_seq=4 ttl=64 time=2749.437 ms
64 bytes from 192.168.10.10: icmp_seq=7 ttl=64 time=4.412 ms
64 bytes from 192.168.10.10: icmp_seq=8 ttl=64 time=6.484 ms
64 bytes from 192.168.10.10: icmp_seq=9 ttl=64 time=2.491 ms
64 bytes from 192.168.10.10: icmp_seq=10 ttl=64 time=3.172 ms
Request timeout for icmp_seq 15
Request timeout for icmp_seq 16
Request timeout for icmp_seq 17
Request timeout for icmp_seq 18

 Dash Button では AWS IoT Button のような設定の自由度はなく、購入処理のための通信を検知して処理を行う必要があります。そこで大きく分けて下記2つの内容を実装します。

  1. Dash Button の ARP Probe もしくは UDP Broadcast をキャプチャしてMACアドレスを確認する

  2. Dash Button のMACアドレスからの通信を検知したら目的の処理を行う

Dash Button のMACアドレス確認

 Ruby でのパケットキャプチャ用ライブラリを探したところ、 PacketFu というライブラリがあったのでこちらを使用してみます。

github.com

 実装コードは下記の通りです。

capture.rb

require 'packetfu'
require './ouis.rb'
include PacketFu

def get_capture(iface)
  cap = Capture.new(iface: iface, start: true)
  cap.stream.each do |pkt|
    time = Time.now.strftime("%Y-%m-%d %H:%M:%S.%6N")

    if UDPPacket.can_parse?(pkt)
      udp_packet = UDPPacket.parse(pkt)
      src_ip = IPHeader.octet_array(udp_packet.ip_src).join('.')
      dst_ip = IPHeader.octet_array(udp_packet.ip_dst).join('.')
      src_port = udp_packet.udp_src
      dst_port = udp_packet.udp_dst
      src_mac, dst_mac, vendor_name = get_common_values(udp_packet)
      puts "time:#{time}, src_mac:#{src_mac}, dst_mac:#{dst_mac}, src_ip:#{src_ip}, dst_ip:#{dst_ip}, src_port:#{src_port}, dst_port:#{dst_port}, protocol:udp, vendor: #{vendor_name}"
      next
    end

    if ARPPacket.can_parse?(pkt)
      arp_packet = ARPPacket.parse(pkt)
      src_ip = arp_packet.arp_saddr_ip
      dst_ip = arp_packet.arp_daddr_ip
      src_mac, dst_mac, vendor_name = get_common_values(arp_packet)
      puts "time:#{time}, src_mac:#{src_mac}, dst_mac:#{dst_mac}, src_ip:#{src_ip}, dst_ip:#{dst_ip}, protocol:arp, vendor: #{vendor_name}"
    end
  end
end

def get_common_values(packet)
  src_mac = EthHeader.str2mac(packet.eth_src)
  dst_mac = EthHeader.str2mac(packet.eth_dst)
  vendor_name = get_vendor_name(src_mac)
  return src_mac, dst_mac, vendor_name
end

def get_vendor_name(mac)
  oui = mac.split(':').slice(0, 3).join('-')
  OUIS[oui.upcase]
end

if $0 == __FILE__
  iface = ARGV[0]
  puts "Capturing for interface: #{iface}"
  get_capture(iface)
end

 キャプチャしたパケットが ARPパケットもしくはUDPパケットであれば、パケットを解析して必要な情報を抜き出して画面に出力しています。

 また、Dash Button 以外からのパケットの情報も出力されるため、MACアドレスのベンダーコードからどのベンダーの端末からのパケットかも出すようにしています。あらかじめ IEEE のサイトからベンダーコードのリストを取得し、hash 形式で ouis.rb に保持し、 require して使っています。

IEEEのベンダーコードリスト(重いです)
http://standards.ieee.org/regauth/oui/oui_public.txt

ouis.rb

# http://standards.ieee.org/regauth/oui/oui_public.txt をもとに作成
OUIS = {
  'E0-43-DB' => "Shenzhen ViewAt Technology Co.,Ltd. ",
  '24-05-F5' => "Integrated Device Technology (Malaysia) Sdn. Bhd.",
  '2C-30-33' => "NETGEAR",
  '3C-D9-2B' => "Hewlett Packard",
  '9C-8E-99' => "Hewlett Packard",
  'B4-99-BA' => "Hewlett Packard",
  '1C-C1-DE' => "Hewlett Packard",
  '3C-35-56' => "Cognitec Systems GmbH",
〜〜〜以下略〜〜〜

 そしてスクリプトを実行して Dash Button を押すと下記のような出力が確認できました。

$ sudo ruby capture.rb en0 | grep -i amazon
/Users/akanuma/workspace/dash_button/ouis.rb:8296: warning: key "00-01-C8" is duplicated and overwritten on line 8309
/Users/akanuma/workspace/dash_button/ouis.rb:12779: warning: key "08-00-30" is duplicated and overwritten on line 17381
/Users/akanuma/workspace/dash_button/ouis.rb:12779: warning: key "08-00-30" is duplicated and overwritten on line 22049
time:2016-12-17 20:52:06.677957, src_mac:88:71:e5:f0:89:71, dst_mac:ff:ff:ff:ff:ff:ff, src_ip:0.0.0.0, dst_ip:255.255.255.255, src_port:68, dst_port:67, protocol:udp, vendor: Amazon Technologies Inc.
time:2016-12-17 20:52:06.689735, src_mac:88:71:e5:f0:89:71, dst_mac:ff:ff:ff:ff:ff:ff, src_ip:192.168.10.10, dst_ip:192.168.10.1, protocol:arp, vendor: Amazon Technologies Inc.

 ベンダーコードのリストで同一のベンダーコードが微妙に異なるベンダー名表記で複数回登録されているために warning が出ていますが、処理には影響ないので今回は無視します。ベンダー名が Amazon Technologies Inc. となっているのが Dash Button です。 src_mac として出力している内容が Dash Button のMACアドレスになりますので、このMACアドレスからの通信を検知して処理を行うスクリプトを実装します。

Dash Button からの通信を検知して Slack に投稿する

 通信を検知するための処理は基本的にMACアドレスを確認するためのスクリプトと同様になります。パケットの送信元MACアドレスが Dash Button のものだったらSlackに投稿する処理を実行します。

post_to_slack.rb

require 'packetfu'
require 'open3'
require 'json'
include PacketFu

FILTER = nil
MAC_OF_DASH = '88:71:e5:f0:89:71'
SLACK_API_URL = 'Slack の Incoming Webhook URL'
INTERVAL_MICRO_SECONDS = 2_000_000

@last_processed_time = 0

def get_capture(iface)
  cap = Capture.new(iface: iface, filter: FILTER, start: true)
  cap.stream.each do |pkt|
    next if !ARPPacket.can_parse?(pkt) && !UDPPacket.can_parse?(pkt)

    packet = Packet.parse(pkt)
    next if EthHeader.str2mac(packet.eth_src) != MAC_OF_DASH
    next if !past_since_last_processed?

    post_to_slack
    @last_processed_time = current_unixtime_with_micro_seconds

    t_stamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%6N")
    puts "#{t_stamp} Posted to Slack."
  end
end

def current_unixtime_with_micro_seconds
  now = Time.now
  "#{now.to_i}#{now.usec}".to_i
end

def past_since_last_processed?
  current_unixtime_with_micro_seconds - @last_processed_time >= INTERVAL_MICRO_SECONDS
end

def post_to_slack
  api_url = SLACK_API_URL
  payload = {
    channel:    '#akanuma_private',
    username:   'dash',
    icon_emoji: ':squirrel:',
    text:       'Hello World from Dash Button!!'
  }
  command = "curl -X POST --data-urlencode 'payload=#{payload.to_json}' #{api_url}"
  puts command
  output, std_error, status = Open3.capture3(command)
  puts output
  puts std_error
  puts status
end

if $0 == __FILE__
  iface = ARGV[0]
  puts "Capturing for interface: #{iface}"
  get_capture(iface)
end

 色々試してみたところ、 自宅ではボタンを押すと ARP と UDP のパケットが一度ずつ飛ぶのですが、会社のネットワークで試すと ARP や UDP のパケットが飛ぶタイミングを特定しきれず、ボタンを押した時に ARP が飛んだり飛ばなかったり、 UDP が複数回飛んだりするので、とりあえずの策として、ARPとUDP両方を検知して、前回の実行から2,000,000マイクロ秒(2秒)以内だったら実行しないようにしました。他の方の記事ではあまり回数がブレるというのはなさそうに見えるので、もう少し調べてみたいところです。この辺り詳しい方いたら教えていただけると嬉しいです。

 Slackへの投稿は Open3 を使って Incoming Webhook のURLにポストしているだけのシンプルな内容です。これを実行すると下記のようにSlackに投稿されます。

f:id:akanuma-hiroaki:20161217212950p:plain

 今回はSlackに投稿しましたが、この内容さえ変えればボタンが押された時の処理は自由に変更できるので、アイディア次第で色々面白いことができそうに思っています。社内で使うちょっとしたツールなど色々試してみたいと思います。

LAN内にサーバが必要

 今回はとりあえず動かしてみたということで、自分のMac上でスクリプトを動かしましたが、常時使うためのツールを作るのであれば、LAN内にサーバを用意する必要があります。これが AWS IoT Button であれば直接AWSのサービスと連携できると思うので、早く日本でも使えるようになってほしいですね。

Vagrant で稼働させたVM上で Dash Button のパケットをキャプチャできなかった

 今回試す環境を最初は Vagrant でパブリックネットワーク設定のVMを作ってVM上で動かしていたのですが、VM上では Dash Button からのパケットが検知できませんでした。たまに検知できるときもあるんですが、検知できない場合が多いです。ただ、 冒頭で紹介した記事で紹介されている node.js でのやり方だとちゃんと検知できていますし、 tcpdump でパケットを見てみると、Macのローカル上でもVM上でも同様に検知できているので、PacketFuで解析している部分が何か違うのかなと思い色々調べてみましたが、特定できませんでした。VM上へはMacのネットワークインタフェースを経由して到達していると思うので、経由したIFの情報を送信元として認識しているような感じではあるのですが、この辺りも何かわかる方いたら情報いただけると嬉しいです。

今回のコード

今回のコードはこちらに公開しました。

github.com