Rackアプリケーションでプライベートネットワークからのプロキシサーバー越しのリクエストから送信元IPアドレスを得る


# 最初に結論

## 条件

1. クライアントがプライベートネットワークにいる
2. プロキシサーバーとRailsアプリケーションをUNIXドメインソケットで接続している

## 現象

Rackのrequest.ipやRailsのrequest.remote_ipで、送信元IPアドレスが取れません。

## 対策
1. Rack::Requestにモンキーパッチを当てる
2. X-Forwarded-Forヘッダーから直接取得する
3. Railsなら、config.action_dispatch.trusted_proxiesを変更する

のいずれかの方法で取れます。

# 構成
**クライアント** - **nginx** - **Rackアプリケーション**

これだけならば、割と一般的な構成です。

ただし、クライアントはプライベートネットワークにいます。
つまり送信元IPアドレスはプライベートIPアドレスです。

また、nginxとRackアプリケーションは何らかの方法でプロセス間通信します。
Rackアプリケーションから見える送信元IPアドレスはnginxのIPアドレスです。
クライアントの送信元IPアドレスではありません。

大抵、nginxはX-Forwarded-Forヘッダーに送信元IPアドレスを格納しRackアプリケーションに伝えます。

# 状況説明1 Rack編
## プロキシは送信元IPアドレスをX-Forwarded-Forヘッダーに格納する

[X-Forwarded-For - Wikipedia](https://ja.wikipedia.org/wiki/X-Forwarded-For)

> X-Forwarded-For (XFF) とは、HTTPヘッダフィールドの一つ。HTTPプロキシサーバまたは負荷分散装置（ロードバランサ）を経由してウェブサーバに接続するクライアントの送信元IPアドレスを特定する際のデファクトスタンダードである

nginxでは[proxy_set_header](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header)ディレクティブを使い

```
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```

を設定すると、送信元IPアドレスを`X-Forwarded-For`ヘッダーに追加できます。

## RackはリクエストのX-Forwarded-Forヘッダーを参照する

[Rack::Request#ip](https://github.com/rack/rack/blob/2-0-stable/lib/rack/request.rb#L256)は

```rb
def ip
  remote_addrs = split_ip_addresses(get_header('REMOTE_ADDR'))
  remote_addrs = reject_trusted_ip_addresses(remote_addrs)

  return remote_addrs.first if remote_addrs.any?

  forwarded_ips = split_ip_addresses(get_header('HTTP_X_FORWARDED_FOR'))

  return reject_trusted_ip_addresses(forwarded_ips).last || get_header("REMOTE_ADDR")
end
```

X-Forwarded-Forヘッダーを参照して送信元IPアドレスを取得します。


## RackはリクエストのX-Forwarded-ForヘッダーのプライベートIPを無視する

しかし、Rack::Request#ipではX-Forwarded-Forヘッダーに対して、`reject_trusted_ip_addresses`という処理をします。



```rb
forwarded_ips = split_ip_addresses(get_header('HTTP_X_FORWARDED_FOR'))

return reject_trusted_ip_addresses(forwarded_ips).last || get_header("REMOTE_ADDR")
```

reject_trusted_ip_addressesでは`trusted_proxy?`なアドレスを消します。

```rb
def reject_trusted_ip_addresses(ip_addresses)
  ip_addresses.reject { |ip| trusted_proxy?(ip) }
end
```

`trusted_proxy?`なアドレスはプライベートIPアドレスを表現した正規表現です。

```rb
def trusted_proxy?(ip)
  ip =~ /\A127\.0\.0\.1\Z|\A(10|172\.(1[6-9]|2[0-9]|30|31)|192\.168)\.|\A::1\Z|\Afd[0-9a-f]{2}:.+|\Alocalhost\Z|\Aunix\Z|\Aunix:/i
end
```

## 再現実験
### 実験用Rackアプリケーション
次のようなRackアプリケーションを用意します。


```rb:my_rack_app.rb
#! /usr/bin/env ruby

require 'rack'

app = Proc.new do |env|
  request = Rack::Request.new env
  ['200', {'Content-Type' => 'text/html'}, ["request.ip #{request.ip}, X-Forwarded-For #{env['HTTP_X_FORWARDED_FOR']}"]]
end

Rack::Handler::WEBrick.run app, {Host: '0.0.0.0'}
```

```Gemfile
source 'https://rubygems.org'

gem 'rack'
```

```Dockerfile
FROM ruby:2.4-alpine

COPY Gemfile /

RUN bundle install --clean

COPY my_rack_app.rb /

CMD ["./my_rack_app.rb"]
```


### 起動

```
chmod +x my_rack_app.rb
docker build -t my-ruby-app .
docker run --rm -p 80:8080 my-ruby-app
```

### 実験
request.ipは

#### X-Forwarded-ForヘッダーがなければリモートIPを返す

```
curl localhost                                                                                                                                                                   
request.ip 172.17.0.1, X-Forwarded-For
```

#### X-Forwarded-ForヘッダーにグローバルIPアドレスがあればそれを返す

```
curl --header "X-Forwarded-For: 1.2.3.4" localhost
request.ip 1.2.3.4, X-Forwarded-For 1.2.3.4
```

#### X-Forwarded-Forヘッダー中のプライベートIPアドレスを無視する

```
curl --header "X-Forwarded-For: 192.168.0.200" localhost
request.ip 172.17.0.1, X-Forwarded-For 192.168.0.200
```

```
curl --header "X-Forwarded-For: 192.168.0.200 10.0.0.1 1.2.3.4" localhost
request.ip 1.2.3.4, X-Forwarded-For 192.168.0.200 10.0.0.1 1.2.3.4
```

#### 172.17.0.1 の補足
[Docker コンテナ・ネットワークの理解 — Docker-docs-ja 1.13.RC ドキュメント](Docker コンテナ・ネットワークの理解 — Docker-docs-ja 1.13.RC ドキュメント)

> Docker をインストールした全ての環境には、 docker0 と表示されるブリッジ（ bridge ）ネットワークが現れます。オプションで docker run --net=<ネットワーク名> を指定しない限り、Docker デーモンはデフォルトでこのネットワークにコンテナを接続します

`docker network inspect bridge`コマンドでbridgeのネットワーク構成を見ると

```
[
    {
        "Name": "bridge",
        "Id": "f167ffba2da56ee97e2de8dbfb20831061a0e82c3ba56c4f20d1db1aea3d1ee3",
        "Created": "2017-03-02T03:17:55.885274307Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "5195b48d88a4db4a05411cf9c0f61b069394b15b5269662eed87914be9ca2c6a": {
                "Name": "eager_meninsky",
                "EndpointID": "a53f8f239f37ac487d47e643e9d125775ad7facb88d91da96c6c4049067f5bac",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]
```

`"Gateway": "172.17.0.1"`と表示されています。
ホストOSからのリクエストはこのGatewayとして送られます。
このため送信元IPアドレスは`172.17.0.1`です。

## 状況のまとめ

Rackは送信元IPアドレスを取得するために、X-Forwarded-Forヘッダーを見ます。
ただしその中のプライベートIPアドレスは無視します。

これは一般的なインターネットからのアクセスを想定した場合、大変うまく動きます。
例えば

**クライアント** - **ELB** - **nginx** - **Rackアプリケーション**

という構成の場合、プライベートネットワーク中のプロキシサーバーが何台居ても、気にする必要がありません。

# 状況説明2 nginx編

先ほどの実験では`request.ip`で送信元IPアドレスが取得できています。
`REMOTE_ADDR`変数に送信元IPアドレスが格納されており、`request.ip`で取れます。
特に問題ないように思えます。

プロキシサーバーを介した場合、送信元IPアドレスが失われます。

## UNIXドメインソケット vs TCP/IP

プロキシサーバーとアプリケーションサーバを接続する方法にはUNIXドメインソケットとTCP/IPを使う方法があります。

[UNIXドメインソケット - Wikipedia](https://ja.wikipedia.org/wiki/UNIX%E3%83%89%E3%83%A1%E3%82%A4%E3%83%B3%E3%82%BD%E3%82%B1%E3%83%83%E3%83%88)

> UNIXドメインソケット（英: UNIX domain socket）や IPCソケット とは、単一のオペレーティングシステム内で実行されるプロセス間でデータを交換するためのデータ通信の終点

UNIXドメインソケットは単一OS内のプロセス間通信の仕組みです。
単一OS上のプロセス間でしか使えませんが、TCP/IPレイヤーを介しない分高速に動作します。

http://serverfault.com/a/124518
> UNIX domain sockets know that they’re executing on the same system, so they can avoid some checks and operations (like routing); which makes them faster and lighter than IP sockets. So if you plan to communicate with processes on the same host, this is a better option than IP sockets.


パフォーマンスを求める場合TCP/IP接続よりUNIXドメインソケットを使いたいです。

nginxとアプリケーションサーバのプロセス間通信が、システム全体のボトルネックに成るとは限りません。一般にはDBなどのストレージアクセスやRubyでのロジックの方が時間が掛かるでしょう。絶対に、UNIXドメインソケットを使わなければいけないわけではありません。それでも、より高速な接続方式が選択可能である方が嬉しいです。

## TCP/IP接続の場合

まず、TCP/IP接続での送信元IPアドレスの伝達を試してみましょう。

### 準備
ファイルを二つ追加します。

```docker-compose.yml
version: '2'
services:
  nginx:
    depends_on:
      - web
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./log:/var/log/nginx
    volumes_from:
      - web
  web:
    build: .
```

```nginx.conf
events {}
http {
  server {
    listen 80;
    location / {
      proxy_pass http://web:8080;
    }
  }
}
```

```
docker-compose build
```

を実行して環境を作成します。

### 起動

```
docker-compose up
```
を実行すると

```
Starting racktest_web_1
Starting racktest_nginx_1
Attaching to racktest_web_1, racktest_nginx_1
web_1    | [2017-03-03 12:25:21] INFO  WEBrick 1.3.1
web_1    | [2017-03-03 12:25:21] INFO  ruby 2.4.0 (2016-12-24) [x86_64-linux]
web_1    | [2017-03-03 12:25:21] INFO  WEBrick::HTTPServer#start: pid=1 port=8080
```

と表示されます。
`racktest`はdocker-composeを実行しているフォルダ名によって変わります。

### 実行
ブラウザで`localhost`に接続すると

```
request.ip 172.19.0.3, X-Forwarded-For
```

と表示されます。
request.ipが`172.19.0.3`に変わりました。

### docker network

```
docker network ls
```
を実行します。

```
NETWORK ID          NAME                                    DRIVER              SCOPE
f167ffba2da5        bridge                                  bridge              local
9e8aa4450aea        host                                    host                local
251cb96e4df9        none                                    null                local
7c644f2bd292        racktest_default                        bridge              local
```
`racktest_default`のようなネットワークが増えています。

```
docker network inspect racktest_default
```

を実行します。

```
[
    {
        "Name": "racktest_default",
        "Id": "7c644f2bd292368f911d7540c3ac8350cf8102c7825e82a658ecb0d838ac2436",
        "Created": "2017-03-02T10:58:57.837391679Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.19.0.0/16",
                    "Gateway": "172.19.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "46dc8476b81c5a44aa64413c81446318171202998ce66845e96ab1d40e755e6f": {
                "Name": "racktest_nginx_1",
                "EndpointID": "77c1b80018d6f643a9919a3e8154a5edd5d3c5a5fc92025fecdbbb3d372e2968",
                "MacAddress": "02:42:ac:13:00:03",
                "IPv4Address": "172.19.0.3/16",
                "IPv6Address": ""
            },
            "afc24a6875fe836c8bcb5f66d67fbd3bde609e1b59312d02cb9559b5ac3b3956": {
                "Name": "racktest_web_1",
                "EndpointID": "249d00c019582f72715a1b75e0e94beec12d8e987c583cf86db6b4befe64bac6",
                "MacAddress": "02:42:ac:13:00:02",
                "IPv4Address": "172.19.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]
```
`"Name": "racktest_nginx_1`というコンテナの`"IPv4Address": "172.19.0.3/16"`という値をみます。
nginxのIPアドレスが`172.19.0.3`という意味です。
送信元IPアドレス`172.19.0.1`は失われました。

## UNIXドメインソケット接続の場合
### 準備
WebrickはUNIXドメインソケットが使えなさそうなので、thinを使います。
今までのRackアプリケーションと共存するために`thin`ディレクトリ配下にファイルを作成します。

thinはeventmachineというgemに依存しています。
eventmachineをgem installするには、いろいろ物入りです。

```thin/Dockerfile
FROM ruby:2.3.3-alpine

ENV BUILD_PACKAGES="ruby-dev build-base"

RUN apk update && \
    apk upgrade && \
    apk add --no-cache --update\
    $BUILD_PACKAGES

COPY Gemfile /

RUN bundle install --clean

COPY my_rack_app.rb /

RUN mkdir -p /app/tmp/sockets
VOLUME /app/tmp/sockets

CMD ["./my_thin_app.rb"]

EXPOSE 8080
```

Gemfileにもthinを足します。

```thin/Gemfile
source 'https://rubygems.org'

gem 'rack'
gem 'thin'
```

アプリケーションの待ち受け先はアドレスではなくファイルです。

```rb:thin/my-thin-app.rb
#! /usr/bin/env ruby
require 'rack'
require 'thin'

app = Proc.new do |env|
    request = Rack::Request.new(env)
    ['200', {'Content-Type' => 'text/html'}, ["request.ip #{request.ip}, X-Forwarded-For #{env['HTTP_X_FORWARDED_FOR']}"]]
end

Rack::Handler.get('thin').run(app, :Host => '/app/tmp/sockets/my_app.socket')
```

docker-compose.ymlの`web`の`build`を`thin`に変えます。

```docker-compose.yml
version: '2'
services:
  nginx:
    depends_on:
      - web
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./log:/var/log/nginx
    volumes_from:
      - web
  web:
    build: thin
```

nginx.confもUNIXドメインソケットを使うように変更します

```nginx.conf
events {}
http {
  upstream app {
    server unix:///app/tmp/sockets/my_app.socket;
  }
  server {
    listen 80;
    location / {
      #proxy_pass http://web:8080;
      proxy_pass http://app;
    }
  }
}

```

```
chmod +x thin/my_thin_app.rb
docker-compose build
```

を実行して環境を作成します。

### 起動

```
docker-compose up
```
を実行すると

```
Recreating racktest_web_1
Recreating racktest_nginx_1
Attaching to racktest_web_1, racktest_nginx_1
```

と表示されます。

### 実行
ブラウザで`localhost`に接続すると

```
request.ip 127.0.0.1, X-Forwarded-For
```

と表示されます。
UNIXドメインソケット接続では送信元IPアドレスが、
nginxのIPアドレスではなく`127.0.0.1`に変わります。

### X-Forwarded-Forヘッダー

そこで前述のX-Forwarded-Forヘッダーを使い、nginxはリモートIPをアプリケーションサーバーに伝えます。

nginx.confに`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`を追加します。


```nginx.conf
events {}
http {
  upstream app {
    server unix:///app/tmp/sockets/my_app.socket;
  }
  server {
    listen 80;
    location / {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_pass http://app;
    }
  }
}
```

`Ctrl + c`でdocker-compose一度終了し


```
docker-compose up
```
を実行します。
ブラウザで`localhost`に接続すると

```
request.ip 127.0.0.1, X-Forwarded-For 172.19.0.1
```

と表示されます。
X-Forwarded-Forヘッダーは表示されますがrequest.ipは`127.0.0.1`のままです。

これが「RackはリクエストのX-Forwarded-ForヘッダーのプライベートIPを無視する」振る舞いです。

# 対策
## モンキーパッチ！

RackはRubyで書かれています。Rubyのクラスはオープンクラスです。
Rack::Requestを、外部から書き換えることができます。

次のモンキーパッチを当ててみましょう。

```rb
module Rack
  class Request
    def trusted_proxy?(ip)
      ip =~ /^127\.0\.0\.1$/
    end
  end
end
```

### 準備

my-thin-app.rbを変更します。

```rb:thin/my-thin-app.rb
#! /usr/bin/env ruby
require 'rack'
require 'thin'

module Rack
  class Request
    def trusted_proxy?(ip)
      ip =~ /^127\.0\.0\.1$/
    end
  end
end

app = Proc.new do |env|
    request = Rack::Request.new(env)
    ['200', {'Content-Type' => 'text/html'}, ["request.ip #{request.ip}, X-Forwarded-For #{env['HTTP_X_FORWARDED_FOR']}"]]
end

Rack::Handler.get('thin').run(app, :Host => '/app/tmp/sockets/my_app.socket')
```

### 起動

起動し直します。
ソースファイルを変えたのでbuildからやりなおします。

```
docker-compose build
docker-compose up
```

### 実行

ブラウザで`localhost`に接続すると

```
request.ip 172.19.0.1, X-Forwarded-For 172.19.0.1
```

と表示されます。
:clap: :clap: :clap: 無事送信元IPアドレスが取得できました。

ただし、モンキーパッチは変更対象のクラスの実装に依存します。
将来、Rackの実装変更時に影響を受けるかもしれません。

また、何らかのライブラリが同じ箇所を修正しているかもしれません。
もしそうであれば、予想しないライブラリに影響が出ます。

可能であれば、モンキーパッチではなく保証されたインターフェースを使って振る舞いを変更したいです。

## 直接 X-Forwarded-Forヘッダー を参照

プライベートネットワークからのリクエストの場合は
プロキシサーバの構成が決まっていて変更予定がないことがありそうです。
その場合は、これでも良さそうです。

RackではenvのHTTPで始まるキーへHTTPヘッダーが格納されています。

```rb
env['HTTP_X_FORWARDED_FOR']
```

これでX-Forwarded-Forが参照可能です。

X-Forwarded-Forヘッダーには複数のIPアドレスが含まれていることがあります。
注意してください。

## RailsならRailsならtrusted_proxiesを設定で変更できる

RailsにはRack::Reqeuest#ipとほとんど同じ動きをする
ActionDispatch::Request#remote_ipがあります。

```rb:config/environments/*.rb
config.action_dispatch.trusted_proxies
```

を書けば`request.remote_ip`で使用するtrusted_proxiesを変更できます。

Railsの環境を作るのが面倒臭すぎるのでためせませんでしたー・・・orz

あと、いまのところログに反映されないっぽい！PRはマージされているっぽい[^1]

[^1]: [RemoteIp middleware trusted proxies config does not affect Rack::Request::trusted_proxy? · Issue #5223 · rails/rails](https://github.com/rails/rails/issues/5223)

# 参考
- [Unix domain sockets vs TCP Sockets](http://bhavin.directi.com/unix-domain-sockets-vs-tcp-sockets/)
- [Performance Analysis of Various Mechanisms
for Inter-process Communication](http://osnet.cs.binghamton.edu/publications/TR-20070820.pdf)
- [unix domain sockets vs. internet sockets](https://lists.freebsd.org/pipermail/freebsd-performance/2005-February/001143.html)

- [Rackをdockerで動かす - Qiita](http://qiita.com/ledsun/items/df2d7c8d1310301b61ed)

- [railsにおけるremote_ipって何が嬉しいのだろうか？ - Qiita](http://qiita.com/ThreeTreesLight/items/c14cf33b4621610a1e23)
- [ActionDispatch::RemoteIp](http://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html)