TL; DR;
Cloudflareで管理しているドメイン用のワイルドカードTLS証明書をLet’s EncryptのACME DNSチャレンジを用いて発行する、という手順をAnsibleで自動で行えるようにしました。
モチベーション
我が家では宅鯖(自宅サーバー)を趣味で運用しており、多くのワークロードはkubernetesクラスタで稼働しています。 最近、kubernetesクラスタなどの機密情報の中央管理用に、kubernetes外でHashicorp Vaultを構築しようとしており、そのTLSサーバー証明書を用意する必要が出てきました。
kubernetes内のワークロード用のサーバー証明書はcert-managerで自動管理することができていますが、こういったkubernetes外のリソースの証明書は管理することができません(できるのかもしれないですが、依存関係が循環してしまいます)。
宅鯖の管理はできるだけIaCの思想で管理したいと思っているので、kubernetesクラスタセットアップでも用いているAnsibleを使った方法で実装してみます。 また、サーバー1台1台に別々の証明書を用意するのも面倒なので、1つのワイルドカードTLS証明書を共有するようにします(本当はセキュリティ的によろしくないですが…)。
なお、Certbotを使えば更新も含めた証明書管理を行ってくれますが、証明書を含めてリポジトリで管理したいという都合で使っていません。
前提
この記事の内容は、全く同じサービスを使っていなくても似た考えで実装できると思いますが、使っているサービスを記載しておきます。
- Cloudflare:独自ドメインの取得・DNSコンテンツサーバーに使用
- APIを用いたDNSレコードの更新ができるDNSコンテンツサーバーであれば問題なし
- Let’s Encrypt:証明書発行に使用
- DNSチャレンジのACMEによる証明書発行ができるサービスであれば問題なし
ACMEによるTLS証明書発行の流れ
実際のAnsibleプレイブックの実装の説明の前に、証明書を自動で発行する仕組みについて説明しておきます。
TLSに用いるサーバー証明書を発行するだけなら、公開鍵ペアを作成し、公開鍵から証明書作成要求(Certificate Signing Request)を作成、認証局に署名してもらうというステップを取ることで発行できます。
このうち、公開鍵ペアの作成とCSRの作成に関しては、いくらでも自動化の余地がありそうです。 しかし、認証局に署名してもらう段階では、サーバーの身元確認を行う必要があり一筋縄ではいかない匂いがします。
実際、有名な証明書発行サービスの最高レベルの証明書発行では、法人登記簿なども用いて身元確認を行っているようです。 (cf. DV、OV、EV の各 SSL 証明書の違いとは | DigiCert)
個人レベルでこんなことをやっていられないので、一番手頃なドメイン認証(Domain Verification)を使いたいところですが、幸いACME(Automatic Certificate Management Environment)というプロトコルでDVによるTLS証明書発行手順が策定されています。
詳しくはRFCをみていただければと思いますが、証明書発行部分の仕組みは非常にシンプルで、
- 認証局が「本当にそのドメインを保有しているか?」というチャレンジを発行
- チャレンジに従ってドメインの保持を証明する
というものになっています。 よく5chでID付きの写真を上げて「嘘松」を回避しているのと発想としては同じです。
チャレンジには、
- HTTPチャレンジ:証明書を発行する対象のサーバーに指定されたトークンファイルを配置する
- DNSチャレンジ:証明書を発行する対象のサーバーのDNSレコードに指定されたトークンを記述する
といった種類があり、今回はCloudflare APIを用いて簡単に行えるDNSチャレンジを用います。
まとめると、図のような流れでTLS証明書を発行します。
実装
Ansibleの実装としてはシンプルで、
- CSRの作成
- ACMEチャレンジの発行
- DNSレコードの作成・伝搬待ち
- 証明書の発行
というような流れとなっています。
ディレクトリ構成は、以下のようにプレイブックと同階層にsecretディレクトリを作成し、作成する証明書を保管するcertificateディレクトリと、ansible-vaultパスワードで暗号化したansible-vaultパスワードのファイル(なぜこうしているかは後述)を配置しておきます。
.├── secret│ ├── ansible_vault_password.yaml # ansible vaultのパスワード(パスワードで暗号化)│ └── certificate│ ├── acme_account.key # ACMEアカウントの秘密鍵(自動作成)│ ├── ca.crt # CAルート証明書(自動作成)│ ├── server-full.crt # 中間証明書を含んだサーバー証明書(自動作成)│ ├── server.crt # サーバー証明書(自動作成)│ └── server.key # サーバーの秘密鍵(自動作成)└── certificate.yaml # プレイブックパスワードのファイルは以下のようなyaml形式のファイルをvaultで暗号化したものを用います。
ansible_vault_password: ここにパスワードを記述# 暗号化用のパスワードはファイルに記述したパスワードそのものを用いる$ ansible-vault encrypt ./ansible_vault_password.yamlNew Vault password:Confirm New Vault password:Encryption successful
$ cat ./ansible_vault_password.yaml$ANSIBLE_VAULT;1.1;AES256663566633163663132383764386334653865373832376633316239383438393765373965376363306439653866343562336231366138356135666136343163650a623163356230383261653835323030353334663864626637386332376630373338333730306437303332643462663638633465393561636233373634623363330a33656535613534396637316231316235636562646664333330633134626534391. CSRの作成
ACMEアカウント用・サーバー用の鍵ペアを作成・保存し、それを用いてCSRを作成しています。 いずれもCommunity.Cryptoコレクションを使用しています。
# プレイブック内にansible vaultのパスワードを読み込む- name: Load vault encrypt password ansible.builtin.include_vars: file: "secret/ansible_vault_password.yaml" no_log: true
# ACMEアカウント用の秘密鍵を作成し- name: Generate ACME account key community.crypto.openssl_privatekey_pipe: type: RSA size: 4096 content: "{{ lookup('ansible.builtin.file', 'secret/certificate/acme_account.key', errors='ignore') | default('') }}" no_log: true register: acme_account_key
# 保存する- name: Save encrypted ACME account key when: acme_account_key.changed ansible.builtin.copy: content: "{{ acme_account_key.privatekey | vault(ansible_vault_password) }}" dest: "secret/certificate/acme_account.key" decrypt: false no_log: true
# ACMEアカウントを作成する- name: Make sure account exists and has given contacts community.crypto.acme_account: acme_directory: "https://acme-staging-v02.api.letsencrypt.org/directory" acme_version: 2 account_key_content: "{{ lookup('ansible.builtin.file','secret/certificate/acme_account.key') }}" state: present terms_agreed: true contact: - "mailto:hogehoge@example.com"
# サーバー用秘密鍵を作成し- name: Generate OpenSSL private key community.crypto.openssl_privatekey_pipe: type: RSA size: 4096 content: "{{ lookup('ansible.builtin.file', 'secret/certificate/server.key', errors='ignore') | default('') }}" no_log: true register: server_key
# 保存する- name: Save encrypted server private key when: server_key.changed ansible.builtin.copy: content: "{{ server_key.privatekey | vault(ansible_vault_password) }}" dest: "secret/certificate/server.key" decrypt: false no_log: true
# CSRを作成する- name: Generate an OpenSSL Certificate Signing Request community.crypto.openssl_csr_pipe: privatekey_content: "{{ lookup('ansible.builtin.file','secret/certificate/server.key') }}" common_name: '*.subdomain.example.com' subject_alt_name: - "DNS:*.subdomain.example.com" register: certificate_request changed_when: false
# CSRを後のタスクのために変数に入れる- community.crypto.openssl_csr_info: content: "{{ certificate_request.csr }}" register: csr_info
# CAのルート証明書を取得する- name: Download root certificate ansible.builtin.get_url: url: https://letsencrypt.org/certs/staging/letsencrypt-stg-root-x1.pem force: true dest: "secret/certificate/ca.crt"このコレクションのcommunity.crypto.openssl_privatekey_pipeモジュールで秘密鍵を作成していますが、contentに秘密鍵の内容を指定することで、ファイル内容に変化がない場合には再作成しないという冪等な挙動を実現しています。
この挙動をセキュアに実現するためには、保存時の暗号化と再利用(ファイル内容チェック)時の復号両方を行う必要があります。 Ansible Vaultのパスワードが書かれているファイルをそのパスワードで暗号化しているのは、これを行うための処置です。
CSRには、Common Name・SAN( Subject Alt Name )として、発行したいワイルドカードなドメインを指定します。 この例だと、server1.subdomain.example.comやserver2.subdomain.example.comといったサーバーに対する証明書を発行できます。
ただし、server.subsubdomain.subdomain.example.comといった更なる階層の証明書とはならないので、そのような場合は別途その階層用のCSRを作成する必要があります。
2. ACMEチャレンジの発行
community.crypto.acme_certificateに従ってDNSチャレンジを発行します。
- name: Create a challenge using a account key file. community.crypto.acme_certificate: acme_directory: "https://acme-staging-v02.api.letsencrypt.org/directory" acme_version: 2 remaining_days: 60 challenge: dns-01 account_key_content: "{{ lookup('ansible.builtin.file','secret/certificate/acme_account.key') }}" csr_content: "{{ certificate_request.csr }}" dest: "secret/certificate/server.crt" fullchain_dest: "secret/certificate/server-full.crt" register: challenge3. DNSレコードの作成・伝搬待ち
community.general.cloudflare_dnsモジュールを用いてDNSチャレンジに対するTXTレコードを作成します。
- name: Generate Certificate block: - block: # TXTレコードを作成する - name: Create a TXT record community.general.cloudflare_dns: zone: "example.com" type: TXT ttl: 120 record: _acme-challenge.subdomain value: "{{ challenge.challenge_data['*.subdomain.example.com']['dns-01']['resource_value'] }}" account_email: hogehoge@example.com api_token: {{ cloudflareのAPIトークン }} state: present
# TXTレコードが更新されるのを待つ - name: Wait Until challenge record is up ansible.builtin.debug: msg: "Waiting for record is up." retries: 10 until: lookup('community.general.dig', '_acme-challenge.subdomain.example.com', '@8.8.8.8', 'qtype=TXT') == challenge.challenge_data['*.subdomain.example.com']['dns-01']['resource_value'] delay: 30
when: challenge.authorizations['*.subdomain.example.com'].status != 'valid'
# 証明書を発行する - name: Let the challenge be validated and retrieve the cert and intermediate certificate community.crypto.acme_certificate: acme_directory: "https://acme-staging-v02.api.letsencrypt.org/directory" acme_version: 2 account_key_content: "{{ lookup('ansible.builtin.file','secret/certificate/acme_account.key') }}" account_email: hogehoge@example.com csr_content: "{{ certificate_request.csr }}" dest: "secret/certificate/server.crt" fullchain_dest: "secret/certificate/server-full.crt" challenge: dns-01 remaining_days: 60 data: "{{ challenge }}"
# TXTレコードを削除する always: - name: Delete TXT record for challenge community.general.cloudflare_dns: zone: "example.com" type: TXT record: _acme-challenge.subdomain account_email: hogehoge@example.com api_token: {{ cloudflareのAPIトークン }} state: absent
when: challenge is changed and '*.subdomain.example.com' in challenge.authorizations少し詰まったこととして、サブドメインのTXTレコード作成時に、ゾーンにsubdomain.example.comを、レコード名に_acme-challengeを入れてしまったのですが、これだとCloudflare側のエラーでTXTレコードを作成することができません。 サブドメイン部分はレコード名に含めるようにしましょう。
また、TXTレコードを作成したからといってすぐにチャレンジが成功するわけではないので、実際にDNS検索をした結果チャレンジを満たせるまで待機します。 この際、公式ドキュメントのようにqtype=‘TXT’とすると、ずっとNXDOMAINとなることがあったので’qtype=TXT’としています。
最後にチャレンジ用に作成したTXTレコードを削除します。 この操作は前段の操作が失敗しようが行って欲しいので、alwaysをつけています。
全体
長いので折りたたみますが、プレイブック全体は以下のようになっています。
プレイブック全体
---- hosts: localhost gather_facts: false vars_prompt: - name: cloudflare_api_token prompt: Cloudflare API Token private: true
tasks: # プレイブック内にansible vaultのパスワードを読み込む - name: Load vault encrypt password ansible.builtin.include_vars: file: "secret/ansible_vault_password.yaml" no_log: true
# ACMEアカウント用の秘密鍵を作成し - name: Generate ACME account key community.crypto.openssl_privatekey_pipe: type: RSA size: 4096 content: "{{ lookup('ansible.builtin.file', 'secret/certificate/acme_account.key', errors='ignore') | default('') }}" no_log: true register: acme_account_key
# 保存する - name: Save encrypted ACME account key when: acme_account_key.changed ansible.builtin.copy: content: "{{ acme_account_key.privatekey | vault(ansible_vault_password) }}" dest: "secret/certificate/acme_account.key" decrypt: false no_log: true
# ACMEアカウントを作成する - name: Make sure account exists and has given contacts community.crypto.acme_account: acme_directory: "https://acme-staging-v02.api.letsencrypt.org/directory" acme_version: 2 account_key_content: "{{ lookup('ansible.builtin.file','secret/certificate/acme_account.key') }}" state: present terms_agreed: true contact: - "mailto:hogehoge@example.com"
# サーバー用秘密鍵を作成し - name: Generate OpenSSL private key community.crypto.openssl_privatekey_pipe: type: RSA size: 4096 content: "{{ lookup('ansible.builtin.file', 'secret/certificate/server.key', errors='ignore') | default('') }}" no_log: true register: server_key
# 保存する - name: Save encrypted server private key when: server_key.changed ansible.builtin.copy: content: "{{ server_key.privatekey | vault(ansible_vault_password) }}" dest: "secret/certificate/server.key" decrypt: false no_log: true
# CSRを作成する - name: Generate an OpenSSL Certificate Signing Request community.crypto.openssl_csr_pipe: privatekey_content: "{{ lookup('ansible.builtin.file','secret/certificate/server.key') }}" common_name: '*.subdomain.example.com' subject_alt_name: - "DNS:*.subdomain.example.com" register: certificate_request changed_when: false
# CSRを後のタスクのために変数に入れる - community.crypto.openssl_csr_info: content: "{{ certificate_request.csr }}" register: csr_info
# CAのルート証明書を取得する - name: Download root certificate ansible.builtin.get_url: url: https://letsencrypt.org/certs/staging/letsencrypt-stg-root-x1.pem force: true dest: "secret/certificate/ca.crt"
- name: Create a challenge using a account key file. community.crypto.acme_certificate: acme_directory: "https://acme-staging-v02.api.letsencrypt.org/directory" acme_version: 2 remaining_days: 60 challenge: dns-01 account_key_content: "{{ lookup('ansible.builtin.file','secret/certificate/acme_account.key') }}" csr_content: "{{ certificate_request.csr }}" dest: "secret/certificate/server.crt" fullchain_dest: "secret/certificate/server-full.crt" register: challenge
- name: Generate Certificate block: - block: # TXTレコードを作成する - name: Create a TXT record community.general.cloudflare_dns: zone: "example.com" type: TXT ttl: 120 record: _acme-challenge.subdomain value: "{{ challenge.challenge_data['*.subdomain.example.com']['dns-01']['resource_value'] }}" account_email: hogehoge@example.com api_token: {{ cloudflareのAPIトークン }} state: present
# TXTレコードが更新されるのを待つ - name: Wait Until challenge record is up ansible.builtin.debug: msg: "Waiting for record is up." retries: 10 until: lookup('community.general.dig', '_acme-challenge.subdomain.example.com', '@8.8.8.8', 'qtype=TXT') == challenge.challenge_data['*.subdomain.example.com']['dns-01']['resource_value'] delay: 30
when: challenge.authorizations['*.subdomain.example.com'].status != 'valid'
# 証明書を発行する - name: Let the challenge be validated and retrieve the cert and intermediate certificate community.crypto.acme_certificate: acme_directory: "https://acme-staging-v02.api.letsencrypt.org/directory" acme_version: 2 account_key_content: "{{ lookup('ansible.builtin.file','secret/certificate/acme_account.key') }}" account_email: hogehoge@example.com csr_content: "{{ certificate_request.csr }}" dest: "secret/certificate/server.crt" fullchain_dest: "secret/certificate/server-full.crt" challenge: dns-01 remaining_days: 60 data: "{{ challenge }}"
# TXTレコードを削除する always: - name: Delete TXT record for challenge community.general.cloudflare_dns: zone: "example.com" type: TXT record: _acme-challenge.subdomain account_email: hogehoge@example.com api_token: {{ cloudflareのAPIトークン }} state: absent
when: challenge is changed and '*.subdomain.example.com' in challenge.authorizations証明書系をローカルに保存していますが、証明書をそのままサーバーに配置したいなどであればサーバーで実行するのがよいです。
次のステップ
この実装では、プレイブックを実行すると自動で一連の処理を行ってくれますが、90日という有効期限が切れそうなことを検知する仕組みがありません。 そもそもAnsibleはそれを目的としたツールではないので、適当な仕組みを使ってAnsibleを自動実行する仕組みがあると実現できそうです。