SSL Server TestでA+とれたぜ。SPDYとHTTP/2にも対応した。そこそこ大変だったので困ったところなどをメモ。

概略

このサイトは

  • endaaman.me … Reactを使ったSPAなフロントエンド。SSRも行う
  • api.endaaman.me … koa.js + MongoDBなREST API
  • static.endaaman.me … リアルタイムサムネイル生成機能付き静的ファイル配信サーバー

の3つのサービスに分かれており、それぞれ個別のドメインを持ち、異なるDockerコンテナ上で動いている。サーバーマシンのフロントエンド(WWWに繋がる部分)はjwilder/nginx-proxyだけを-p 80:80 -p 443:443で起動して、それぞれをバーチャルホストとしてリクエストを割り振っている。

jwilder/nginx-proxyを使ってSSL化するには、jwilder/nginx-proxyでボリュームになっている /etc/nginx/certsdomain-name.com.crtdomain-name.com.key という形式のファイル名になっている証明書と鍵を保存したディレクトリをマウントすることで、

upstream domain-name.com {
                                ## Can be connect with "bridge" network
                        # <container name>
                        server <container ip>:80;
}
server {
        server_name domain-name.com;
        listen 80 ;
        access_log /var/log/nginx/access.log vhost;
        return 301 https://$host$request_uri;
}
server {
        server_name domain-name.com;
        listen 443 ssl http2 ;
        access_log /var/log/nginx/access.log vhost;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA;
        ssl_prefer_server_ciphers on;
        ssl_session_timeout 5m;
        ssl_session_cache shared:SSL:50m;
        ssl_certificate /etc/nginx/certs/domain-name.com.crt;
        ssl_certificate_key /etc/nginx/certs/domain-name.com.key;
        ssl_dhparam /etc/nginx/certs/domain-name.com.dhparam.pem;
        add_header Strict-Transport-Security "max-age=31536000";
        location / {
                proxy_pass http://domain-name.com;
        }
}

という様なconfファイルを内部で自動で生成してくれる。ただし、Let’s Encryptでは

  • 証明書: /etc/letsencrypt/live/domain-name.com/fullchain.pem
  • 鍵: /etc/letsencrypt/live/domain-name.com/privkey.pem

という形式で証明書と鍵を保持しており、さらに、Dockerはシンボリックリンクのフォローをしてくれないので、これらのファイルをjwilder/nginx-proxyに識別させるためには

  1. シンボリックリンクを張って名前を変える
  2. 実体の入っている/etc/letsencrypt/archiveを含めてマウントする

の両方を満たしてる必要がある。一番素直なやり方としては、まずホスト側のマシンで

# ln -s /etc/letsencrypt/live/domain-name.com/fullchain.pem /path/to/certs/domain-name.com.crt
# ln -s /etc/letsencrypt/live/domain-name.com/privkey.pem /path/to/certs/domain-name.com.key

という感じに証明書と鍵をまとめて

$ docker run -d -p 80:80 -p 443:443 \
  -v /var/run/docker.sock:/tmp/docker.sock:ro \
  -v /etc/letsencrypt:/etc/letsencrypt:ro
  -v /path/to/certs:/etc/letsencrypt/live:ro
  jwilder/nginx-proxy

みたい起動してやれば良い。

アプリ側の設定について

上に書いたようにアプリにはjwilder/nginx-proxyから proxy_passされてリクエストが到来することになるので、 nginx.confでの$schemeは’http’になっている

これは結構重要で、というのは、jwilder/nginx-proxyの配下のコンテナのnginxでは

server {

  ### SNIP ###

  location @ssr {
    proxy_pass http://127.0.0.1:8080;
    proxy_redirect off;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;

という様に普通に更にアプリにproxy_passしてしまうと、 X-Forwarded-Proto'http'がセットされてしまいアプリ側でSSLが有効かどうかわからなくなってしまう。 なので、

server {

  ### SNIP ###

  set $cutom_protocol $scheme;
  if ($http_x_forwarded_proto = 'https') {
    set $cutom_protocol 'https';
  }

  location @ssr {
    proxy_pass http://127.0.0.1:8080;
    proxy_redirect off;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $cutom_protocol;

さらに上位がSSLであるかチェックしてX-Forwarded-Protoに上位のサーバーのプロトコルを渡してproxy_passしてやると良い。

なんでこんなことを書くのか?

上に書いたようにendaaman.meはSSRのために小さなExpressサーバーを持っているのだが、 アプリ側にSSLを強制したくないので、API fetchのために、http://endaaman.meならhttp://api.endaaman.meへ、 https://endaaman.meならhttps://api.endaaman.meというように切り替えたかった(ブラウザ側は単に//api.endaaman.meで良い)。

そのためにはExpressにはhttpsでのアクセスなら、 上記のような設定を行った上で、さらに

const app = express()
app.enable('trust proxy')

としたサーバーにX-Forwarded-Proto: httpsでリクエストが到達することで、 リクエストオブジェクトのreq.protocol'https'になってくれるのである。

DHパラメータ

証明書と鍵だけの設定だとSSL Server TestでB評価止まりだが、

$ openssl dhparam 2048 -out /path/to/certs/domain-name.com.dhparam.pem

でDHパラメータを生成してマウントしたディレクトリにdomain-name.com.dhparam.pemという名前でおいてやれば、 とくにSSL周りで特殊な設定は何もしなくてもA+評価まで取れる。

jwilder/nginx-proxyを使うことによる最大のメリット

それは、ドメインに対応する証明書と鍵の存在をチェックして動的にconfを生成してくれるので、鍵のない状態ならhttpに、鍵を配置すれば全てhttpsにという感じでサーバーを一括で切り替えられるという点である。ローカル開発機では3つのenda.locaapi.enda.localstatic.enda.local127.0.0.1に紐づけることで3つ全てが完全な状態で動作するかの確認していて、httpsでの動作確認をするならオレオレ証明書SSLをマウントしてやれば全部httpsになるし(もちろん警告は出る)、そうでないばあいであれば鍵をマウントしないだけで全てhttpで動作してくれる。

これは個人的には結構なメリットだと思っている。

最後に

この辺の設定はendaaman/enda-serverで Ansibleのplaybookを作ってまとめて自動化してあるので、良かったら参考にしてください。

参考