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.yaml
New Vault password:
Confirm New Vault password:
Encryption successful
$ cat ./ansible_vault_password.yaml
$ANSIBLE_VAULT;1.1;AES256
66356663316366313238376438633465386537383237663331623938343839376537396537636330
6439653866343562336231366138356135666136343163650a623163356230383261653835323030
35333466386462663738633237663037333833373030643730333264346266363863346539356163
6233373634623363330a336565356135343966373162313162356365626466643333306331346265
3439
1. 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:[email protected]"
# サーバー用秘密鍵を作成し
- 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: challenge
3. 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: [email protected]
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: [email protected]
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: [email protected]
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:[email protected]"
# サーバー用秘密鍵を作成し
- 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: [email protected]
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: [email protected]
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: [email protected]
api_token: {{ cloudflareのAPIトークン }}
state: absent
when: challenge is changed and '*.subdomain.example.com' in challenge.authorizations
証明書系をローカルに保存していますが、証明書をそのままサーバーに配置したいなどであればサーバーで実行するのがよいです。
次のステップ
この実装では、プレイブックを実行すると自動で一連の処理を行ってくれますが、90日という有効期限が切れそうなことを検知する仕組みがありません。 そもそもAnsibleはそれを目的としたツールではないので、適当な仕組みを使ってAnsibleを自動実行する仕組みがあると実現できそうです。