Trema/Pioでパケットを作ろう(3)

まあ、細かいこと考えてたなかなか先進まないのでやれそうなことやってしまえ、ってことで、TCP packet generator/parser を作ってみました。やってみたメモ。

Pioのクラスとファイルの分割

パケットを作ろう(1) で "データとラッパーで分ける" って書いたんだけどまだきれいに分けられていません。クラス・モジュールの構造として、というのと、ファイルの分割を考えないといけない。いろいろ書いてみたけどファイルサイズ的にもうしんどい。

これPio::Tcp, Pio::Udp みたいな形でモジュール分割しておけばよかったなあ。その当たりの整理と、あわせてフォルダとかを分けるか。

  • IPv4
    • FrameHeader (data)
    • Frame (data)
    • Header (data)
  • Tcp
    • OptionalTLV (data)
    • tcp option classes (data)
    • Datagram (data)
  • Udp
    • Datagram (data)

ってあたりだろうか。Header class に payload field があって実際にはデータも含んじゃってるってあたりがわかりにくいなあ…というのがあるので wrapper の名前は変えるべきだろう。Pio::Lldp とかは (pio)/lib/lldp.rb に wrapper class が置いて在るみたいなので、今の "Datagram" class はそっちに変えるべきか。

  • lib/
    • tcp.rb (wrapper)
    • tcp/
      • datagram.rb (data)

みたいな感じか。まあ追々ね。

TCP packet generator/parser 実装上のアレコレ

Option

TCP Option も TLV である程度定型であればまだしも、Type だけしかない(EOL, NO-OP) とか, Type と Length だけある ( Sack permitted, Partial order connection permitted) とか、いくつかバリエーションがあってどうにか吸収してやらないといけない。結局共通するフィールドが type しかないので、length/value については別途クラスつくって choice してやるようにしています。

Header Length

TCP ヘッダにある長さの情報はヘッダ長 (data offset) だけで、データ(payload)長を明示的に計算できる情報はないんだよね。TCP datagram 全体の長さかあるいは payload (data) の長さが分かれば、:read_lengthで読み込むというのがやれるのだけど、ないので、data offset の後全体を読み込むってのをやらんといかん。

Read Options and TCP payload

ってことで、data offset から後を tcp payload として読んでやらないといけないのだけど。TCP option 読み込むのも結構厄介でね。以下の条件を満たさないといけない。

  • EOL までで読むのを止める
  • data offsetまで読み込む(読み捨てる)
    • Option list が EOL で終わっていない場合がある。
    • EOL が Option list の中間に挟まっている可能性がある。

ということでこういう感じにしてみた。

  class TcpHeader < BinData::Record
    endian :big

    ## 略 ##

    array  :optional_tlv,
           :type => :tcp_optional_tlv,
           :onlyif => :has_options?,
           :read_until => lambda {
      @readbytes ||= header_length_in_bytes - 20
      @readbytes = @readbytes - element.bytesize
      element.end_of_option_list? or @readbytes <= 0
    }
    skip   :length => lambda { @readbytes or 0 }
    rest   :payload

    def has_options?
      data_offset > 5
    end

    ## 略 ##

  end
  • skip
    • :length で指定したバイト数だけ読み飛ばすことができる。:optional_tlv が在る場合は、:read_until で読んだオプションのサイズをカウント(カウントダウン)しておく。この数字が残っていれば末尾に EOL padding があるか option list の中間に EOL が入っている状況で、いずれもそこから後は読み飛ばしていい。
    • もし option がなければ :optional_tlv 自体を含めないようにする。(:onlyif で data offset が 5(=20byte), つまり option がない場合にはこのフィールドを含めない。これをやらないと、payload の最初の1バイトを :optional_tlv で読んじゃってたので。
  • rest
    • 「残り全部」。入力が Stream になってないと使えない…とかあったはず。
    • 最初、count_bytes_remaining (Stream の残りのバイト数を出す) ってのでやろうとしたんだけど、どうもコレ思ったように動かなかったんだよね…。
Generate options

オプション生成は、symbol table から type value 出してあとは引数全部そのまま渡すだけです。なんかもうちょいうまくかけないもんかねえこれ。ホントは wrapper 側で EOL やら NO-OP やら自動的に埋めてくれるようにするのがいいんだろうけどね。

options = [
  { :mss => { :segment_size => 1460 } },
  :noop,
  { :wsopt => { :shift_count => 8 } },
  :noop,
  :noop,
  :sackp
]

tdgm = Pio::TcpDatagram.new(
  # 略
  :optional_tlv => options,
  :payload => payload,
)

いまのところ、EOL(0), NO-OP(1), MSS(2), Windows Scale Option(3), Sack Permitted(4), SACK(5) までは実際のデータ(パケットキャプチャしたデータ)を元にデータ作れるってのが見えてます。他のオプションは、一応書いてあるけど未テスト。

simple-router + TCP echo server

Pio::TcpServer で、なんちゃって tcp state machine を実装してみた。が、seq/ack の計算とか session close 処理とかもろもろ怪しいです…けどまあ気にしない方向で。一応 tcp echo server としては動きます。
Pio は packet generate/parse の機能だとするならこういう機能は Pio ではないよな…。

  • TCP SYN from client
[handle_ipv4], 17
  src mac      : 00:02:2a:ea:a3:c9
  dst mac      : 54:52:00:01:00:02
  ether type   : 0800
  version      : 4
  header length: 20 (octets)
  tos          : 0
  total length : 52 (octets)
  identificat  : 13999
  flags        : 2
  frag offset  : 0
  ttl          : 128
  protocol     : 6
  checksum     : 4061
  src addr     : 192.168.1.16
  dst addr     : 192.168.1.83
  options      :
  valid?       : true
  src port     : 51279
  dst port     : 7
  seq number   : 64adb478
  ack number   : 00000000
  data offset  : 8
  ctrl flag    : 000010
  window size  : 2000
  checksum     : e9df
  urg pointer  : 0000
  options      : 2, 1, 3, 1, 1, 4
  payload      :
  valid?       : true

Checksum Calculation

ヘッダフィールド値を wrapper 側で個別にクラス変数として持って計算している。が、これは別に、checksum = 0 な data object をつくって to_binary_s して、binary string に対して計算してやっても別にかまわないのだよな。アクセサならほとんど委譲しちゃうだろうし。

いま router-utils.rb を元に書いたところがあるのと、疑似ヘッダ部分の計算を加えてやらなければいけないのとで、こういう書き方になってる。が、checksum 計算についてはバイトオーダーと 16bit 単位での区切りが合っていれば計算順序は問わないし、疑似ヘッダも別途 data class 作ってやってもいいんだよね。そうすると checksum 計算ルーチンは一本化できる…か? Checksum validation も含めて考えないとイカンけど。