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 Calendar 2016](https://qiita.com/advent-calendar/2016/nginx)** の、12/4

* **[OpenResty で証明書の動的読み込み](https://qiita.com/Hexa/items/07e8b0d942576375d1f7)（Hexa さん）**

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

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

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

 * **[ngx_mrubyで最初のHTTPSアクセス時に自動で証明書を設定可能にするFastCertificateの提案とPoC](https://hb.matsumoto-r.jp/entry/2017/03/23/173236)（人間とウェブの未来）**
 * **[常時HTTPS化に向けた高集積マルチテナントWebサーバの大規模証明書管理](https://rand.pepabo.com/article/2017/06/16/iot37-matsumotory/)（ペパ研ブログ）**

要するに、**「たくさんの証明書を管理するのは大変だし、Webサーバのバーチャルホストの設定に静的に記述して証明書を読み込ませると、メモリを大量消費するのに加えて処理速度も遅くなるから、動的に対応してしまおう」**ということです。
なお、これらの記事では大量の証明書を利用することを想定しているため、証明書の（Let's Encrypt への）発行依頼まで（初回アクセスをトリガに）自動化するところにまで踏み込んでいます。

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

 * **[AWSではてなブログの常時HTTPS配信をバーンとやる話](https://speakerdeck.com/aereal/the-epic-of-migration-from-http-to-https-on-hatena-blog-with-aws)（aerial さん）**

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

 * **[GUI/lua-resty-auto-ssl](https://github.com/GUI/lua-resty-auto-ssl)**
 * ↑の解説記事：**[Nginxとlua-resty-auto-sslで全自動なLet's Encrypt環境を作る](https://qiita.com/Ets/items/8e6a1df730e24de239e4)（Ets さん）**

# 2. OpenResty でやってみる

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

 * **[OpenResty(R) -  Official Site](https://openresty.org/en/)**

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

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

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

```config: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_certificate`・`local ssl_certificate_key`の`string.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`ファイルを作成しておく必要があります。

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

また、余分な`add_header`が`location /`に含まれていたので削除しました。

---

**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 静的で処理時間を比較してみました。

 * **[nginx＋ngx_luaで証明書動的読み込みの常時HTTPS用リバースプロキシ(2) 動的／静的の処理時間を比較してみた](https://qiita.com/hmatsu47/items/3eff00a2432f660fe462)**