Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

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

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

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

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

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

です。

nginx Advent Calendar 2016 の、12/4

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

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

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

要するに、「たくさんの証明書を管理するのは大変だし、Webサーバのバーチャルホストの設定に静的に記述して証明書を読み込ませると、メモリを大量消費するのに加えて処理速度も遅くなるから、動的に対応してしまおう」ということです。
なお、これらの記事では大量の証明書を利用することを想定しているため、証明書の(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 / {

            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 バージョン(ssl_protocols)および CipherSpec(ssl_ciphers
  • HSTS などの指定(この例では指定せず)
  • 証明書・秘密鍵ファイルの保存先ディレクトリ(local ssl_certificatelocal ssl_certificate_keystring.format()
  • バックエンドサーバにリクエストを中継するときに追加/変更/削除する HTTP リクエストヘッダ(proxy_set_header
  • 転送対象のパスおよびバックエンドサーバの IP アドレス・ポート番号(proxy_pass

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

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


2018/07/04 追記:
この設定例では、/usr/local/openresty/nginx/conf/(OpenResty の場合)の下にcertsディレクトリを作り、その中にticket.keyファイルを作成しておく必要があります。

ticket.key作成
# cd /usr/local/openresty/nginx/conf
# mkdir certs
# cd certs
# openssl rand 48 > tickets.key

また、余分なadd_headerlocation /に含まれていたので削除しました。


2018/07/10 追記:
正規表現によるマッチングが意図したものと違う動作になっていたので修正しました(「%-」の部分)。


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

  • 証明書・秘密鍵ファイルの保存先ディレクトリのアクセス権に注意。途中のディレクトリ階層に 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 移行をすっかり忘れてた。早く対応しなければ。とりあえず切り替えだけはしました。


2018/06/30 追記:
ngx_mruby を使ったものではないのに、まつもとりーさんからブコメをいただいてしまいました。ありがとうございます!
言い訳というかこの記事の主旨そのものでもあるのですが、「求められる要件を満たすことを前提に、自分たちが管理可能な手法を選んで対応すべし」という考えの下で「OpenResty を使う」「証明書・秘密鍵はローカルファイルで管理」という方法を紹介しているので、可能な方は、是非 ngx_mruby や Redis を使う方法にもチャレンジしてみてください。

2018/07/02 追記:
編集リクエストでの typo の指摘、ありがとうございました(老眼おじさんにはしばらく違いがわかりませんでした…)。

2018/07/04 追記:
動的 vs 静的で処理時間を比較してみました。

hmatsu47
名古屋で士業向けWebサービスのインフラ構築管理、たまにアプリケーション開発をやっています。 業務利用しているもの、個人研究など、気長にのんびり投稿していきます。ニッチ狙いが多めです。 IPA RISS(001158)・NW・DB/日商・大商2級コレクター?(簿記・ビジネス法務・ビジネス会計)。 https://hmatsu47.qrunch.io/
https://hmatsu47.hatenablog.com/
infra-workshop
インフラ技術を勉強したい人たちのためのオンライン勉強会です
https://wp.infra-workshop.tech/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away