PROXY protocol を使ってNLB配下のサーバーでクライアントの Remote IP を得る

背景

  1. goproxy を使って HTTP Proxy server を作った
  2. その Proxy server は AWS Fargate で動作していてNLBでロードバランシングされている
  3. NLBはターゲットタイプがインスタンス以外だとRemote IPがNLBのものになってしまう
  4. Proxy server でクライアントのRemote IPがわからず困る

どうするか

goproxy をEC2で動かして、NLBのターゲットタイプをインスタンスにする、というのが手っ取り早いのですが、どうやら PROXY protocol というものがあり、 それを使うとこの問題を解決できるようです。

PROXY protocol ってなに

要はHTTPにおける X-Forwarded-For ヘッダーに相当するもので、TCP/IPベースの任意のプロトコルでクライアントのRemote IPなどの情報を付与するものです。 PROXY Protocolにはバージョン1とバージョン2があり、前者はテキスト、後者はバイナリです。両者ともTCP(UDP)パケットのペイロードに挿入されます。

PROXY protocol v1

以下の形式のプレーンテキストです。

PROXY_STRING + single space + INET_PROTOCOL + single space + CLIENT_IP + single space + PROXY_IP + single space + CLIENT_PORT + single space + PROXY_PORT + "\r\n"

https://developers.cloudflare.com/spectrum/getting-started/proxy-protocol/

たとえば以下のようになります。

PROXY TCP4 192.0.2.0 192.0.2.255 42300 443\r\n
  • INET_PROTOCOL: TCP over IPv4
  • CLIENT_IP: 192.0.2.0
  • PROXY_IP: 192.0.2.255
  • CLIENT_PORT: 42300
  • PROXY_PORT: 443

PROXY protocol v2

バイナリで、プロトコルTCP over IPv4 の場合は以下のようなレイアウトになっています。

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                                                               +
|                  Proxy Protocol v2 Signature                  |
+                                                               +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version|Command|   AF  | Proto.|         Address Length        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                      IPv4 Source Address                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    IPv4 Destination Address                   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Source Port          |        Destination Port       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

https://developers.cloudflare.com/spectrum/getting-started/proxy-protocol/

たとえば以下のようになります。

00000000  0D 0A 0D 0A 00 0D 0A 51  55 49 54 0A 21 11 00 0C  |.......QUIT.!...|
00000010  C0 A8 64 C8 C0 A8 64 C9  D8 6F 1F 90              |..d...d..o..|
  • Proxy Protocol v2 Signature: 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A
  • Version: 2(0x2)
  • Command: PROXY(0x1)
  • AF: IPv4(0x1)
  • Proto.: TCP over IPv4(0x1)
  • Address Length: 12 byte(0x00, 0x0C)
  • IPv4 Source Address: 192.168.100.200(0xC0, 0xA8, 0x64, 0xC8)
  • IPv4 Destination Address: 192.168.100.201(0xC0, 0xA8, 0x64, 0xC9)
  • Source Port: 55407(0xD8, 0x6F)
  • Destination Port: 8080(0x1F, 0x90)

Go言語で PROXY protocol をパースする

Go言語の場合、pires/go-proxyproto というライブラリを使うのが楽です。

server.go:

package main

import (
    "log"
    "net"

    "github.com/pires/go-proxyproto"
)

func main() {
    addr := "127.0.0.1:8080"
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        log.Fatalf("couldn't listen to %q: %q\n", addr, err.Error())
    }

    ppListener := &proxyproto.Listener{Listener: listener}
    defer ppListener.Close()

    conn, _ := ppListener.Accept()
    defer conn.Close()

    if conn.LocalAddr() == nil {
        log.Fatal("couldn't retrieve local address")
    }
    log.Printf("local address: %q", conn.LocalAddr().String())

    if conn.RemoteAddr() == nil {
        log.Fatal("couldn't retrieve remote address")
    }
    log.Printf("remote address: %q", conn.RemoteAddr().String())
}

ppListener := &proxyproto.Listener{Listener: listener} で生の net.Listener をラップすることで PROXY protocol を解析できます。 ppListenernet.Listener 互換のインターフェイスを持っているため、後続のコードでは proxyproto を使っているかどうかを気にせず使用できます(conn.RemoteAddr() などが IPヘッダのものではなく、PROXY Protocol によって指定されたものになる)。

このサーバーに PROXY protocol のヘッダを付与して通信してみます。

client.go:

package main

import "net"

func main() {
    ppv2 := []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A} // \r\n \r\n \0 \r\n QUIT \n
    ppv2 = append(ppv2, []byte{0x21}...)                                                   // Version 2, PROXY command
    ppv2 = append(ppv2, []byte{0x11}...)                                                   // AF_INET, SOCK_STREAM
    ppv2 = append(ppv2, []byte{0x00, 0x0C}...)                                             // Src/Dst addr/port length
    ppv2 = append(ppv2, []byte{0xC0, 0xA8, 0x64, 0xC8}...)                                 // Src addr: 192.168.100.200
    ppv2 = append(ppv2, []byte{0xC0, 0xA8, 0x64, 0xC9}...)                                 // Dst addr: 192.168.100.201
    ppv2 = append(ppv2, []byte{0xD8, 0x6F}...)                                             // Src port: 55407
    ppv2 = append(ppv2, []byte{0x1F, 0x90}...)                                             // Dst port: 8080

    addr := "127.0.0.1:8080"
    conn, err := net.Dial("tcp", addr)
    defer conn.Close()
    if err != nil {
        panic(err)
    }

    conn.Write(ppv2)
}

結果:

2020/07/31 15:40:19 local address: "192.168.100.201:8080"
2020/07/31 15:40:19 remote address: "192.168.100.200:55407"

PROXY protocol で指定したアドレスになっていることが確認できました。

NLBで PROXY protocol を有効にする

NLBではターゲットグループで以下の設定を有効にすることにより、NLB配下のサーバーに PROXY protocol を付与することができます。

f:id:mrk21:20200801195225p:plain f:id:mrk21:20200801195300p:plain

注意点としては、ヘルスチェックするときも PROXY protocol のヘッダを付与するので、ヘルスチェックで指定したエンドポイントでも PROXY protocol を考慮する必要があります。

まとめ

PROXY protocol を利用することによって、TCP/IP上の任意のプロトコルでクライアントのIPを取得することができます。 しかし、PROXY protocol を有効にしたサーバーをインターネット上に公開すると外部からアクセス元が改竄できてしまうので注意が必要です。 また、Go言語で PROXY protocol を使うサンプルは https://github.com/mrk21/sandbox/tree/master/go-go-proxyproto にあります。

参考