TCP(HTTP)のキャプチャ＆リプレイ


# はじめに
以前、UDPのキャプチャ＆リプレイを試したので、今度はTCPのキャプチャ＆リプレイを実装しました。UDPはコネクションレスであり、TCPはコネクション方式なので、その部分だけ書き直しています。
なんで私がキャプチャ＆リプレイについて調査しているのかというと、スタブを制作したいからです。TCPやUDPの開発をする際に、理屈としてはサーバ側がなくても仕様書があれば開発できるはずですが、実際には動作確認しながら使いたいもの。ただ、いつもサーバが近くにあれば良いのですが、ハードウェアなどに依存していることがあり、その借用期間が限られていることから、借用期間内に応答値をキャプチャしておき、返却後もキャプチャしたログを使って開発を続けることを想定しています。

# 環境
OS X El Capitan 10.11
Python 2.7.11

# 動作イメージ

```lang:Captureモード
+-----------+     +-----------------+     +-----------+
|HTTP Client|---->|Capture_Replay.py|---->|HTTP Server|
|           |<----|                 |<----|           |
+-----------+     +--------|--------+     +-----------+
                           v
                   +---------------+
                   |capture_tcp.log|
                   +---------------+
```

```lang:Replayモード
+-----------+     +-----------------+
|HTTP Client|---->|Capture_Replay.py|
|           |<----|                 |
+-----------+     +-----------------+
                           ^
                   +-------|-------+
                   |capture_tcp.log|
                   +---------------+
```
```lang:シーケンス図(Captureモード)
+-----------+    +-----------+    +-----------+
|HTTP Client|    |Capture &  |    |HTTP Server|
|           |    |Replay     |    |           |
+-----+-----+    +-----+-----+    +-----+-----+
      |                |                |
      |                |                |   start server
      |                |                | ----+ 
      |                |                | <---+ 
      |                |  bind          |
      |                |----+           |
      |                |<---+           |
      |                |  listen        |
      |                |----+           |
      |                |<---+           |
      |  connect       |                |
      |--------------->|                |
      |  accept        |                |
      |<---------------|                |
      |  send/recv     |                |
      |--------------->|                |
      |                |  connect       |
      |                |--------------->|
      |                |  accept        |
      |                |<---------------|
      |                |  send/recv     |
      |                |--------------->|
      |                |  recv/send     |
      |                |<---------------|
      |                |  close         |
      |                |--------------->|
      |  recv/send     |                |
      |<---------------|                |
      |  close         |                |
      |<---------------|                |
      |                |                |
      |                |                |
```

# 実装

```py:tcp_capture_replay.py
import socket
import urllib

stub_mode = 'replay'
#stub_mode = 'capture'

TCP_IP_LISTEN = "localhost"
TCP_PORT_LISTEN = 60001
TCP_IP_TO = "localhost"
TCP_PORT_TO = 60000
BUFFER_SIZE = 4096
CAPTURE_LOG_FILENAME = 'capture_tcp.log'

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((TCP_IP_LISTEN, TCP_PORT_LISTEN))
sock.listen(5)

cache = []
captured_data = []
captured_data_dic = {}

if stub_mode == 'capture':
    f = open(CAPTURE_LOG_FILENAME, 'a')
elif stub_mode == 'replay':
    f = open(CAPTURE_LOG_FILENAME, 'r')
    captured_data = f.readlines()
    for i in range(len(captured_data)):
        if 'Requested' in captured_data[i]:
           key = urllib.unquote(captured_data[i+3].rstrip())
           value = urllib.unquote(captured_data[i+3+4].rstrip())
           captured_data_dic[key] = value

while True:
    if stub_mode == 'capture':
        # send to remote server
        conn, (host_request, port_request) = sock.accept()

        data_request = ''
        while True:
            tmp = conn.recv(BUFFER_SIZE)
            data_request += tmp
            if b'\r\n\r\n' in tmp or tmp == b'\r\n' or tmp == '':
                break

        print 'Requested:'
        print data_request, host_request, port_request
        print urllib.quote(data_request), host_request, port_request
        print

        f.write(urllib.quote('Requested data') + '\n')
        f.write(urllib.quote(host_request) + '\n')
        f.write(urllib.quote(str(port_request)) + '\n')
        f.write(urllib.quote(data_request) + '\n')
        sock_remote = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP
        sock_remote.connect((TCP_IP_TO, TCP_PORT_TO))
        sock_remote.send(data_request)

        # receive from remote server
        data_response = ''
        while True:
            tmp = sock_remote.recv(BUFFER_SIZE)
            data_response += tmp
            if b'\r\n\r\n' in tmp or tmp == b'\r\n' or tmp == '':
                break

        sock_remote.close()

        f.write(urllib.quote('Responsed data') + '\n')
        f.write(urllib.quote(TCP_IP_TO) + '\n')
        f.write(urllib.quote(str(TCP_PORT_TO)) + '\n')
        f.write(urllib.quote(data_response) + '\n')
        f.write('\n')

        conn.send(data_response)
        print 'responsed:'
        print data_response, TCP_IP_LISTEN, port_request
        print urllib.quote(data_response), TCP_IP_LISTEN, port_request
        print '---------------------------'
        conn.close()

    elif stub_mode == 'replay':
        conn, (host_request, port_request) = sock.accept()

        data_request = ''
        while True:
            tmp = conn.recv(BUFFER_SIZE)
            data_request += tmp
            if b'\r\n\r\n' in tmp or tmp == b'\r\n' or tmp == '':
                break

        print 'Requested:'
        print data_request, host_request, port_request
        print urllib.quote(data_request), host_request, port_request
        print

        try:
            ret_data = captured_data_dic[data_request]
            conn.send(ret_data)
            print 'responsed:'
            print ret_data, TCP_IP_LISTEN, port_request
            print urllib.quote(ret_data), TCP_IP_LISTEN, port_request
            print '---------------------------'
        except:
            ret_data = 'not found'
            print 'ERROR: Key error'
        finally:
            conn.close()

    else:
        print 'error'
        break
```

解説1
socket.SO_REUSEADDRは、多重でbindするための設定。
通常はソケット通信を終わらせる際にきちんとcloseさせるのですが、デバッグ中などは例外で終了してしまいきちんとcloseできないケースが多いので、このフラグをオンにしています。
http://stackoverflow.com/questions/29217502/socket-error-address-already-in-use

解説2
conn.recvは、リクエストの終端が\r\n\r\nであることから、それを受けるまでは受信を続けるようにしています。ただ、クライアントによっては、1回ですべての受信できる場合(通常のブラウザ)や、1行ずつ受信(Telnet)する場合があるので、クアイアントからの送信形態に合わせて、終端を検出するようにしています。
ちなみに、毎回全データの終端をチェックすれば検出パターンは\r\n\r\nだけで済みます。

解説3
HTTPクライアントがResponseを受信できたらコネクションをクローズするようにしています。コネクションを維持させるばあいは、サーバ側から切断される場合もあるので、そのための考慮が必要です。

解説4
TCPはUDPと異なり、コネクションをクローズさせたら、再度ソケットからの作成が必要になります。connectからではダメなようです。

# 準備

PythonのSimpleHTTPServerを使って動作確認をします。

表示するHTMLを準備。

```html:index.html
<HTML><HEAD></HEAD><BODY>TEST</BODY></HTML>
```

SimpleHTTPSeverを起動。

```sh:HTTPサーバ
$ python -m SimpleHTTPServer 60000
Serving HTTP on 0.0.0.0 port 60000 ...
```

キャプチャ＆リプレイを起動。

```sh:キャプチャ＆リプレイ
$ sudo python tcp_capture_replay.py 
```

# 動作確認

適当なHTTPクライアントを用意するのが面倒だったので、TelnetでHTTPリクエストを送信しました。

## Captureモード

```sh:HTTPクアイアントからのRequestとResponse
$ telnet 127.0.0.1  60001
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET /

<HTML><HEAD></HEAD><BODY>TEST</BODY></HTML>
Connection closed by foreign host.
```

```sh:キャプチャ＆リプレイのログ
$ sudo python tcp_capture_replay.py 
Requested:
GET /

127.0.0.1 56607
GET%20/%0D%0A%0D%0A 127.0.0.1 56607

responsed:
<HTML><HEAD></HEAD><BODY>TEST</BODY></HTML>
localhost 56607
%3CHTML%3E%3CHEAD%3E%3C/HEAD%3E%3CBODY%3ETEST%3C/BODY%3E%3C/HTML%3E%0A localhost 56607
```

## Replayモード


```sh:HTTPクアイアントからのRequestとResponse
$ telnet 127.0.0.1  60001
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET /

<HTML><HEAD></HEAD><BODY>TEST</BODY></HTML>
Connection closed by foreign host.
```

```sh:キャプチャ＆リプレイのログ
$ sudo python tcp_capture_replay.py 
Requested:
GET /

127.0.0.1 56611
GET%20/%0D%0A%0D%0A 127.0.0.1 56611

responsed:
<HTML><HEAD></HEAD><BODY>TEST</BODY></HTML>
localhost 56611
%3CHTML%3E%3CHEAD%3E%3C/HEAD%3E%3CBODY%3ETEST%3C/BODY%3E%3C/HTML%3E%0A localhost 56611
```

# 今後
この方法は、SSL上でも使えますし、SOAPやRESTでも使える方法なので、応用していきたいと思います。

# 参考
https://docs.python.org/2/howto/sockets.html
http://qiita.com/higuma/items/b23ca9d96dac49999ab9
http://stackoverflow.com/questions/29217502/socket-error-address-already-in-use
http://memo.saitodev.com/home/python_network_programing/