go-mmproxy という Proxy Protocol をサポートするプロキシの動作確認、理解をします。

Proxy Protocol とは

Proxy Protocol とはプロキシを介した TCP 通信においてサーバに本来のクライアント IP を伝えるためのプロトコルです。

一般的にプロキシ、リバースプロキシを介した通信ではサーバ側で本来のクライアント IP を知ることができません (サーバ側では送信元をプロキシだと認識してしまう)。これはよくロードバランサを介してリクエストを受けるような場合に問題になったりします。HTTP に限定すれば X-Forwarded-For のようなヘッダによりこの問題を回避できることが多いですが、TCP のレイヤとして広く使われる手法は無いかと思います。

HAProxy が提唱した (?) Proxy Protocol というのはそれを解決するための仕様であり、TCP パケットに追加の情報を含めることで元のクライアント情報をサーバに伝えることができます。プロトコル自体は単純なものですが、一つ問題として通信の両端 (例えばプロキシとアプリケーションサーバ) が Proxy Protocol を理解する必要があるので、特にサーバ側でそれを用意するのが容易ではない場合もあります。

go-mmproxy とは

go-mmproxymmproxy という Proxy Protocol を理解するプロキシの Go 実装です。上述したように Proxy Protocol は通信の両端で実装する必要があるので、go-mmproxy はそのためにサーバ側に配置する薄い TCP リバースプロキシとして機能します。

README のなかで go-mmproxy を動かす手順は以下のように説明されています。

  1. ip rule add from 127.0.0.1/8 iif lo table 123
  2. ip route add local 0.0.0.0/0 dev lo table 123
  3. ./go-mmproxy -l 0.0.0.0:<listen_port> -4 127.0.0.1:<server_port> --allowed-subnets ./path-prefixes.txt

これを動かすと確かに意図した通りにサーバでクライアント IP が取れるのですが、具体的にどういう通信が行われているのかが初見でわからなかったので、ソースや実際のパケット等を見て理解していきたいと思います。

以下の内容は こちら に用意したローカルで動作確認できる環境で試しています。

$ docker-compose up -d --build

# client コンテナで bash 起動
$ docker container exec -it <client_container> /bin/bash 

# HAProxy に接続
$ nc -v 172.21.0.3 8080

# 送信した文字列がそのままサーバから返ってくる
abcdefg
abcdefg

環境の概要は以下です。server 通信用に client では bash や nc を起動し、haproxy のポート 8080 に接続します。haproxy は backend として server のポート 8080 を登録しており、そこで待ち受ける mmproxy が最終的に同ホスト上の server プログラム (main) にリクエストを渡すという形です。

network-of-mmproxy-sample

HAProxy, mmproxy 間

まずは HAProxy, mmproxy 間の通信を確認します。これは Proxy Protocol を利用する箇所で、逆にいうとこれ以外のコンポーネント間の通信では Proxy Protocol の存在は意識されません。

パケットキャプチャ

HAProxy, mmproxy 間の送信内容をキャプチャして Proxy Protocol を利用したパケットを確認しました。

ざっと一連のパケットを見た感じ、Proxy Protocol を含むパケットは 3-way handshake の直後に送信された 1 パケットのみで、それ以外は通常の TCP 通信と変化がありませんでした。

# 3-way handshake のあとに haproxy -> mmproxy に送信されるパケット
# 0x42 以降が TCP payload
0000   02 42 ac 15 00 04 02 42 ac 15 00 03 08 00 45 00   .B.....B......E.
0010   00 50 73 c3 40 00 40 06 6e b3 ac 15 00 03 ac 15   .Ps.@[email protected].......
0020   00 04 b9 20 1f 90 02 35 ab 08 67 19 bf ab 80 18   ... ...5..g.....
0030   01 f6 58 74 00 00 01 01 08 0a 9d d9 bb 1f 04 f6   ..Xt............
0040   d2 91 0d 0a 0d 0a 00 0d 0a 51 55 49 54 0a 21 11   .........QUIT.!.
0050   00 0c ac 15 00 02 ac 15 00 03 dd 3a 1f 90         ...........:..

Proxy Protocol の仕様は The PROXY protocol です。これに基づいて TCP payload 部分は以下のように解釈できます。

  • at 0x0042-0x004d: 0x0D,0x0A,0x0D,0x0A,0x00,0x0D,0x0A,0x51,0x55,0x49,0x54,0x0A
    • Proxy Protocol version 2 のシグネチャ
  • at 0x004e: 0x21
    • 上位 4 bits が header の version (ここでは 0x2)
    • 下位 4 bits が 0x1 だと original sender を Proxy Protocol で表現していることになる
  • at 0x004f: 0x11
    • 上位 4 bits が address family (ここでは 0x1)
      • 0x1 だと AF_INET
      • 0x2 だと AF_INET6
      • 0x3 だと AF_UNIX
    • 下位 4 bits が protocol (ここでは 0x1)
      • 0x1 だと STREAM
      • 0x2 だと DGRAM
  • at 0x0050-0x0051: 0x000c
    • 残りの Proxy Protocol データが何 bytes あるか
  • at 0x0052-0x005d: source, destination
    • 次の情報が順番に network byte order で含まれている
      • source layer 3 address (ここでは 172.21.0.2)
      • destination layer 3 address (ここでは 172.21.0.3)
      • source layer 4 address (TCP の場合 port, ここでは 56634)
      • destination layer 4 address (ここでは 8080)

このように TCP payload として元のクライアント情報が含まれるので Proxy Protocol を理解する同士であればその情報を保持して通信できることがわかります。問題はそれをどのように Proxy Protocol のことを知らないサーバに伝えるかということで、それを次に見ていきます。

mmproxy, server 間

ここでは mmproxy, server 間の通信の様子を見ていきます。先に構成図で示したように、 mmproxy と server は同一ホストに存在するものとし、また mmproxy は Proxy Protocol を理解できるけれど server はそうではないという想定です。

go-mmproxy のソース

go-mmproxy がプロキシとして何をしているかというと以下に示すように端的には 2 つの接続間でデータの受け渡しをしているだけです。conn は haproxy からの通信、upstreamConn は server への通信に相当します。

# https://github.com/path-network/go-mmproxy/blob/master/tcp.go
func tcpHandleConnection(conn net.Conn, logger *zap.Logger) {
    ...
	go tcpCopyData(upstreamConn, conn, outErr)
	go tcpCopyData(conn, upstreamConn, outErr)
    ...
}

func tcpCopyData(dst net.Conn, src net.Conn, ch chan<- error) {
	_, err := io.Copy(dst, src)
	ch <- err
}

ただもちろん本当にそれだけだと server 側には go-mmproxy からの通信として見えてしまうので、その部分の情報を書き換える必要があります。go-mmproxy では Proxy Protocol 部分を抜き出して upstreamConn 作成時の LocalAddr として本来のクライント IP, Port を設定し、残りのデータはそのまま server に送信します。

# https://github.com/path-network/go-mmproxy/blob/master/tcp.go
func tcpHandleConnection(conn net.Conn, logger *zap.Logger) {
    ...
	dialer := net.Dialer{LocalAddr: saddr}
	if saddr != nil {
		dialer.Control = DialUpstreamControl(saddr.(*net.TCPAddr).Port)
	}
	upstreamConn, err := dialer.Dial("tcp", targetAddr)
	if err != nil {
		logger.Debug("failed to establish upstream connection", zap.Error(err), zap.Bool("dropConnection", true))
		return
	}
    ...
}

ソケット

net.Dialer{LocalAddr: saddr} のような Dialer からどのようなソケットが作成されるのかわからなかったので、client が server に接続する前後のソケットの様子を ss により確認しました。

# server 接続前
$ ss -tnap
State       Recv-Q      Send-Q           Local Address:Port            Peer Address:Port
LISTEN      0           4096                127.0.0.11:36425                0.0.0.0:*
LISTEN      0           4096                         *:18080                      *:*         users:(("main",pid=118,fd=3))
LISTEN      0           4096                         *:8080                       *:*         users:(("go-mmproxy",pid=13,fd=3))

# server 接続後
$ ss -tnap
State    Recv-Q   Send-Q            Local Address:Port               Peer Address:Port
LISTEN   0        4096                 127.0.0.11:36425                   0.0.0.0:*
ESTAB    0        0                    172.21.0.2:56634                 127.0.0.1:18080    users:(("go-mmproxy",pid=13,fd=8))
LISTEN   0        4096                          *:18080                         *:*        users:(("main",pid=118,fd=3))
LISTEN   0        4096                          *:8080                          *:*        users:(("go-mmproxy",pid=13,fd=3))
ESTAB    0        0            [::ffff:127.0.0.1]:18080       [::ffff:172.21.0.2]:56634    users:(("main",pid=118,fd=4))
ESTAB    0        0           [::ffff:172.21.0.4]:8080        [::ffff:172.21.0.3]:47392    users:(("go-mmproxy",pid=13,fd=7))
  • mmproxy -> server のソケットは 2 行目の 172.21.0.2:51198 -> 127.0.0.1:18080
  • server -> mmproxy のソケットは 6 行目の [::ffff:127.0.0.1]:18080 -> [::ffff:172.21.0.2]:51198

ということで Local Address に自 IP ではない IP を持つ一見不思議なソケットが作成されるようです。ただそのおかげで server 目線では 172.21.0.2 という本来のクライアント IP, Port から通信が直接来たかのように見えます。

ルーティング

ここまでで mmproxy -> server にどのようにクライアント情報を伝えるかは理解できました。残る問題はどのように server から client まで情報を戻すかです。そのために go-mmproxy のセットアップ手順では事前に以下のルーティング設定を加えるように書かれていました。

$ ip rule add from 127.0.0.1/8 iif lo table 123
$ ip route add local 0.0.0.0/0 dev lo table 123

ip rule ... は policy ベースの routing を行なうときに使用されるもので、どのルーティングテーブルを使用するかのルールを設定するためのものです。例えばデフォルトでは以下のようなルールが設定されていました。

$ ip rule
0:      from all lookup local
32766:  from all lookup main
32767:  from all lookup default

ルールには優先度 (一番左の数字) があり、優先度が高いもの (値の小さいもの) からマッチングされます。

ip rule add from 127.0.0.1/8 iif lo table 123 は「送信元 IP が 127.0.0.1/8 で lo interface から来たパケットは 123 番のテーブルを参照する」というルールを加えていることになります。ここでは優先度を指定していないので、使用されていない最低の優先度で設定されます (ここでは 32765)。

$ ip rule
0:      from all lookup local
32765:  from 127.0.0.1/8 iif lo lookup 123
32766:  from all lookup main
32767:  from all lookup default

続いて ip route ... というのはルーティングテーブル内のエントリを操作するコマンドです。対象ルーティングテーブルを指定しない場合は暗黙的に main が使用されます。例えば server コンテナの main ルーティングテーブルははじめ以下のようなエントリを持っていました。

$ ip route show table main
default via 172.21.0.1 dev eth0
172.21.0.0/16 dev eth0 proto kernel scope link src 172.21.0.4

ip route add local 0.0.0.0/0 dev lo table 123 実行後、 ルーティングテーブル 123 の内容は以下のようになります。

$ ip route show table 123
local default dev lo scope host

ip-route の man ページを見た感じ意味合いとしては、

  • local
    • ルートタイプが local (宛先が自身である) という指定
  • 0.0.0.0/0
    • 対象とする宛先 IP (のレンジ)
  • dev lo
    • 出力 interface 名
  • table 123
    • routing table の指定

なのでこの table にやってきた全てのパケットを localhost に戻すという意図だと思います。

この 2 つのルーティング設定を入れることでどうして mmproxy を経由できるのか、が一番理解の難しい箇所です。正直まだ理解が怪しいのですが、追加した rule の iif lo に着目しています。これは ip-rule の man ページいわく、ホスト自身から送出されるパケットを対象とするための条件とのことですが、言い換えると ss で見たときに Local Address が localhost となるような場合なんだと思っています。例えば自分自身に ping を送るとかはわかりやすくそうですし、今回の場合でいうと 127.0.0.1:18080 -> 172.21.0.2:51198 のようなソケットも Local Address が 127.0.0.1 なので条件に当てはまります。

自分が混乱しているのはパケット送出時にいつ使用するネットワークインタフェースが決定されるかというところです。自分からパケットを送信するときには ip route get <address> で確認できるのと同様の仕組みで送信先のアドレスに基づいてルーティングテーブルにより決定されると思っています。一方でサーバとしてリクエスト受ける場合、bind しているリクエストを受けたアドレスによってルーティングテーブルを参照する前から使用するインタフェースが決まるということなのかなと思っています。そうでないと今回の場合は送信先アドレスを見て lo ではなく eth0 のようなインタフェースを選んでしまうはずなので。

今回のようなソケットを介してレスポンスを返す際のルーティングテーブルの参照の仕方は ip route get from 127.0.0.1 to 172.21.0.2 と同等なのかなと考えており、この場合確かに今回の設定を加えないと RTNETLINK answers: Invalid argument となり解決されなくなります。

パケットキャプチャ

最期に mmproxy, server 間のパケットキャプチャした結果を載せます。ここでは server コンテナ上で lo interface のパケットを tcpdump により見ており、前に ss 出力で見たように client の 172.21.0.2:55136 と server の 127.0.0.1:18080 間の通信の様子が確認できます。

$ tcpdump -i lo -vvv -n -x -X

# 3-way handshake
09:17:30.038943 IP (tos 0x0, ttl 64, id 28265, offset 0, flags [DF], proto TCP (6), length 60)
    172.21.0.2.56634 > 127.0.0.1.18080: Flags [S], cksum 0x2b47 (incorrect -> 0x0216), seq 1814517112, win 65495, options [mss 65495,sackOK,TS val 2803340355 ecr 0,nop,wscale 7], length 0
        0x0000:  4500 003c 6e69 4000 4006 a13a ac15 0002  E..<ni@.@..:....
        0x0010:  7f00 0001 dd3a 46a0 6c27 5578 0000 0000  .....:F.l'Ux....
        0x0020:  a002 ffd7 2b47 0000 0204 ffd7 0402 080a  ....+G..........
        0x0030:  a717 9443 0000 0000 0103 0307            ...C........
09:17:30.038952 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    127.0.0.1.18080 > 172.21.0.2.56634: Flags [S.], cksum 0x2b47 (incorrect -> 0x533b), seq 2714018509, ack 1814517113, win 65483, options [mss 65495,sackOK,TS val 3631649228 ecr 2803340355,nop,wscale 7], length 0
        0x0000:  4500 003c 0000 4000 4006 0fa4 7f00 0001  E..<..@.@.......
        0x0010:  ac15 0002 46a0 dd3a a1c4 a2cd 6c27 5579  ....F..:....l'Uy
        0x0020:  a012 ffcb 2b47 0000 0204 ffd7 0402 080a  ....+G..........
        0x0030:  d876 91cc a717 9443 0103 0307            .v.....C....
09:17:30.038957 IP (tos 0x0, ttl 64, id 28266, offset 0, flags [DF], proto TCP (6), length 52)
    172.21.0.2.56634 > 127.0.0.1.18080: Flags [.], cksum 0x2b3f (incorrect -> 0x79f6), seq 1, ack 1, win 512, options [nop,nop,TS val 2803340356 ecr 3631649228], length 0
        0x0000:  4500 0034 6e6a 4000 4006 a141 ac15 0002  E..4nj@[email protected]....
        0x0010:  7f00 0001 dd3a 46a0 6c27 5579 a1c4 a2ce  .....:F.l'Uy....
        0x0020:  8010 0200 2b3f 0000 0101 080a a717 9444  ....+?.........D
        0x0030:  d876 91cc                                .v..

# client -> server への "abcdef" というメッセージの送信
09:17:39.772666 IP (tos 0x0, ttl 64, id 28269, offset 0, flags [DF], proto TCP (6), length 60)
    172.21.0.2.56634 > 127.0.0.1.18080: Flags [P.], cksum 0x2b47 (incorrect -> 0xa4c7), seq 2:10, ack 2, win 512, options [nop,nop,TS val 2803350089 ecr 3631656876], length 8
        0x0000:  4500 003c 6e6d 4000 4006 a136 ac15 0002  E..<nm@[email protected]....
        0x0010:  7f00 0001 dd3a 46a0 6c27 557a a1c4 a2cf  .....:F.l'Uz....
        0x0020:  8018 0200 2b47 0000 0101 080a a717 ba49  ....+G.........I
        0x0030:  d876 afac 6162 6364 6566 670a            .v..abcdefg.
09:17:39.772675 IP (tos 0x0, ttl 64, id 25383, offset 0, flags [DF], proto TCP (6), length 52)
    127.0.0.1.18080 > 172.21.0.2.56634: Flags [.], cksum 0x2b3f (incorrect -> 0x2de1), seq 2, ack 10, win 512, options [nop,nop,TS val 3631658962 ecr 2803350089], length 0
        0x0000:  4500 0034 6327 4000 4006 ac84 7f00 0001  E..4c'@.@.......
        0x0010:  ac15 0002 46a0 dd3a a1c4 a2cf 6c27 5582  ....F..:....l'U.
        0x0020:  8010 0200 2b3f 0000 0101 080a d876 b7d2  ....+?.......v..
        0x0030:  a717 ba49                                ...I

# server -> client への "abcdef" というメッセージの送信
09:17:39.772712 IP (tos 0x0, ttl 64, id 25384, offset 0, flags [DF], proto TCP (6), length 60)
    127.0.0.1.18080 > 172.21.0.2.56634: Flags [P.], cksum 0x2b47 (incorrect -> 0x9c99), seq 2:10, ack 10, win 512, options [nop,nop,TS val 3631658962 ecr 2803350089], length 8
        0x0000:  4500 003c 6328 4000 4006 ac7b 7f00 0001  E..<c(@.@..{....
        0x0010:  ac15 0002 46a0 dd3a a1c4 a2cf 6c27 5582  ....F..:....l'U.
        0x0020:  8018 0200 2b47 0000 0101 080a d876 b7d2  ....+G.......v..
        0x0030:  a717 ba49 6162 6364 6566 670a            ...Iabcdefg.
09:17:39.772715 IP (tos 0x0, ttl 64, id 28270, offset 0, flags [DF], proto TCP (6), length 52)
    172.21.0.2.56634 > 127.0.0.1.18080: Flags [.], cksum 0x2b3f (incorrect -> 0x2dd9), seq 10, ack 10, win 512, options [nop,nop,TS val 2803350089 ecr 3631658962], length 0
        0x0000:  4500 0034 6e6e 4000 4006 a13d ac15 0002  E..4nn@.@..=....
        0x0010:  7f00 0001 dd3a 46a0 6c27 5582 a1c4 a2d7  .....:F.l'U.....
        0x0020:  8010 0200 2b3f 0000 0101 080a a717 ba49  ....+?.........I
        0x0030:  d876 b7d2                                .v..

このように server からのレスポンスは宛先が 172.21.0.2 なのに lo interface を経由していることがわかります。

これが mmproxy にちゃんと戻ってくるのが少し不思議というかカーネルがパケットを受け取ってからどのように宛先のソケットを選択するかの自身の理解が足りない部分ですが、ss でいう (Local Address, Port) と (Peer Address, Port) の組み合わせを見て一致するものがあればたとえ Local Address が自 IP でなくてもそこに戻せるということなんだと思います。