最初に結論
条件
- クライアントがプライベートネットワークにいる
- プロキシサーバーとRailsアプリケーションをUNIXドメインソケットで接続している
現象
Rackのrequest.ipやRailsのrequest.remote_ipで、送信元IPアドレスが取れません。
対策
- Rack::Requestにモンキーパッチを当てる
- X-Forwarded-Forヘッダーから直接取得する
- 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 (XFF) とは、HTTPヘッダフィールドの一つ。HTTPプロキシサーバまたは負荷分散装置(ロードバランサ)を経由してウェブサーバに接続するクライアントの送信元IPアドレスを特定する際のデファクトスタンダードである
nginxではproxy_set_headerディレクティブを使い
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
を設定すると、送信元IPアドレスをX-Forwarded-For
ヘッダーに追加できます。
RackはリクエストのX-Forwarded-Forヘッダーを参照する
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
という処理をします。
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?
なアドレスを消します。
def reject_trusted_ip_addresses(ip_addresses)
ip_addresses.reject { |ip| trusted_proxy?(ip) }
end
trusted_proxy?
なアドレスはプライベートIPアドレスを表現した正規表現です。
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アプリケーションを用意します。
#! /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'}
source 'https://rubygems.org'
gem 'rack'
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ドメインソケット(英: UNIX domain socket)や IPCソケット とは、単一のオペレーティングシステム内で実行されるプロセス間でデータを交換するためのデータ通信の終点
UNIXドメインソケットは単一OS内のプロセス間通信の仕組みです。
単一OS上のプロセス間でしか使えませんが、TCP/IPレイヤーを介しない分高速に動作します。
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アドレスの伝達を試してみましょう。
準備
ファイルを二つ追加します。
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: .
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するには、いろいろ物入りです。
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を足します。
source 'https://rubygems.org'
gem 'rack'
gem 'thin'
アプリケーションの待ち受け先はアドレスではなくファイルです。
#! /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
に変えます。
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ドメインソケットを使うように変更します
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;
を追加します。
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を、外部から書き換えることができます。
次のモンキーパッチを当ててみましょう。
module Rack
class Request
def trusted_proxy?(ip)
ip =~ /^127\.0\.0\.1$/
end
end
end
準備
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
と表示されます。
無事送信元IPアドレスが取得できました。
ただし、モンキーパッチは変更対象のクラスの実装に依存します。
将来、Rackの実装変更時に影響を受けるかもしれません。
また、何らかのライブラリが同じ箇所を修正しているかもしれません。
もしそうであれば、予想しないライブラリに影響が出ます。
可能であれば、モンキーパッチではなく保証されたインターフェースを使って振る舞いを変更したいです。
直接 X-Forwarded-Forヘッダー を参照
プライベートネットワークからのリクエストの場合は
プロキシサーバの構成が決まっていて変更予定がないことがありそうです。
その場合は、これでも良さそうです。
RackではenvのHTTPで始まるキーへHTTPヘッダーが格納されています。
env['HTTP_X_FORWARDED_FOR']
これでX-Forwarded-Forが参照可能です。
X-Forwarded-Forヘッダーには複数のIPアドレスが含まれていることがあります。
注意してください。
RailsならRailsならtrusted_proxiesを設定で変更できる
RailsにはRack::Reqeuest#ipとほとんど同じ動きをする
ActionDispatch::Request#remote_ipがあります。
config.action_dispatch.trusted_proxies
を書けばrequest.remote_ip
で使用するtrusted_proxiesを変更できます。
Railsの環境を作るのが面倒臭すぎるのでためせませんでしたー・・・orz
あと、いまのところログに反映されないっぽい!PRはマージされているっぽい1