Let’s EncryptやACMEプロトコルによるDV証明書取得の自動化に伴い、証明書の取得と設定が簡単になってきました。
一方で、ACMEをツール化したものが増えるに従って、ACMEってそもそもどういう動きになっているのか、とか、自分たちの用途でどういう使い方がありえるのかとかが余計にわかりにくくなってきており、どこまで自動化できるかもよくわからない場合が多いのではないでしょうか。
そこで、
ドメインとAレコードの紐付けさえしていれば、最初のアクセス時に自動で証明書をとってきて、HTTPS通信にできないか
というような、いわゆる FastCertificate 的な動きを実現したいと考え、ACMEの通信の中で各種処理を別のスクリプトでhookできるdehydratedとngx_mrubyを応用して実現可否も含めてPoCを実装してみました。
※ FastContainerという考え方については、以下の記事をご覧ください。ようするに、リクエスト契機で必要な設定やWebサーバの立ち上げをやってしまって、裏でタスクを回すみたいな処理を極力避けるようなピタゴラスイッチ的なアーキテクチャ(FaaSとかも似ている)のことを指しております。
結果、結構簡単にできてしまったので、そのための便利メソッドをNginx::SSL::ACME
としてngx_mrubyに取り込み、nginxのサンプル設定も同封しました。下記PRがその内容になります。また、v1.19.2でExperimentalな機能としてリリースしています。
このPRで実現しているフローを簡単に図にすると以下のようになります。
図にあるとおり、とある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を様々な証明書管理や動的処理にご活用下さい。