読者です 読者をやめる 読者になる 読者になる

人間とウェブの未来

「ウェブの歴史は人類の歴史の繰り返し」という観点から色々勉強しています。

ngx_mrubyで最初のHTTPSアクセス時に自動で証明書を設定可能にするFastCertificateの提案とPoC

Let’s EncryptやACMEプロトコルによるDV証明書取得の自動化に伴い、証明書の取得と設定が簡単になってきました。

一方で、ACMEをツール化したものが増えるに従って、ACMEってそもそもどういう動きになっているのか、とか、自分たちの用途でどういう使い方がありえるのかとかが余計にわかりにくくなってきており、どこまで自動化できるかもよくわからない場合が多いのではないでしょうか。

そこで、

ドメインとAレコードの紐付けさえしていれば、最初のアクセス時に自動で証明書をとってきて、HTTPS通信にできないか

というような、いわゆる FastCertificate 的な動きを実現したいと考え、ACMEの通信の中で各種処理を別のスクリプトでhookできるdehydratedとngx_mrubyを応用して実現可否も含めてPoCを実装してみました。

※ FastContainerという考え方については、以下の記事をご覧ください。ようするに、リクエスト契機で必要な設定やWebサーバの立ち上げをやってしまって、裏でタスクを回すみたいな処理を極力避けるようなピタゴラスイッチ的なアーキテクチャ(FaaSとかも似ている)のことを指しております。

hatenanews.com

結果、結構簡単にできてしまったので、そのための便利メソッドをNginx::SSL::ACMEとしてngx_mrubyに取り込み、nginxのサンプル設定も同封しました。下記PRがその内容になります。また、v1.19.2でExperimentalな機能としてリリースしています

github.com

このPRで実現しているフローを簡単に図にすると以下のようになります。

f:id:matsumoto_r:20170323171612p:plain

図にあるとおり、とあるngx_mrubyが動いているサーバにAレコードを向けさえすれば、最初のHTTPSのアクセス時に動的に証明書を取得してredisに保存してからレスポンスを返し、2回目以降はredisから証明書データを取り出して返すようになります。

実際に上記の動きをnginx.conに書くと、以下のようになります。ここで利用しているNginx::SSL::ACMEクラスは既にngx_mrubyのコアに導入済みなので使うことができます。

#
# Example for FastCertificate PoC configuration
#
# use redis for ACME data management
#

worker_processes auto;
events {
    worker_connections 1024;
}

daemon off;
user nginx;
master_process on;
error_log logs/error.log notice;

http {
    include mime.types;

    mruby_init_worker_code '

        Userdata.new.redis = Redis.new "127.0.0.1", 6379
    ';

    mruby_init_code '

        secret_token = SecureRandom.uuid

        # Setup dehydrated example
        #
        # cp -pr ngx_mruby/test/conf/auto-ssl ${NGINX_INSTALL_DIR}/conf/.
        # sudo chown nginx -R ${NGINX_INSTALL_DIR}/conf/auto-ssl

        Userdata.new.dehydrated_opts = {
                            bin: "__NGXDOCROOT__/conf/auto-ssl/dehydrated",
                            conf: "__NGXDOCROOT__/conf/auto-ssl/dehydrated.conf",
                            hook: "__NGXDOCROOT__/conf/auto-ssl/ngx_mruby-hook.sh",
                            secret_token: secret_token,
                          }

        Userdata.new.allow_domains = %w(
                            autossl.matsumoto-r.jp
                            udzura.matsumoto-r.jp
                            pyama.matsumoto-r.jp
                            linyows.matsumoto-r.jp
                            harasou.matsumoto-r.jp
                          )

        Userdata.new.auto_ssl_secret = secret_token
        Userdata.new.auto_ssl_port = 11111
    ';

    server {
        listen 443 ssl;
        server_name  _;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_certificate __NGXDOCROOT__/dummy.crt;
        ssl_certificate_key __NGXDOCROOT__/dummy.key;

        mruby_ssl_handshake_handler_code '

          ssl = Nginx::SSL.new
          domain = ssl.servername
          acme = Nginx::SSL::ACME.new domain, Userdata.new.dehydrated_opts, Userdata.new.allow_domains

          raise "not allowed servername" unless acme.allow_domain?

          redis = Userdata.new.redis
          if redis["#{domain}.crt"].nil? or redis["#{domain}.key"].nil?
            acme.auto_cert_deploy
          end

          ssl.certificate_data = redis["#{domain}.crt"]
          ssl.certificate_key_data = redis["#{domain}.key"]
        ';

        location / {
            mruby_content_handler_code 'Nginx.rputs "hello #{Nginx::Request.new.hostname} world!"';
        }
    }

    server {
        listen 80;
        server_name _;

        location ^~ /.well-known/acme-challenge/ {
            mruby_content_handler_code '

              Nginx.return -> do
                r = Nginx::Request.new
                redis = Userdata.new.redis
                access_token = Nginx::SSL::ACME.token_filename_from_url(r)
                stored_token = redis["#{r.hostname}_token_filename"]

                if access_token != stored_token
                  Nginx.log Nginx::LOG_ERR, "ACME challenge token not found"
                  Nginx.log Nginx::LOG_ERR, "access token: #{access_token}"
                  Nginx.log Nginx::LOG_ERR, "stored token: #{stored_token}"
                  return Nginx::HTTP_NOT_FOUND
                end

                Nginx.rputs redis["#{r.hostname}_token_value"]
              end.call
            ';
        }

        location = /.well-known/acme-challenge/ {
           return 404;
        }
    server {
        listen 127.0.0.1:11111;
        server_name localhost;

        location /deploy-cert {
            mruby_enable_read_request_body on;
            mruby_content_handler_code '

              Nginx.return -> do
                r = Nginx::Request.new

                if Userdata.new.auto_ssl_secret == Nginx::SSL::ACME.secret_token(r)
                  cert_info = Nginx::SSL::ACME.deploy_cert_information r
                  redis = Userdata.new.redis
                  redis.mset "#{cert_info[:domain]}.key", cert_info[:key], "#{cert_info[:domain]}.crt", cert_info[:crt]
                  Nginx.rputs "deploy cert ok"
                  return Nginx::HTTP_OK
                end

                return Nginx::HTTP_UNAUTHORIZED
              end.call
            ';
        }

        location /deploy-challenge {
            mruby_enable_read_request_body on;
            mruby_content_handler_code '

              Nginx.return -> do
                r = Nginx::Request.new

                if Userdata.new.auto_ssl_secret == Nginx::SSL::ACME.secret_token(r)
                  domain = Nginx::SSL::ACME.challenged_domain r
                  redis = Userdata.new.redis
                  redis["#{domain}_token_filename"] = Nginx::SSL::ACME.challenged_token_filename r
                  redis["#{domain}_token_value"] = Nginx::SSL::ACME.challenged_token_value r
                  Nginx.rputs "depoy challenge ok"
                  return Nginx::HTTP_OK
                end

                return Nginx::HTTP_UNAUTHORIZED
              end.call
            ';
        }

        location / {
            mruby_content_handler_code "Nginx.rputs 'hello 11111 world'";
        }
    }
}

この設定の特徴としては、どんなドメインであっても、Aレコードが向いていれば自動に証明書を取得しにいってHTTPS通信がはじまること、それらをnginxの設定を変えることなく動的に実現できることです。後は、ドメインに応じて動的にこれまたngx_mrubyでバックエンドサーバへのプロキシ先を選択するようにしておけば、nginx自体の設定もシンプルにしておくことができます。

これにより、例えばcurlでAレコードを設定した上ではじめてのアクセスをすると、

$ time curl https://pyama.matsumoto-r.jp/ --insecure
hello pyama.matsumoto-r.jp world!

real    0m6.420s
user    0m0.041s
sys     0m0.006s

$ time curl https://pyama.matsumoto-r.jp/ --insecure
hello pyama.matsumoto-r.jp world!

real    0m0.089s
user    0m0.043s
sys     0m0.007s

というように、一番最初のアクセスは証明書取得のため数秒かかりますが、2回目以降はredisのデータ経由でシュシュっとレスポンスを返せています。(Let’s Encryptのstagingで試しているため–insecureを使っています)また、例えばホスティング的には最初の環境の用意の時点であれば、遅くても問題ないのでそういうドメインがどんどん増えていくようなサービスにおいては実用に足るかなと思います。

このPoCでは、証明書更新についてはまだ未実装ですが、ここまでできればその処理もさほど難しくないでしょう。

ということで、是非ngx_mrubyを様々な証明書管理や動的処理にご活用下さい。