Kotaro7750's Gehirn
Home About
  • Home
  • Category
  • About

AnsibleでACMEのDNSチャレンジによるワイルドカードTLS証明書発行を自動化する

2024-06-09

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をみていただければと思いますが、証明書発行部分の仕組みは非常にシンプルで、

  1. 認証局が「本当にそのドメインを保有しているか?」というチャレンジを発行
  2. チャレンジに従ってドメインの保持を証明する

というものになっています。 よく5chでID付きの写真を上げて「嘘松」を回避しているのと発想としては同じです。

チャレンジには、

  • HTTPチャレンジ:証明書を発行する対象のサーバーに指定されたトークンファイルを配置する
  • DNSチャレンジ:証明書を発行する対象のサーバーのDNSレコードに指定されたトークンを記述する

といった種類があり、今回はCloudflare APIを用いて簡単に行えるDNSチャレンジを用います。

まとめると、図のような流れでTLS証明書を発行します。 ACMEのDNSチャレンジによるTLS証明書作成の流れ

実装

Ansibleの実装としてはシンプルで、

  1. CSRの作成
  2. ACMEチャレンジの発行
  3. DNSレコードの作成・伝搬待ち
  4. 証明書の発行

というような流れとなっています。

ディレクトリ構成は、以下のようにプレイブックと同階層に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を自動実行する仕組みがあると実現できそうです。

Related Posts

Golangのnet/httpの実装からHTTP1.1クライアントでのTCPコネクションの挙動を確かめる

Golangのnet/httpの実装からHTTP1.1クライアントでのTCPコネクションの挙動を確かめる

2025-03-21

Packerで実現するKVMでのイミュータブルインフラストラクチャ

Packerで実現するKVMでのイミュータブルインフラストラクチャ

2024-09-27

Synology DS1522+を買ったので宅鯖用に仮想化環境をIaCでセットアップする ~コンテナ編~

Synology DS1522+を買ったので宅鯖用に仮想化環境をIaCでセットアップする ~コンテナ編~

2024-07-15

New Posts

スケーラブルなOpenTelemetry CollectorパイプラインをKubernetes上に構築する

スケーラブルなOpenTelemetry CollectorパイプラインをKubernetes上に構築する

2025-04-14

OpenTelemetry Certified Associate ( OTCA ) を取得した

OpenTelemetry Certified Associate ( OTCA ) を取得した

2025-04-13

Golangのnet/httpの実装からHTTP1.1クライアントでのTCPコネクションの挙動を確かめる

Golangのnet/httpの実装からHTTP1.1クライアントでのTCPコネクションの挙動を確かめる

2025-03-21

ToC

  • TL; DR;
  • モチベーション
  • 前提
  • ACMEによるTLS証明書発行の流れ
  • 実装
  • 1. CSRの作成
  • 2. ACMEチャレンジの発行
  • 3. DNSレコードの作成・伝搬待ち
  • 全体
  • 次のステップ

Ads

Ads

Privacy Policy