🔒

ホームラボ向け Proxmox 自動構築 Part4 ── ACME + Cloudflare DNS-01 で証明書を自動管理

に公開

概要

Part3 では、Keepalived + Nginx で VIP と標準ポートアクセスを実現しましたが、自己署名証明書によるブラウザの警告が残っていました。本記事では、ACME(Let's Encrypt)+ Cloudflare DNS-01 チャレンジで証明書を自動発行し、この課題を解消します。

  • Cloudflare DNS-01 チャレンジ: プライベートネットワークでも Let's Encrypt 証明書を取得可能
  • ステージング → 本番の 2 段階フロー: Let's Encrypt のレート制限を安全に回避
  • VIP ドメインの SAN 追加: 各ノードの証明書に VIP ドメインも含め、どのノードが MASTER でも有効な証明書を提供
  • configure_proxmox.yml を実行するだけ: DNS レコード登録から証明書発行・Nginx リロードまで一気通貫で完了

https://github.com/tjst-t/ansible-proxmox-infra

背景

Part3 では、Nginx リバースプロキシで Proxmox の Web UI を標準ポート(443)で公開しました。しかし、SSL 証明書には Proxmox がデフォルトで生成する自己署名証明書を使用しており、ブラウザでアクセスするたびに証明書の警告が表示される状態でした。

Part3 の末尾で予告した通り、本記事ではこの課題を解消します。

configure_proxmox.yml のロール適用順序を再掲します。今回の対象は proxmox_acme ロールです。cloudflare_dns ロールは前提となる DNS レコードの登録を担当します。

playbooks/configure_proxmox.yml(ロール適用順序)
roles:
  - role: proxmox_repo
  - role: proxmox_cluster
  - role: cloudflare_dns       # DNS レコード登録
  - role: proxmox_acme         # ← 今回の対象
  - role: nginx
  - role: proxmox_keepalived
  - role: proxmox_dc_settings

proxmox_acmenginx より前に実行されます。ACME で発行された証明書はデフォルトの自己署名証明書とは異なるパスに配置されるため、nginx ロールでは ACME 証明書のパスを参照するように設定しています。

課題

証明書の自動管理を実現するにあたり、以下の課題がありました。

  • 自己署名証明書の警告: ブラウザで毎回「この接続ではプライバシーが保護されません」と表示される。API クライアントからのアクセスでも証明書検証を無効化する必要がある
  • HTTP-01 チャレンジが使えない: Let's Encrypt の一般的な認証方式である HTTP-01 は、インターネットから HTTP ポートにアクセスできる必要がある。プライベートネットワーク上の Proxmox ノードでは利用できない
  • Let's Encrypt のレート制限: 本番環境では同一ドメインに対して週 5 回までしか証明書を発行できない。設定ミスで何度もリトライすると、制限に達して一定期間発行できなくなる
  • VIP ドメインの証明書カバレッジ: VIP(pve-cluster.example.com)経由のアクセスでも有効な証明書を提供する必要がある。Keepalived のフェイルオーバーにより、どのノードが MASTER になっても VIP ドメインに対応した証明書を持っていなければならない

アプローチ

DNS-01 チャレンジ + Cloudflare API

HTTP-01 が使えないプライベートネットワーク環境では、DNS-01 チャレンジが選択肢になります。DNS-01 はドメインの TXT レコードを設定することで所有権を証明する方式で、サーバへのインバウンドアクセスが不要です。

DNS プロバイダには Cloudflare を採用しました。Proxmox が DNS-01 用のプラグインを公式に提供しており、API トークンを設定するだけで TXT レコードの作成・削除を自動化できます。

Proxmox 組み込みの ACME 機能

Proxmox VE には ACME クライアントが組み込まれています。pvenode acme コマンドで証明書の発行・更新ができ、発行された証明書は /etc/pve/local/pveproxy-ssl.pem に自動配置されます。これはデフォルトの自己署名証明書とパスが異なるため、Part3 で設定した Nginx が参照しているパスを修正して、リロードする必要があります。

ステージング → 本番の 2 段階フロー

レート制限対策として、まず Let's Encrypt のステージング環境で証明書の発行を試み、成功した場合にのみ本番環境で発行する 2 段階フローを実装しました。ステージング環境にはレート制限がないため、設定ミスがあっても何度でもやり直せます。

VIP ドメインの SAN 追加

各ノードの証明書に VIP ドメインを SAN(Subject Alternative Name) として追加します。これにより、どのノードが Keepalived の MASTER になっても、VIP ドメインに対して有効な証明書を提供できます。

関連ロールの構成

証明書の自動管理は 2 つのロールで構成されています。

ロール 役割
cloudflare_dns Cloudflare に DNS A レコードを登録(ノード + VIP)
proxmox_acme ACME アカウント登録、プラグイン設定、証明書発行

前提となる DNS レコード登録(cloudflare_dns ロール)

ACME の DNS-01 チャレンジでは、対象ドメインの DNS レコードが存在している必要があります。cloudflare_dns ロールは、各ノードのホスト名と VIP のドメインを Cloudflare に A レコードとして登録します。

roles/cloudflare_dns/tasks/main.yml
- name: Manage Cloudflare DNS Records
  block:
    - name: Register Host A Record
      community.general.cloudflare_dns:
        api_token: "{{ cloudflare_api_token }}"
        zone: "{{ cloudflare_zone }}"
        record: "{{ (proxmox_hostname + '.' + bootstrap_domain) | replace('.' + cloudflare_zone, '') }}"
        type: A
        value: "{{ ansible_host }}"
        proxied: "{{ cloudflare_dns_proxied }}"
        state: present
      delegate_to: localhost

    - name: Register VIP A Record
      community.general.cloudflare_dns:
        api_token: "{{ cloudflare_api_token }}"
        zone: "{{ cloudflare_zone }}"
        record: "{{ proxmox_vip_domain | replace('.' + cloudflare_zone, '') }}"
        type: A
        value: "{{ proxmox_vip }}"
        proxied: "{{ cloudflare_dns_proxied }}"
        state: present
      delegate_to: localhost
      run_once: true
      when: proxmox_vip is defined
  when: cloudflare_dns_enabled | bool

このタスクのポイントは以下の通りです。

  • delegate_to: localhost: DNS API の呼び出しはコントロールノードから行います。Proxmox ノードからインターネットへのアクセスが不要です
  • run_once: true(VIP レコード): VIP の A レコードは全ノード共通なので、1 回だけ実行します
  • cloudflare_dns_enabled: インベントリで false に設定すれば DNS 登録をスキップできます(既に登録済みの場合など)
  • record の組み立て: pve01.example.com から Cloudflare のゾーン名(example.com)部分を除去し、レコード名(pve01)を抽出しています

登録される DNS レコードの例:

レコード タイプ
pve01.example.com A 172.16.1.11
pve02.example.com A 172.16.1.12
pve-cluster.example.com A 172.16.1.10

実装の詳細 -- データセンター設定(datacenter.yml)

proxmox_acme ロールは、データセンターレベルの設定(ACME アカウント・プラグイン)とノードレベルの設定(証明書発行)に分かれています。

roles/proxmox_acme/
├── defaults/
│   └── main.yml
└── tasks/
    ├── main.yml
    ├── datacenter.yml    # ACME アカウント・プラグイン設定
    └── node.yml          # 証明書発行
roles/proxmox_acme/tasks/main.yml
- name: DataCenter ACME Configuration
  include_tasks: datacenter.yml
  run_once: true

- name: Node ACME Configuration
  include_tasks: node.yml

datacenter.ymlrun_once: true でクラスタ内の 1 ノードだけが実行します。ACME アカウントとプラグインはクラスタ全体で共有されるためです。なお、データセンター設定では pvesh(クラスタ全体の API にアクセスするコマンド)、ノード設定では pvenode(ノードローカルの設定を操作するコマンド)を使い分けています。

ACME アカウント登録

roles/proxmox_acme/tasks/datacenter.yml(アカウント登録)
- name: Check existing ACME accounts
  command: pvesh get /cluster/acme/account
  register: acme_accounts
  changed_when: false
  run_once: true

- name: Register Staging ACME account
  command: >
    pvesh create /cluster/acme/account
    --name {{ proxmox_acme_account_staging }}
    --contact {{ proxmox_acme_email }}
    --directory {{ proxmox_acme_directory_staging }}
    --tos_url {{ proxmox_acme_tos_staging }}
  when:
    - proxmox_acme_account_staging not in acme_accounts.stdout
  run_once: true

- name: Register Production ACME account
  command: >
    pvesh create /cluster/acme/account
    --name {{ proxmox_acme_account_prod }}
    --contact {{ proxmox_acme_email }}
    --directory {{ proxmox_acme_directory_production }}
    --tos_url {{ proxmox_acme_tos_production }}
  when:
    - proxmox_acme_account_prod not in acme_accounts.stdout
  run_once: true

ステージング用と本番用の 2 つの ACME アカウントを登録します。pvesh get /cluster/acme/account の出力にアカウント名が含まれていなければ作成するため、べき等に動作します。

デフォルト変数は以下の通りです。

roles/proxmox_acme/defaults/main.yml
proxmox_acme_account_staging: "letsencrypt-staging"
proxmox_acme_account_prod: "letsencrypt-prod"
proxmox_acme_email: "admin@example.com"
proxmox_acme_directory_staging: "https://acme-staging-v02.api.letsencrypt.org/directory"
proxmox_acme_directory_production: "https://acme-v02.api.letsencrypt.org/directory"
proxmox_acme_tos_staging: "https://letsencrypt.org/documents/LE-SA-v1.4-April-3-2024.pdf"
proxmox_acme_tos_production: "https://letsencrypt.org/documents/LE-SA-v1.4-April-3-2024.pdf"
proxmox_acme_plugin_id: "cloudflare"

Cloudflare DNS-01 プラグイン設定

roles/proxmox_acme/tasks/datacenter.yml(プラグイン設定)
- name: Check existing ACME plugins
  command: pvesh get /cluster/acme/plugins --output-format json
  register: acme_plugins_result
  changed_when: false
  run_once: true

- name: Set plugin existence flag
  set_fact:
    acme_plugin_exists: >-
      {{ acme_plugins_result.stdout | from_json
         | selectattr('plugin', 'equalto', proxmox_acme_plugin_id)
         | list | length > 0 }}
  run_once: true

- name: Manage Cloudflare ACME Plugin fresh (only if missing)
  block:
    - name: Create temporary credential file for Cloudflare
      copy:
        content: "CF_Token={{ cloudflare_api_token }}"
        dest: "/tmp/proxmox_acme_cf"
        mode: '0600'

    - name: Create Cloudflare ACME Plugin using data file
      command: >-
        pvenode acme plugin add dns {{ proxmox_acme_plugin_id }}
        --api cf --data /tmp/proxmox_acme_cf

    - name: Remove temporary credential file
      file:
        path: "/tmp/proxmox_acme_cf"
        state: absent
  when: not acme_plugin_exists
  run_once: true

Cloudflare の API トークンは pvenode acme plugin add--data オプションでファイル経由で渡します。直接コマンドライン引数に含めるとプロセス一覧(ps aux)に表示されるリスクがあるため、一時ファイルを使用しています。ファイルのパーミッションは 0600 に設定し、プラグイン作成後は即座に削除します。

プラグインが既に存在する場合はスキップするため、繰り返し実行しても問題ありません。

実装の詳細 -- ノード証明書発行(node.yml)

node.yml は各ノードで実行され、証明書の状態を確認してから発行を行います。

証明書状態のチェック

roles/proxmox_acme/tasks/node.yml(状態チェック)
- name: Check node config for ACME
  command: "pvenode config get"
  register: node_config
  changed_when: false

- name: Check current certificate issuer
  shell: "openssl x509 -noout -issuer -in /etc/pve/local/pveproxy-ssl.pem"
  register: cert_issuer
  failed_when: false
  changed_when: false

- name: Check current certificate SAN
  shell: "openssl x509 -noout -ext subjectAltName -in /etc/pve/local/pveproxy-ssl.pem"
  register: cert_san
  failed_when: false
  changed_when: false

- name: Determine if Production certificate is already active
  set_fact:
    acme_prod_active: >
      {{
        (proxmox_hostname + '.' + bootstrap_domain in node_config.stdout) and
        (proxmox_acme_account_prod in node_config.stdout) and
        ('STAGING' not in cert_issuer.stdout) and
        (proxmox_vip_domain in cert_san.stdout)
      }}

本番証明書が有効かどうかを 4 つの条件で判定します。

条件 チェック内容
ノードのドメインが ACME 設定に存在 pvenode config get にノードの FQDN が含まれている
本番アカウントが設定済み 設定に letsencrypt-prod が含まれている
証明書がステージングでない Issuer に STAGING が含まれていない
VIP ドメインが SAN に含まれている SAN に VIP ドメインが存在する

4 つすべてを満たしていれば acme_prod_activetrue になり、証明書発行をスキップします。この判定により、既に本番証明書が正しく発行されているノードに対して不要な再発行を防ぎます。

ステージング → 本番の 2 段階発行

roles/proxmox_acme/tasks/node.yml(証明書発行)
- name: ACME Certificate Management Block
  block:
    - name: "Configure ACME Account (Step 1: Staging)"
      command: "pvenode config set --acme account={{ proxmox_acme_account_staging }}"

    - name: "Configure ACME Domain (Step 1: Staging)"
      command: >-
        pvenode config set
        --acmedomain0 domain={{ proxmox_hostname }}.{{ bootstrap_domain }},plugin={{ proxmox_acme_plugin_id }}
        --acmedomain1 domain={{ proxmox_vip_domain }},plugin={{ proxmox_acme_plugin_id }}

    - name: "Order ACME Certificate (Staging)"
      shell: "pvenode acme cert order 2>&1 | iconv -c -f UTF-8 -t UTF-8"
      register: staging_cert
      failed_when: false

    - name: "Check Staging Certification Result"
      fail:
        msg: |
          Staging certificate order failed.
          Output: {{ staging_cert.stdout }}
          Error: {{ staging_cert.stderr }}
      when: staging_cert.rc != 0

    - name: "Configure ACME Account (Step 2: Production)"
      command: "pvenode config set --acme account={{ proxmox_acme_account_prod }}"
      when: staging_cert.rc == 0

    - name: "Order ACME Certificate (Production)"
      shell: "pvenode acme cert order --force"
      when: staging_cert.rc == 0
      register: cert_order
      failed_when: false
      notify: reload nginx

    - name: "Check Production Certification Result"
      fail:
        msg: |
          Production certificate order failed.
          Output: {{ cert_order.stdout }}
      when:
        - staging_cert.rc == 0
        - cert_order.rc != 0
  when: not acme_prod_active | bool

発行フローの流れを追っていきます。

Step 1: ステージング証明書の発行

  1. ACME アカウントをステージング用(letsencrypt-staging)に設定
  2. --acmedomain0 にノードの FQDN、--acmedomain1 に VIP ドメインを設定し、Cloudflare プラグインを指定
  3. pvenode acme cert order でステージング証明書を発行

ステージング環境の証明書はブラウザでは信頼されませんが、ACME プロトコルのフロー全体(DNS-01 チャレンジの TXT レコード作成・検証・証明書受信)が正しく動作することを検証できます。

Step 2: 本番証明書の発行

  1. ステージングが成功した場合のみ、ACME アカウントを本番用(letsencrypt-prod)に切り替え
  2. --force オプションで証明書を再発行(ステージング証明書を本番証明書で上書き)

本番証明書の発行に成功すると、notify: reload nginx で Nginx のリロードがトリガーされ、新しい証明書が即座に反映されます。

動作確認

Before(自己署名証明書)

issuer=O = Proxmox Virtual Environment, CN = Proxmox Virtual Environment

ブラウザで https://pve-cluster.example.com にアクセスすると、「この接続ではプライバシーが保護されません」の警告が表示されていました。

After(Let's Encrypt 証明書)

証明書の Issuer:

$ openssl x509 -noout -issuer -in /etc/pve/local/pveproxy-ssl.pem
issuer=C = US, O = Let's Encrypt, CN = R10

証明書の SAN:

$ openssl x509 -noout -ext subjectAltName -in /etc/pve/local/pveproxy-ssl.pem
X509v3 Subject Alternative Name:
    DNS:pve01.example.com, DNS:pve-cluster.example.com

ノード設定:

$ pvenode config get
acme: account=letsencrypt-prod
acmedomain0: domain=pve01.example.com,plugin=cloudflare
acmedomain1: domain=pve-cluster.example.com,plugin=cloudflare

ブラウザの警告は消え、鍵アイコンが表示されるようになりました。各ノードのドメイン(pve01.example.com)と VIP ドメイン(pve-cluster.example.com)の両方で有効な証明書が確認できます。

振り返り

うまくいった点

  • ステージング → 本番の 2 段階フロー: 開発中にステージング環境で何度もテストできたため、本番のレート制限に引っかかることなく安全にデバッグできた
  • Proxmox 組み込み ACME の活用: certbot などの外部ツールを導入せず、pvenode acme だけで完結した。証明書の配置パスも Proxmox 標準のまま

まとめ

proxmox_acme ロールと cloudflare_dns ロールにより、Proxmox クラスタの証明書管理を自動化しました。

  • DNS-01 チャレンジ: プライベートネットワークでも Let's Encrypt 証明書を取得
  • ステージング → 本番: レート制限を気にせず安全にデプロイ
  • VIP 対応: SAN に VIP ドメインを含めることで、フェイルオーバー後も有効な証明書を提供
  • Nginx との連携: 証明書発行後に Nginx を自動リロードし、ダウンタイムなしで反映

これで Part1(ISO 自動生成・ブートストラップ)、Part2(リポジトリ設定・クラスタ構築)、Part3(Keepalived + Nginx)、Part4(ACME 証明書)を通じて、Proxmox VE クラスタの構築から証明書管理までを ansible-playbook コマンドだけで完了できる状態になりました。

Discussion