nginx
HTTPS
nginx_lua_module

nginx+ngx_luaで証明書動的読み込みの常時HTTPS用リバースプロキシを手軽に立ててみる

Google Chrome 68 による非 HTTPS サイトの Not secure 警告待ったなし!の今頃になってこの記事を出すのもどうかとは思うのですが、

  • nginx+ngx_lua を使って(実際には OpenResty を導入するのがお手軽)
  • 静的な設定ではなく、動的に SSL/TLS 証明書を読み込む形の
  • SSL/TLS ラッパー兼リバースプロキシを手軽に立ててみる

ということを(今更ながら)やってみます。

なお、想定している環境・状況等は、

  • ドメイン名(FQDN)はそこそこたくさんある(数百~)が、万単位までは行かない
  • アクセス数(PV)はそれほど多くない
  • Let's Encrypt などを使って証明書を自動更新している/したい
  • 但し、証明書の発行はある程度人手でコントロールしたい(「初回アクセス時自動発行」まではしない)
  • Redis など別の構成要素を増やしたくない

です。

nginx Advent Calender 2016 の、12/4

の記事を参考に、証明書・秘密鍵をファイル管理する形で SSL/TLS ラッパー兼リバースプロキシを立てるものです。

1. 証明書動的読み込みについての参考情報

このテーマでは、やはり GMO ペパボ研究所のまつもとりーさんのブログ記事を読むのが一番良いと思います。

これらの記事では大量の証明書を利用することを想定しているため、証明書の(Let's Encrypt への)発行依頼まで(初回アクセスをトリガに)自動化するところにまで踏み込んでいます。

はてなブログの常時 HTTPS 化プロジェクトでも、同じような考え方の下、AWS の各種サービスを利用する形で仕組みを構築しています。

また、同じような考え方で OpenResty+lua-resty-auto-ssl を使って証明書自動発行依頼ができるようですので、要件が合えばこれを使うのも良いかもしれません(動作は未確認です)。

2. OpenResty でやってみる

OpenResty は、nginx に ngx_lua などのモジュールがあらかじめ組み込まれた形で配布されているものです。

ソースコードのほか、主要ディストリビューション向けにバイナリパッケージも用意されています。
※パッケージを使うと簡単にインストールできるため、インストール方法等の説明は省略します。

なお、OpenResty はデフォルトでインストールされる先のディレクトリ構成が独特だったり、本家のリリースよりどうしてもリリースタイミングが遅くなるなどの欠点もあるため、本家 nginx+ngx_lua モジュールの環境を選択しても構いません。

以下にnginx.confの関連設定部分を示します。

nginx.conf(関連部分)
http {

    # HTTPS server

    server {
        listen       443 ssl;
        listen       [::]:443 ssl;
        server_name  _;

        ssl_protocols TLSv1.1 TLSv1.2;
        ssl_certificate certs/www.example.com.pem;
        ssl_certificate_key certs/www.example.com.key;
        ssl_session_tickets on;
        ssl_session_ticket_key certs/ticket.key;
        ssl_session_cache shared:SSL:1m;
        ssl_session_timeout 5m;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;

        ssl_certificate_by_lua_block {
            local ssl = require "ngx.ssl"

            local ok, err = ssl.clear_certs()
            if not ok then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end

            local name, err = ssl.server_name()
            if not name then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end

            local ok, err = ngx.re.match(name, "^([A-Za-z0-9][A-Za-z0-9%-%.]{1,251}[A-Za-z])$", "jo")
            if not ok then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end

            local ssl_certificate = string.format("conf/certs/%s.pem", name)
            local ssl_certificate_key = string.format("conf/certs/%s.key", name)

            local file, err = io.open(ssl_certificate, "r")
            if not file then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end
            local pem_cert = file:read("*all")
            io.close(file)

            local cert, err = ssl.cert_pem_to_der(pem_cert)
            if not cert then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end

            local ok, err = ssl.set_der_cert(cert)
            if not ok then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end

            local file, err = io.open(ssl_certificate_key, "r")
            if not file then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end
            local pem_priv_key = file:read("*all")
            io.close(file)

            local priv_key, err = ssl.priv_key_pem_to_der(pem_priv_key)
            if not priv_key then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end

            local ok, err = ssl.set_der_priv_key(priv_key)
            if not ok then
                ngx.log(ngx.ERR, err)
                return ngx.exit(ngx.ERROR)
            end

        }

        location / {

            add_header  Content-Type    text/html;

            access_by_lua_block {
                local headers = ngx.req.get_headers()
                local host = headers["Host"]
                local m, err = ngx.re.match(host, "(?<hostname>[^:]+)", "jo")
                if err then
                    ngx.log(ngx.ERR, err)
                    return
                end
            }

            proxy_redirect   off;
            proxy_set_header Host               $host;
            proxy_set_header X-Real-IP          $remote_addr;
            proxy_set_header X-Forwarded-Host   $host;
            proxy_set_header X-Forwarded-Server $host;
            proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;

            proxy_pass http://【バックエンドサーバのIPアドレス】:【同・ポート番号】;
        }
    }
}
  • 受け入れる TLS バージョンおよび CipherSpec
  • HSTS などの指定
  • 証明書・秘密鍵ファイルの保存先ディレクトリ
  • バックエンドサーバにリクエストを中継するときに追加/変更/削除する HTTP リクエストヘッダ
  • 転送対象のパスおよびバックエンドサーバの IP アドレス・ポート番号

については自身の環境に合わせます。また、HTTP リクエストヘッダのhostnameは詐称されて届くこともありますので、ディレクトリトラバーサル等が成立しないよう、最低限のチェックを入れています(こちらも環境に合わせて適宜書き換えてください。正規表現ではなくngx.shared.DICTを使ってマッチングさせることで制限を掛ける方法もあります)。

また、このコードは「hostnameなしのリクエストは拒否する」ポリシーで書かれていますが、「hostnameなしのリクエストはデフォルトドメインへのリクエストとみなす」ポリシーを採用するのであれば、ssl.server_name()およびhostnameのチェック箇所を書き換えて対応してください。

その他の注意点としては、

  • 証明書・秘密鍵ファイルの保存先ディレクトリのアクセス権に注意。途中のディレクトリ階層に nginx 実行ユーザのアクセス権がないと「Permission Denied」エラーになります(かといって「777」など緩くしすぎるのはやめましょう)
  • 外側に AWS の NLB などを置く場合、ヘルスチェックは HTTPS でリクエストを送る形ではなく TCP 443 などのポート監視を行うか、HTTP 側に対してリクエストを送りましょう(HTTP リクエストヘッダにhostnameが載らないため、FQDN チェックでエラーになります)

などがあります。

※Let's Encrypt 等を使った SSL/TLS 証明書の新規発行/更新/削除については実装が必要ですがここでは触れません。Certbot や lego を使ってスクリプトなどで定期実行するのは比較的容易だと思います。

3. おわりに

Google Chrome 68 での Not secure 警告は控えめに表示されるようですが、その先のバージョンでは目立つ表示に変更されることがアナウンスされていますので、未対応の方は早めに対応してしまいましょう。

…あ、自分のはてなブログの HTTPS 移行をすっかり忘れてた。早く対応しなければ。