1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HashiCorp Vault PKI External CA機能でDNS-01チャレンジ - Vault 2.1新機能

1
Posted at

1. はじめに

本記事は、HashiCorp Vault 2.1で追加されたDNS-01チャレンジ対応をこれから実装する人向けに、実機で検証した内容を整理したものです。前作「IBM Bobで実装しながら理解する:HashiCorp Vault PKI External CA機能 - Vault 2.0新機能」(Vault 2.0 / HTTP-01チャレンジ)の続編にあたります。

Vault 2.0のPKI External CA機能は、Vault AgentがACMEクライアントとして動作しHTTP-01チャレンジでLet's Encryptなどのパブリック認証局から証明書を取得する仕組みでした。この方式には2つの制約があります。

  • 80番ポートを外部に開放する必要がある(ACMEサーバがHTTP-01チャレンジのためにinboundしてくる)
  • ワイルドカード証明書を取得できない(HTTP-01はホスト単位のチャレンジのため)

Vault 2.1では、この2つの制約を解消するDNS-01チャレンジが使えるようになりました。Vaultが自分でDNSプロバイダ(本記事ではRoute53)にTXTレコードを書き込み、80番ポートを一切開けずに、ワイルドカード証明書まで取得できます。

現時点(検証時点)のVault Agentは、DNS-01チャレンジに対応していません。

今回の検証で使用した技術:

  • Vault Enterpriseのpki-external-caシークレットエンジン(DNS-01チャレンジ / aws-route53プロバイダ)
  • AppRoleで認証する証明書取得スクリプト(curl + jq。Vault AgentはDNS-01非対応のため、スクリプトで代替)
  • AWS Route53(DNSプロバイダ)とAWS CloudTrail(DNS操作の独立検証)
  • Terraformによるインフラ構成管理

この記事で扱う内容:

  • DNS-01チャレンジを実装するときに最低限理解しておくべき構成
  • Terraformでの設定ポイント(DNSプロバイダ・AppRole・ポリシー)
  • 証明書取得スクリプトの設計(Vault Agentが使えない理由と、そのために必要な設計判断)
  • DNS操作の裏取り(CloudTrail)
  • 公式ドキュメントに無い、または実測で確かめた値・落とし穴
  • 前作(HTTP-01)との違い

この記事で扱わない内容:

  • EC2インスタンスのセットアップ
  • Vaultのインストール
  • nginxのインストール
  • HTTP-01チャレンジの詳細(前作を参照)

HashiCorp公式ドキュメント:

2. 検証環境の概要

2.1 前作(Vault 2.0/HTTP-01)との違い

前作(Vault 2.0 / HTTP-01)と今回(Vault 2.1 / DNS-01)における実装方法や仕様の違いを以下にまとめます。

論点 Vault 2.0 / HTTP-01 Vault 2.1 / DNS-01
チャレンジの配信 nginxが.well-known/acme-challenge/を80番で配信 配信しない。 VaultがRoute53にTXTを書く
80番ポート 全世界に開放が必須(ACMEサーバがinboundしてくる) 開けない。 listenもSGルールも存在しない
ワイルドカード証明書 不可能 可能(*.example.com)
証明書の取得・配布 Vault Agent(設定ファイルのみで自動取得) スクリプト(curl / API呼び出し)(※1)
DNS設定変更の実行主体 なし(DNS操作自体が不要) Vault(nginx/クライアント側は権限不要)(※2)
  • ※1: Vault 2.1ではVaultサーバー本体はDNS-01に対応しましたが、クライアント常駐プログラムである「Vault Agent」は現時点でDNS-01に対応していません。そのため、本検証ではWebサーバー側からAPI(curl + jq)で発行要求と証明書配置を行うスクリプトを用意しています。
  • ※2: VaultがRoute53を操作するための認証には、本検証ではVaultホストのIAMロールのみを使用しています。access_key_id/assume_role_arnによる明示的な認証情報も設定可能ですが、動作は未検証です。

本記事で使うACMEの用語は次の2つです。

  • identifier: 証明書に載せるドメイン名(例: nginx.example.com、*.example.com)。オーダーごとに指定します。
  • authorization: 「このACMEアカウントはこのidentifierを管理している」とACMEサーバが認めた記録です。チャレンジに合格するとvalidになり、一定期間は同じアカウントの次のオーダーで再利用されます(4.3.4節)。

前作(HTTP-01)ではVaultのAudit Logを中心に処理ログや挙動を確認していましたが、DNS-01版では以下の3つの観点から動作とログを確認します。

  1. ポリシーによる権限分離の確認: スクリプトのトークンでチャレンジ系のパスを呼ぶと403になり、手動履行がポリシーで禁止されていること(3.2節)
  2. DNS変更の証跡確認: CloudTrailで、Vaultが実際にRoute53を呼び出した時刻と呼び出し元IAMロールまで特定できること(4.4節)
  3. authorization再利用の確認: 有効なauthorizationが残っている間の再取得では、Route53呼び出しが0件になること(TXTが作られなかったことのAWS側での証明。4.3.4・4.4.3節)

2.2 構成図・シーケンス図

以下は、証明書取得スクリプト(curl+jq)を中心とした全体のデータフローです。前作(HTTP-01)ではVault Agentがnginxホスト上に常駐し証明書取得を自律実行していましたが、今回はその役割を証明書取得スクリプトが引き継ぎ、チャレンジの履行はVaultからRoute53への直接呼び出しに置き換わっています。

※ 「Let's Encrypt → Route53」の点線は、Let's Encryptが公開DNSに対して行う名前解決クエリです(AWS API呼び出しではないためCloudTrailには記録されません)。「Vault → CloudTrail」の点線は、Vaultが行ったRoute53 API呼び出しがAWS側で自動記録されることを示しています(Vaultが明示的にCloudTrailへログを送信しているわけではありません)。

以下は、証明書取得スクリプトが実行する6ステップのAPIシーケンスです。

2.3 使用バージョン

検証環境は以下の構成です(nginxホストはAmazon Linux 2023)。

項目 値
Vault 2.1.0+ent.fips1403
エンジン pki-external-ca(プラグイン版数 v0.1.9+builtin。Vault本体の版数とは別管理)
ACME Let's Encrypt staging(https://acme-staging-v02.api.letsencrypt.org/directory)
DNS Route53(リージョン ap-northeast-1)
Terraform 1.12.2 / hashicorp/vault provider 5.12.0

3. Vaultの設定

3.1 pki-external-caエンジンの有効化とDNS-01発行設定

本検証はTerraformで構成しています。以下の設定例は、執筆時点で最新のhashicorp/vault provider 5.12.0(2026年9月17日リリース)で書いています。4章の動作検証もこの構成で行いました。

まずproviderのバージョン固定と接続設定です。Vaultへの接続情報(VAULT_ADDR・VAULT_TOKEN・VAULT_CACERT)はtfvarsやstateにroot tokenを平文で置かないよう、環境変数で渡しています。

versions.tf
terraform {
  required_version = ">= 1.5"

  required_providers {
    vault = {
      source  = "hashicorp/vault"
      version = "~> 5.12"
    }
  }
}

provider "vault" {}

続いてリソース定義です。

main.tf
resource "vault_mount" "pki" {
  path        = var.mount_path
  type        = "pki-external-ca"
  description = "Public CA (Let's Encrypt) via ACME DNS-01"
}

resource "vault_pki_external_ca_secret_backend_acme_account" "le" {
  mount          = vault_mount.pki.path
  name           = var.acme_account_name
  directory_url  = var.acme_directory_url
  email_contacts = var.acme_email_contacts

  # 注意: 省略すると、plan では ec-256 と表示されるが、apply では provider が空文字を送り
  #   Vault が 400(invalid key_type field)を返す(provider 5.12.0 で実測)。明示が必要。
  key_type = var.acme_key_type
}

各変数の定義と既定値は以下の通りです。

variables.tf
variable "mount_path" {
  description = "pki-external-ca シークレットエンジンのマウントパス"
  type        = string
  default     = "pki-external-ca"
}

variable "acme_account_name" {
  description = "ACME アカウント名"
  type        = string
  default     = "letsencrypt-staging"
}

variable "acme_directory_url" {
  description = "ACME ディレクトリ URL(本検証は staging 固定)"
  type        = string
  default     = "https://acme-staging-v02.api.letsencrypt.org/directory"
}

variable "acme_email_contacts" {
  description = "ACME アカウントの連絡先。list(string) の必須。mailto: プレフィックスは付けない"
  type        = list(string)
}

variable "acme_key_type" {
  description = "ACME アカウント鍵の種類。有効値: ec-256 / ec-384 / ec-521 / rsa-2048 / rsa-4096 / rsa-8192"
  type        = string
  default     = "ec-256"
}

variable "role_name" {
  description = "証明書発行に使うロール名"
  type        = string
  default     = "web-servers"
}

variable "allowed_domain_options" {
  description = "ドメインマッチングのオプション(bare_domains / subdomains / wildcards / globs)"
  type        = list(string)
  default     = ["bare_domains", "subdomains", "globs", "wildcards"]
}

variable "allowed_challenge_types" {
  description = "許可するチャレンジ方式。既定は http-01 / dns-01 / tls-alpn-01 の3方式なので、dns-01 だけを明示する"
  type        = list(string)
  default     = ["dns-01"]
}

variable "dns_provider_name" {
  description = "DNS プロバイダ設定の名前"
  type        = string
  default     = "route53-fitz"
}

variable "dns_ttl" {
  description = "TXT レコードの TTL(秒)"
  type        = number
  default     = 60
}

variable "approle_role_name" {
  description = "AppRole のロール名"
  type        = string
  default     = "acme-dns01-orchestrator"
}

variable "approle_token_ttl" {
  description = "AppRole トークンの TTL(秒)。ポーリング上限 10 分より長くする"
  type        = number
  default     = 3600
}

variable "approle_token_max_ttl" {
  description = "AppRole トークンの最大 TTL(秒)"
  type        = number
  default     = 7200
}

domain_name・route53_zone_id・aws_regionはphase1のTerraform stateからterraform_remote_stateで受け取るため、変数定義には現れません(詳細はmain.tfのlocalsブロック)。

terraform.tfvarsは以下の通りです(メールアドレスはマスク済み)。

terraform.tfvars
mount_path = "pki-external-ca"

acme_account_name   = "letsencrypt-staging"
acme_directory_url  = "https://acme-staging-v02.api.letsencrypt.org/directory"
acme_email_contacts = ["<masked-email>"]

role_name               = "web-servers"
allowed_domain_options  = ["bare_domains", "subdomains", "globs", "wildcards"]
allowed_challenge_types = ["dns-01"]

dns_provider_name = "route53-fitz"
dns_ttl           = 60

approle_role_name     = "acme-dns01-orchestrator"
approle_token_ttl     = 3600
approle_token_max_ttl = 7200
acme_key_type         = "ec-256"

DNSプロバイダは、5.12.0で追加された専用リソースvault_pki_external_ca_secret_backend_dns_provider_aws_route53で書きます。

main.tf
resource "vault_pki_external_ca_secret_backend_dns_provider_aws_route53" "route53" {
  mount          = vault_mount.pki.path
  name           = var.dns_provider_name
  identifiers    = local.dns_identifiers
  region         = local.aws_region # 注意: 省略しない(省略すると plan に差分が出る。doc 上の既定は us-east-1)
  hosted_zone_id = local.route53_zone_id
  ttl            = var.dns_ttl # 数値(秒)
  # 認証情報を書かなければ、Vault ホストのインスタンスプロファイル(IAM ロール)を使う
}

読み戻した設定の実例です(provider 5.12.0で作成したrole)。

$ vault read pki-external-ca/role/web-servers
Key                          Value
---                          -----
acme_account_name            letsencrypt-staging
allowed_challenge_types      [dns-01]
allowed_domain_options       [bare_domains subdomains globs wildcards]
allowed_domains              [<masked-domain> *.<masked-domain>]
csr_generate_key_type        ec-256
csr_identifier_population    cn_first
dns_provider_name            route53-fitz
dns_provider_type            aws-route53
name                         web-servers

dns_provider_name/dns_provider_typeは、provider 5.12.0でvault_pki_external_ca_secret_backend_roleに追加されたフィールドです。本検証の構成ではdns_provider_name = vault_pki_external_ca_secret_backend_dns_provider_aws_route53.route53.name・dns_provider_type = "aws-route53"を設定しており、Vault上にも値が入っています。この状態で、単一ドメイン証明書(nginx.<masked-domain>)とワイルドカード証明書の発行(DNS-01の自動履行)まで成功しました。

3.2 ポリシー設定

前作のVault Agent版は、Agentが自律的にHTTP-01チャレンジを配信・履行していました。DNS-01ではVaultが自分でTXTレコードを書くため、証明書取得スクリプト側にはチャレンジの読み取りも履行の申告も一切不要です。この事実をポリシーにそのまま反映します。

policies.tf
resource "vault_policy" "orchestrator" {
  name = "acme-dns01-orchestrator"

  policy = <<-EOT
    # 新規オーダーの作成
    path "${vault_mount.pki.path}/role/+/new-order" {
      capabilities = ["create", "update"]
    }

    # オーダーのステータス確認(ポーリング)
    path "${vault_mount.pki.path}/role/+/order/+/status" {
      capabilities = ["read"]
    }

    # 証明書の取得
    path "${vault_mount.pki.path}/role/+/order/+/fetch-cert" {
      capabilities = ["read"]
    }

    # 実行中オーダーの一覧(重複オーダーの抑制に使う)
    path "${vault_mount.pki.path}/role/+/active-orders" {
      capabilities = ["list"]
    }
  EOT
}

Vaultの公式ドキュメント(Policies)では、ACLの*はパス末尾でのみワイルドカードとして働き、中間セグメントには+(単一セグメント一致)を使うとされています。そのためrole/+/order/*/statusのような書き方はせず、中間はすべて+で書いています。

実際にVaultに登録されたポリシーの内容(vault policy readの出力)は以下の通りです。

$ vault policy read acme-dns01-orchestrator
# 新規オーダーの作成
path "pki-external-ca/role/+/new-order" {
  capabilities = ["create", "update"]
}

# オーダーのステータス確認(ポーリング)
path "pki-external-ca/role/+/order/+/status" {
  capabilities = ["read"]
}

# 証明書の取得
path "pki-external-ca/role/+/order/+/fetch-cert" {
  capabilities = ["read"]
}

# 実行中オーダーの一覧(重複オーダーの抑制に使う)
path "pki-external-ca/role/+/active-orders" {
  capabilities = ["list"]
}

ポリシーに許可パスは4つだけ書いています。注目すべきは書いていないものです。DNS-01チャレンジではVaultが自動でTXTレコードを書いてLet's Encryptに申告するため、証明書取得スクリプト側がチャレンジを読み取ったり履行を申告したりする必要が一切ありません。そのため以下の2パスをポリシーに含めていません。

省いたパス 意味
role/+/order/+/challenge チャレンジ内容の手動取得
role/+/order/+/fulfilled-challenge 手動でチャレンジ履行を申告する

これにより「証明書取得スクリプトは手動履行をしていない」ことが、運用上の慣行ではなくポリシーで強制されます。実際に同じトークンでこれらのパスを叩くと、403になることを確認しています。

3.3 AppRole設定

証明書取得スクリプトがVaultにログインするための認証方式としてAppRoleを使います。AppRole名・ポリシー名のacme-dns01-orchestratorは、この証明書取得スクリプトを指します。AppRoleはrole_idとsecret_idの2要素でログインするサービスアカウント向けの認証方式で、取得したトークンには3.2節のポリシーが付与されます。

main.tf
resource "vault_auth_backend" "approle" {
  type = "approle"
}

resource "vault_approle_auth_backend_role" "orchestrator" {
  backend        = vault_auth_backend.approle.path
  role_name      = var.approle_role_name
  token_policies = [vault_policy.orchestrator.name]

  token_ttl     = var.approle_token_ttl
  token_max_ttl = var.approle_token_max_ttl

  secret_id_ttl      = 0
  secret_id_num_uses = 0
}

公式ドキュメントの定義では、secret_id_num_uses=1のsecret_idは1回しかログインに使えません。証明書取得スクリプトは実行のたびにAppRoleでログインするため、2回目の実行からログインに失敗することになります。本検証では明示的に0(無制限)にしています。

token_ttlはステータスポーリングの最大所要時間(5秒間隔×最大120回=10分)より長く設定します。本検証ではtoken_ttl=3600(秒)としました。ログイン応答のlease_durationは3600で、ログイン直後にauth/token/lookup-selfで見た残りTTLも3600でした(残り時間なので、照会のタイミングで3599になることもあります)。以下はその応答の抜粋です。

{"policies":["acme-dns01-orchestrator","default"],"display_name":"approle","ttl":3600}

本検証ではsecret_id_ttl=0・secret_id_num_uses=0のためsecret_idは無期限・無制限の資格情報で、TerraformのStateに平文で保存されます。本番環境ではVaultのAWS Auth Method(EC2またはIAM)への切り替えを推奨します。インスタンスプロファイルで認証するためsecret_id自体が不要になります。

3.4 CLI等価コマンド(参考)

以下はTerraform構成と等価なVault CLIコマンドです。CLIコマンド自体は実行しておらず、Terraform構成から書き起こしたものです。

# 監査ログ(注意: 最初に有効化する。後から有効化すると、それ以前の構成操作が記録されない)
vault audit enable file file_path=/var/log/vault/audit.log

# シークレットエンジンの有効化
vault secrets enable -path=pki-external-ca pki-external-ca

# ACMEアカウント(注意: key_type は明示が必要)
vault write pki-external-ca/config/acme-account/letsencrypt-staging \
  directory_url="https://acme-staging-v02.api.letsencrypt.org/directory" \
  email_contacts="you@example.com" \
  key_type="ec-256"

# DNSプロバイダ(注意: region は明示する。doc 上の既定は us-east-1。配列はキーを繰り返し、* は必ずクォートする)
vault write pki-external-ca/config/dns/aws-route53/route53-fitz \
  identifiers=<masked-domain> identifiers='*.<masked-domain>' \
  region=ap-northeast-1 \
  hosted_zone_id=<HOSTED-ZONE-ID> \
  ttl=1m0s

# ロール(注意: allowed_challenge_types の既定は http-01 / dns-01 / tls-alpn-01 の3方式なので、dns-01 を明示する)
vault write pki-external-ca/role/web-servers \
  acme_account_name=letsencrypt-staging \
  allowed_domains=<masked-domain> allowed_domains='*.<masked-domain>' \
  allowed_domain_options=bare_domains allowed_domain_options=subdomains \
  allowed_domain_options=globs allowed_domain_options=wildcards \
  allowed_challenge_types=dns-01

# AppRole
vault auth enable approle
vault write auth/approle/role/acme-dns01-orchestrator \
  token_policies=acme-dns01-orchestrator \
  token_ttl=3600 token_max_ttl=7200 \
  secret_id_ttl=0 secret_id_num_uses=0

4. 動作確認

Vaultの設定ができたところで、実際に動かして確認していきます。証明書取得スクリプトの実装、発行前のゲート、証明書の発行、AWS側での裏取りまでを順に見ていきます(全体構成は2.2節の図の通りです)。

4.1 証明書取得スクリプトの実装

スクリプトは約500行のbashで、全APIコールをcurl+jqで直接行います。nginxホストにvaultバイナリを置かずに済むという副次的な利点もあります。以下では関数ごとに実装のポイントを説明します。

スクリプトは1本で、引数にプロファイル名を渡して、発行する証明書を切り替えます。本検証では次の2つのプロファイルを用意しました。

実行コマンド 読み込む設定 発行する証明書
acme-dns01.sh nginx /etc/acme-dns01/nginx.env(CERT_NAME=nginx、IDENTIFIERS=nginx.<masked-domain>) 単一ドメイン証明書 nginx.<masked-domain>
acme-dns01.sh wildcard /etc/acme-dns01/wildcard.env(CERT_NAME=wildcard、IDENTIFIERS=*.<masked-domain>) ワイルドカード証明書 *.<masked-domain>

プロファイルの設定に書くのは、この2行だけです。Vaultの接続先やポーリングの間隔などの共通の設定は/etc/acme-dns01/common.envにまとめ、スクリプトはこれを先に読み込みます(内容は下記)。以降のログの[nginx]・[wildcard]は、このプロファイル名です。

3つのファイルは、phase4のinstall.shがnginxホストに書き出します。本検証での実際の内容は以下の通りです。

/etc/acme-dns01/nginx.env
CERT_NAME=nginx
IDENTIFIERS=nginx.<masked-domain>
/etc/acme-dns01/wildcard.env
CERT_NAME=wildcard
IDENTIFIERS=*.<masked-domain>
/etc/acme-dns01/common.env
VAULT_ADDR=https://vault-int.<masked-domain>:8200
VAULT_CACERT=/etc/acme-dns01/vault-ca.pem
VAULT_MOUNT=pki-external-ca
VAULT_ROLE=web-servers
APPROLE_PATH=approle
ROLE_ID_FILE=/etc/acme-dns01/role_id
SECRET_ID_FILE=/etc/acme-dns01/secret_id
SSL_DIR=/etc/nginx/ssl
STATE_DIR=/var/lib/acme-dns01
LOG_DIR=/var/log/acme-dns01
RENEW_DAYS=30
POLL_INTERVAL=5
POLL_MAX=120
MAX_FAILURES_PER_HOUR=4
AWAITING_WARN_AFTER=12
AWAITING_GIVEUP_AFTER=120

MAX_FAILURES_PER_HOUR=4は、直近1時間の失敗回数がこの上限に達したらスクリプト自身が発行を止める仕組み(本記事では「自制」と呼びます)の設定です。Let's Encryptの失敗回数の上限に達する前に止めるためのものです。

role_id・secret_idはファイルのパスだけを書き、値そのものは別ファイル(root:acme 0640)に置いています。

4.1.1 全体の流れ — main()

スクリプトの入口です。発行が必要かを判定し、必要な場合は直近の失敗回数が上限に達していないかを確認してから、AppRoleでログインしてオーダーを作成します。

acme-dns01.sh
main() {
  log "=== start (identifiers=$IDENTIFIERS) ==="
  mkdir -p "$STATE_DIR"

  if ! needs_issue; then
    log "=== end (no action) ==="
    exit 0
  fi

  local fails
  fails=$(recent_failures)
  if [ "$fails" -ge "$MAX_FAILURES_PER_HOUR" ]; then
    die "self-throttled: $fails failures in the last hour (limit $MAX_FAILURES_PER_HOUR). Let's Encrypt allows only 5 failed validations per hour per identifier — refusing to burn the budget. Fix the root cause, then: rm $FAILURE_LOG"
  fi

  login
  issue
  log "=== end (issued) ==="
}

needs_issueで発行不要と判定されれば即終了します。必要と判定された場合は、直近1時間の失敗カウントが上限(既定4回)に達していれば自制してレート制限を温存し、login→issueの順で実行します。

4.1.2 発行判定 — needs_issue()

「初回取得」と「更新」を同一の入口にすることで冪等性を保証します。スクリプトを何度実行してもLet's Encryptに無駄なオーダーが飛びません。

acme-dns01.sh
needs_issue() {
  if [ "$FORCE" -eq 1 ]; then
    log "issue needed: --force specified (test case C-7: re-issuance)"
    return 0
  fi
  if [ ! -s "$FULLCHAIN" ] || [ ! -s "$PRIVKEY" ]; then
    log "issue needed: certificate files missing"
    return 0
  fi
  if [ ! -f "$SUCCESS_MARK" ]; then
    log "issue needed: no successful issuance recorded (current cert is the placeholder, or the marker was cleared)"
    return 0
  fi
  if openssl x509 -in "$FULLCHAIN" -noout -issuer 2>/dev/null | grep -q 'Vault-PKI-Placeholder'; then
    log "issue needed: current certificate is still the placeholder (issuer check)"
    return 0
  fi
  if ! openssl x509 -in "$FULLCHAIN" -noout -checkend $((RENEW_DAYS * 86400)) >/dev/null 2>&1; then
    log "issue needed: expires within $RENEW_DAYS days"
    return 0
  fi
  local notafter
  notafter=$(openssl x509 -in "$FULLCHAIN" -noout -enddate 2>/dev/null | cut -d= -f2-)
  log "no action: certificate valid beyond $RENEW_DAYS days (notAfter=$notafter)"
  return 1
}

インフラ構築時に置く自己署名のプレースホルダ証明書は有効期間90日です。期限だけを更新判定に使うと「まだ有効」と誤判定され、本物の証明書を永久に取りに行かない事故になります。期限に加えて成功マーカーの有無とissuerの文字列を見て、プレースホルダを検出しています。

4.1.3 APIヘルパーとログイン認証 — api() / assert_ok() / login()

全APIコールはapi()を経由します。curlに-w '\n%{http_code}'を付けてHTTPステータスコードをレスポンス末尾に追加し、with_status()でJSONに._http_statusフィールドとして埋め込みます。

acme-dns01.sh
api() {
  local method="$1" path="$2" data="${3:-}" out
  local -a args=(-sS --max-time 30 --cacert "$VAULT_CACERT" -X "$method" -w '\n%{http_code}')
  if [ -n "$VAULT_TOKEN" ]; then args+=(-H "X-Vault-Token: $VAULT_TOKEN"); fi
  if [ -n "$data" ]; then
    args+=(-H 'Content-Type: application/json' --data-binary "$data")
  fi
  if ! out=$(curl "${args[@]}" "$VAULT_ADDR/v1/$path" 2>&1); then
    printf '%s' "$out"
    return 1
  fi
  with_status "${out##*$'\n'}" "${out%$'\n'*}"
}

with_status() {
  local code="$1" body="$2"
  if [ -z "$body" ]; then
    jq -nc --argjson c "$code" '{_http_status: $c}'
  elif jq -e 'type == "object"' >/dev/null 2>&1 <<<"$body"; then
    jq -c --argjson c "$code" '. + {_http_status: $c}' <<<"$body"
  else
    jq -nc --argjson c "$code" --arg raw "$(head -c 300 <<<"$body")" '{_http_status: $c, _raw: $raw}'
  fi
}

http_status() { jq -r '._http_status // 0' <<<"$1"; }

error_text() {
  jq -r '((.errors // []) | join("; ")) as $e | if $e != "" then $e else (._raw // "no error message") end' <<<"$1"
}

成否の判定は**HTTPステータスコード(2xx以外は失敗)**で行います。.errorsフィールドはエラーメッセージの取り出しにのみ使います。実測では、Vaultのエラー応答はいずれも4xx(sealedのときは503)と.errorsを伴っていました。HTTP 200かつ.errorsあり(古い実装で懸念していたケース)は観測されませんでしたが、もし発生した場合はWARNログを出して継続します。

acme-dns01.sh
# 成否は HTTP ステータスで判定する(2xx 以外は失敗)。.errors はエラーメッセージとして使う。
assert_ok() {
  local json="$1" ctx="$2" code
  code=$(http_status "$json" 2>/dev/null) || die "$ctx: unexpected response: $(head -c 300 <<<"$json")"
  if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
    die "$ctx: HTTP $code: $(error_text "$json")"
  fi
  if [ "$(jq -r '(.errors // []) | length' <<<"$json")" -gt 0 ]; then
    log "WARN: $ctx: HTTP $code but .errors is present: $(error_text "$json")"
  fi
}

login()はapi()を経由し、assert_ok()でHTTPステータスを確認します。

acme-dns01.sh
login() {
  local role_id secret_id resp ttl
  role_id=$(cat "$ROLE_ID_FILE") || die "cannot read $ROLE_ID_FILE"
  secret_id=$(cat "$SECRET_ID_FILE") || die "cannot read $SECRET_ID_FILE"

  resp=$(api POST "auth/$APPROLE_PATH/login" \
    "$(jq -n --arg r "$role_id" --arg s "$secret_id" '{role_id:$r,secret_id:$s}')") ||
    die "AppRole login: curl failed (Vault sealed or unreachable?)"

  assert_ok "$resp" "AppRole login"
  VAULT_TOKEN=$(jq -r '.auth.client_token // empty' <<<"$resp")
  [ -n "$VAULT_TOKEN" ] || die "AppRole login: no client_token in response"
  ttl=$(jq -r '.auth.lease_duration // "?"' <<<"$resp")
  log "AppRole login OK (token TTL=${ttl}s)" # ★ トークン自体は絶対にログに出さない
}

4.1.4 オーダー作成 — issue()

実行中のオーダーが残っていないかを確認してからnew-orderで発行を要求し、ステータスの確認(4.1.5)と証明書の取得(4.1.6)に進みます。

acme-dns01.sh
issue() {
  local resp order_id active n acode
  IDENT_JSON=$(printf '%s\n' $IDENTIFIERS | jq -R . | jq -sc .)

  # 実行中のオーダーを確認する(タイムアウトしたオーダーが残っている状態での重複オーダー防止)
  if active=$(api GET "$VAULT_MOUNT/role/$VAULT_ROLE/active-orders?list=true"); then
    # ★ 403 を見落とすと .data.keys が null で n=0 になり NOTE も出ずに完全沈黙する。
    #   Vault の LIST は該当なしのとき 404 と空の .errors を返すので、
    #   その組み合わせだけを「実行中のオーダーなし」として扱う。.errors に中身がある 404 は失敗。
    acode=$(http_status "$active" 2>/dev/null || echo 0)
    if [ "$acode" -eq 404 ] && [ "$(jq -r '(.errors // []) | length' <<<"$active" 2>/dev/null || echo 1)" -eq 0 ]; then
      : # 実行中のオーダーなし
    elif [ "$acode" -ne 200 ]; then
      log "NOTE: active-orders list failed: HTTP $acode: $(error_text "$active" 2>/dev/null || echo "unparseable response") (the policy path '$VAULT_MOUNT/role/+/active-orders' may not match a LIST request)"
    fi
    n=$(jq -r '(.data.keys // []) | length' <<<"$active" 2>/dev/null || echo 0)
    if [ "$n" -gt 0 ]; then
      log "NOTE: $n active order(s) already exist for role '$VAULT_ROLE': $(jq -c '.data.keys // []' <<<"$active"). A previous run may have timed out. These count toward Let's Encrypt rate limits."
    fi
  else
    log "NOTE: could not list active-orders (continuing anyway)"
  fi

  log "creating order identifiers=$IDENT_JSON"
  resp=$(api POST "$VAULT_MOUNT/role/$VAULT_ROLE/new-order" \
    "$(jq -n --argjson ids "$IDENT_JSON" '{identifiers:$ids}')") ||
    die "new-order request failed (Vault unreachable or sealed?): $resp"
  assert_ok "$resp" "new-order"

  order_id=$(jq -r '.data.order_id // .data.id // empty' <<<"$resp")
  [ -n "$order_id" ] || die "new-order: could not determine order id from $(jq -c '.data' <<<"$resp")"
  log "order created id=$order_id"

  poll_status "$order_id"
  fetch_and_install "$order_id"
}

new-orderを送った後は、VaultがRoute53にTXTレコードを書いてLet's Encryptに申告するまで何もしません。チャレンジの手動取得・手動履行は一切行いません(3.2節のポリシーでそもそも禁止されています)。

4.1.5 ステータスポーリング — poll_status()

オーダーの状態をPOLL_INTERVAL(5秒)間隔で最大POLL_MAX(120回)確認し、completedになるのを待ちます。一時的なエラーと、終了すべき状態を区別して扱います。

acme-dns01.sh
poll_status() {
  local order_id="$1" i raw st le nw resp done=0 awaiting_count=0 nw_epoch remaining
  local transient=0 max_transient=5 nw_warned=0 resp_err code
  for ((i = 1; i <= POLL_MAX; i++)); do
    if ! resp=$(api GET "$VAULT_MOUNT/role/$VAULT_ROLE/order/$order_id/status"); then
      transient=$((transient + 1))
      log "WARN: status request failed (transient $transient/$max_transient): $resp"
      [ "$transient" -lt "$max_transient" ] ||
        die "status request failed $transient times in a row — giving up (Vault unreachable or sealed?)"
      sleep "$POLL_INTERVAL"
      continue
    fi
    # 5xx と 429 は一時的なエラーとして transient カウンタに載せて継続する。
    # 手動 unseal + 単一ノード Raft の構成では、10分のポーリング中に再起動・sealed が起こりうる。
    code=$(http_status "$resp" 2>/dev/null || echo 0)
    if [ "$code" -ge 500 ] || [ "$code" -eq 429 ]; then
      resp_err=$(error_text "$resp" 2>/dev/null || echo "unparseable response")
      transient=$((transient + 1))
      log "WARN: status returned a retryable error HTTP $code (transient $transient/$max_transient): $resp_err"
      [ "$transient" -lt "$max_transient" ] ||
        die "status kept returning retryable errors $transient times in a row: HTTP $code: $resp_err"
      sleep "$POLL_INTERVAL"
      continue
    fi
    transient=0
    assert_ok "$resp" "order status"

    # フィールド名は2.1のドキュメントがorder_status、2.0の実測がstatus。両方受ける。
    # 値はアンダースコア/ハイフンの表記揺れがあるため、小文字化して'-'に正規化する。
    raw=$(jq -r '.data.order_status // .data.status // "unknown"' <<<"$resp")
    st=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr '_' '-')
    le=$(jq -r '.data.last_error // "" | tostring' <<<"$resp")
    nw=$(jq -r '.data.next_work_date // "" | tostring' <<<"$resp")
    log "order=$order_id poll=$i/$POLL_MAX status=$st last_error=${le:-none} next_work_date=${nw:-none}"

    if [ -n "$nw" ] && [ "$nw" != "null" ] && [ "$st" != "completed" ]; then
      nw_epoch=$(date -d "$nw" +%s 2>/dev/null || true)
      if [ -n "$nw_epoch" ]; then
        remaining=$(((POLL_MAX - i) * POLL_INTERVAL))
        if [ "$nw_epoch" -gt "$(($(date +%s) + remaining))" ] && [ "$nw_warned" -eq 0 ]; then
          nw_warned=1
          log "WARN: Vault's next scheduled background work for this order is $nw, which is beyond our remaining timeout (${remaining}s). If this run times out, raise POLL_MAX/POLL_INTERVAL rather than assuming a failure. (this warning is shown once)"
        fi
      fi
    fi

    case "$st" in
    completed)
      done=1
      break
      ;;
    expired | revoked | error)
      die "order reached terminal failure state '$st' (last_error=${le:-none})" ;;
    awaiting-challenge-fulfillment)
      awaiting_count=$((awaiting_count + 1))
      if [ "$awaiting_count" -eq "$AWAITING_WARN_AFTER" ]; then
        log "WARN: still awaiting-challenge-fulfillment after $((awaiting_count * POLL_INTERVAL))s. Will give up at $((AWAITING_GIVEUP_AFTER * POLL_INTERVAL))s. If this persists, Vault did not select dns-01 auto-fulfillment."
      fi
      if [ "$awaiting_count" -ge "$AWAITING_GIVEUP_AFTER" ]; then
        die_config "stuck in awaiting-challenge-fulfillment for $((awaiting_count * POLL_INTERVAL))s. Vault did NOT select dns-01 auto-fulfillment for this order, so waiting longer will not help."
      fi
      log "note: awaiting-challenge-fulfillment ($awaiting_count/$AWAITING_GIVEUP_AFTER before giving up) — waiting for Vault to pick up dns-01 auto-fulfillment"
      ;;
    new | submitted | vault-challenge-fulfillment | vault-challenge-propogating | vault-challenge-propagating | notify-acme-server-challenges-completed | processing-challenge | fetching-certificate)
      awaiting_count=0  # awaiting 以外に遷移したらリセット(往復時の誤カウント防止)
      ;;
    *) log "WARN: unrecognized status '$st' (raw='$raw') — continuing" ;;
    esac
    sleep "$POLL_INTERVAL"
  done
  [ "$done" -eq 1 ] || die "timed out after $((POLL_MAX * POLL_INTERVAL))s waiting for order $order_id (last status=$st)"
}

ステータス判定に関する3つの設計判断:

5xx/429は一時エラーとして継続する: 手動 unseal + 単一ノード Raftの構成では、10分のポーリング中にVaultの再起動や一時的なsealed状態が現実的に起こりえます。これらを即dieにすると、vault-challenge-propogatingの途中でRoute53のTXTが作られたままオーダーが孤児化します。max_transient(既定5)回連続で失敗した場合のみ打ち切ります。

網羅列挙しない: 公式ドキュメントは全12値を定義していますが、実測ではvault-challenge-propogatingが一度も現れず、notify-acme-server-challenges-completedも今回のオーダーには現れませんでした(4.3.2節の表)。未知の値で死ぬ実装は、ドキュメントにない遷移が発生した時点で壊れます。

next_work_dateを打ち切り条件にしない: 実測では値の大半がポーリング時刻以前だったため(4.3.2節)、ハード終了はPOLL_MAXのタイムアウトに任せています。

4.1.6 証明書取得と配置 — fetch_and_install()

fetch-certで証明書・CAチェーン・秘密鍵を取得し、nginxが読むファイルに差し替えます。秘密鍵はnginxのworkerから読めない権限で置きます。

acme-dns01.sh
fetch_and_install() {
  local order_id="$1" resp cert key chain tmp_fc tmp_pk notafter
  resp=$(api GET "$VAULT_MOUNT/role/$VAULT_ROLE/order/$order_id/fetch-cert") ||
    die "fetch-cert request failed: $resp"
  assert_ok "$resp" "fetch-cert"

  cert=$(jq -r '.data.certificate // empty' <<<"$resp")
  key=$(jq -r '.data.private_key // empty' <<<"$resp")
  chain=$(jq -r '(.data.ca_chain // []) | map(tostring) | join("\n")' <<<"$resp")
  [ -n "$cert" ] || die "fetch-cert: no certificate in response"
  [ -n "$key" ] || die "fetch-cert: no private_key in response (identifiers workflow expected, not CSR)"

  tmp_fc=$(mktemp "$SSL_DIR/.$CERT_NAME-fullchain.XXXXXX")
  tmp_pk=$(mktemp "$SSL_DIR/.$CERT_NAME-privkey.XXXXXX")
  TMP_FILES+=("$tmp_fc" "$tmp_pk")

  printf '%s\n' "$cert" >"$tmp_fc"
  [ -n "$chain" ] && printf '%s\n' "$chain" >>"$tmp_fc"
  printf '%s\n' "$key" >"$tmp_pk"

  openssl x509 -in "$tmp_fc" -noout -subject >/dev/null 2>&1 || die "fetched certificate is not valid PEM"
  chmod 0644 "$tmp_fc"
  chmod 0600 "$tmp_pk"  # nginx worker から読めないようにする

  [ -f "$FULLCHAIN" ] && cp -p "$FULLCHAIN" "$FULLCHAIN.bak"
  [ -f "$PRIVKEY" ]   && cp -p "$PRIVKEY"   "$PRIVKEY.bak"
  mv -f "$tmp_fc" "$FULLCHAIN"
  mv -f "$tmp_pk" "$PRIVKEY"

  notafter=$(openssl x509 -in "$FULLCHAIN" -noout -enddate | cut -d= -f2-)
  log "installed certificate: $(openssl x509 -in "$FULLCHAIN" -noout -subject | sed 's/^subject=//') notAfter=$notafter"
  log "issuer: $(openssl x509 -in "$FULLCHAIN" -noout -issuer | sed 's/^issuer=//')"

  reload_nginx

  mkdir -p "$STATE_DIR"
  ts >"$SUCCESS_MARK"
  : >"$FAILURE_LOG"  # 成功したら失敗カウンタをリセット
  log "SUCCESS"
}

fullchain.pemはcertificateとca_chain[]を連結したものです。tmpファイルに書いてmvで原子的に置換するため、nginxがリロードの瞬間に中途半端なファイルを読む状況は起きません。

4.1.7 nginxの再読み込み — reload_nginx()

nginx -tで設定を検査してからreloadします。検査に失敗した場合は、差し替える前のファイルに戻します。reloadに失敗した場合は、成功扱いにせずdieで止めます。

acme-dns01.sh
reload_nginx() {
  if sudo /usr/sbin/nginx -t 2>&1 | sed "s/^/$(ts) [$CERT_NAME] nginx -t: /"; then
    sudo /usr/bin/systemctl reload nginx ||
      die "nginx reload FAILED after installing the new certificate. The new cert files are in place but nginx is still serving the old one. Investigate: journalctl -u nginx"
    log "nginx reloaded"
    rm -f "$FULLCHAIN.bak" "$PRIVKEY.bak"
    return 0
  fi
  log "ERROR: nginx -t failed with the new certificate — rolling back"
  [ -f "$FULLCHAIN.bak" ] && mv -f "$FULLCHAIN.bak" "$FULLCHAIN"
  [ -f "$PRIVKEY.bak"   ] && mv -f "$PRIVKEY.bak"   "$PRIVKEY"
  sudo /usr/sbin/nginx -t 2>&1 | sed "s/^/$(ts) [$CERT_NAME] nginx -t (after rollback): /" ||
    log "ERROR: nginx -t still failing after rollback — manual intervention required"
  die "nginx configuration test failed with the new certificate (rolled back)"
}

4.1.8 権限分離

スクリプトは専用ユーザーacmeで動かし、root権限が必要な操作はsudoで2つだけ許可しています。以下はacmeユーザーに許可したsudoの内容と、許可していないrestartを試した結果です。

acme ALL=(root) NOPASSWD: /usr/sbin/nginx -t
acme ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx
--- restart MUST be denied ---
sudo: a password is required

systemctl restart nginxは実質的に大きな権限になるため与えていません。nginx -tを通してからreloadするだけで用は足ります。

ファイル権限は以下の通りです。

パス 所有 / 権限 意味
/etc/nginx/ssl/*-privkey.pem 0600 acme:acme nginx worker(nginxユーザー)から読めない。読むのはmaster(root)だけ
/etc/nginx/ssl/*-fullchain.pem 0644 acme:acme 公開情報
/etc/acme-dns01/ 0750 root:acme acmeは読めるが書けない(侵害されてもsecret_idを差し替えられない)
/etc/acme-dns01/role_id / secret_id 0640 root:acme nginx workerから読めない

4.1.9 nginxの設定

nginxは以下のように設定しました。443番だけで待ち受け、80番は設定していません(ドメインはマスク済み)。ワイルドカード側をdefault_serverにしているので、SNI(TLS接続時にクライアントが送るホスト名)を送らないアクセスはワイルドカード証明書で受けます。なお、Amazon Linux 2023のnginxパッケージはnginx.conf本体に80番のserverブロックを持っているため、nginx.confも80番を含まない内容に書き直しています。

/etc/nginx/conf.d/acme-dns01.conf
# --- ワイルドカード証明書(SNI無しのアクセスもここに落ちる) ---
server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name *.<masked-domain>;

    ssl_certificate     /etc/nginx/ssl/wildcard-fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/wildcard-privkey.pem;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1h;

    location = /healthz {
        access_log off;
        default_type text/plain;
        return 200 "wildcard-ok\n";
    }

    location / {
        default_type text/plain;
        return 200 "served by wildcard cert (*.$host)\n";
    }
}

# --- 単一ドメイン(nginx)の証明書 ---
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name nginx.<masked-domain>;

    ssl_certificate     /etc/nginx/ssl/nginx-fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/nginx-privkey.pem;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1h;

    location = /healthz {
        access_log off;
        default_type text/plain;
        return 200 "nginx-fqdn-ok\n";
    }

    location / {
        default_type text/plain;
        return 200 "served by specific cert (nginx.<masked-domain>)\n";
    }
}

4.2 DNSプロバイダの疎通テスト

証明書発行に進む前に、認証情報・TXTの作成削除権限・identifierのカバー範囲を確認できるゲートがあります。dns/test/workflowエンドポイントで、Let's Encryptに一度もアクセスせずに検証できます。 ここが通らなければ発行に進む意味がないので、最初に確認する価値があります。このパスは証明書取得スクリプトのポリシーに含めていないため、rootトークンで実行しています。

$ VAULT_CLIENT_TIMEOUT=180s vault write pki-external-ca/dns/test/workflow \
    provider_type=aws-route53 provider_name=route53-fitz identifier=nginx.<masked-domain>
Key              Value
---              -----
identifier       nginx.<masked-domain>
message          DNS provider test successful: TXT record was created and cleaned up successfully
provider_name    route53-fitz
provider_type    aws-route53
record_name      _acme-challenge.nginx.<masked-domain>.
success          true

このエンドポイントは既定のVAULT_CLIENT_TIMEOUT(60秒)では応答が返る前にタイムアウトすることがありました。VAULT_CLIENT_TIMEOUT=180sを付けて呼んでいます(この現象自体の実出力は保存していないため参考情報として記載します)。

4.3 証明書の発行結果

スクリプトで、単一ドメイン(nginx.<masked-domain>)とワイルドカード(*.<masked-domain>)の証明書を発行した結果です。発行ログ、ステータスの遷移、TXTレコードの出現と削除、再取得の挙動を順に示し、最後に同じ操作をCLIで行った例を載せます。

4.3.1 発行ログの実際

nginx.<masked-domain>への初回発行は97秒、20回のポーリングで完了しました(以下、ポーリング行のlast_error/next_work_dateの項目は省略)。

2026-09-24T09:40:52Z [nginx] === start (identifiers=nginx.<masked-domain>) ===
2026-09-24T09:40:52Z [nginx] issue needed: no successful issuance recorded (current cert is the placeholder, or the marker was cleared)
2026-09-24T09:40:52Z [nginx] AppRole login OK (token TTL=3600s)
2026-09-24T09:40:52Z [nginx] creating order identifiers=["nginx.<masked-domain>"]
2026-09-24T09:40:52Z [nginx] order created id=<ORDER-nginx-1>
2026-09-24T09:40:52Z [nginx] order=<ORDER-nginx-1> poll=1/120 status=new
2026-09-24T09:40:57Z [nginx] order=<ORDER-nginx-1> poll=2/120 status=vault-challenge-fulfillment
2026-09-24T09:41:02Z [nginx] order=<ORDER-nginx-1> poll=3/120 status=vault-challenge-fulfillment
...(中略。以下poll=20/120 status=completedまで続く)

ワイルドカード証明書(*.<masked-domain>)の初回発行は76秒、16回のポーリングで完了しました。

2026-09-24T09:43:35Z [wildcard] === start (identifiers=*.<masked-domain>) ===
2026-09-24T09:43:35Z [wildcard] issue needed: no successful issuance recorded (current cert is the placeholder, or the marker was cleared)
2026-09-24T09:43:35Z [wildcard] AppRole login OK (token TTL=3600s)
2026-09-24T09:43:35Z [wildcard] creating order identifiers=["*.<masked-domain>"]
2026-09-24T09:43:35Z [wildcard] order created id=<ORDER-wildcard-1>
2026-09-24T09:43:35Z [wildcard] order=<ORDER-wildcard-1> poll=1/120 status=new
2026-09-24T09:43:40Z [wildcard] order=<ORDER-wildcard-1> poll=2/120 status=vault-challenge-fulfillment
2026-09-24T09:43:45Z [wildcard] order=<ORDER-wildcard-1> poll=3/120 status=vault-challenge-fulfillment
...(中略。以下poll=16/120 status=completedまで続く)

4.3.2 ステータス遷移(5秒間隔ポーリングで観測)

公式ドキュメントは全12個のステータス値を定義しており、今回の5秒間隔のポーリングで計測できたステータスは以下です。

順 単一ドメイン(nginx)初回(計20回/97秒) ワイルドカード(*)初回(計16回/76秒)
1 new new
2 vault-challenge-fulfillment ×5 vault-challenge-fulfillment ×6
3 processing-challenge ×12 processing-challenge ×7
4 fetching-certificate ×1 fetching-certificate ×1
5 completed completed

next_work_dateも公式サンプルとは違う値が返ってきました。公式APIドキュメントのサンプルはcreation_dateの1時間後を示す値ですが、実測した48値(6ラン分: 単一ドメイン初回、ワイルドカード初回、--force再取得4回。うち1回は4.3.4節の表に含めていない、reload失敗を確かめたときの再取得)は次の3パターンに分かれました。

  • ポーリング時刻より未来: 2件のみ(+5秒・+4秒)
  • ポーリング時刻以前(同時刻を含む): 40件
  • 完了時のゼロ値0001-01-01T00:00:00Z: 6件

単一ドメイン初回の例です(poll=1・2・20の行だけを抜粋し、[nginx] order=...の部分は省略)。

2026-09-24T09:40:52Z poll=1/120 status=new next_work_date=2026-09-24T09:40:52Z
2026-09-24T09:40:57Z poll=2/120 status=vault-challenge-fulfillment next_work_date=2026-09-24T09:40:53Z
2026-09-24T09:42:28Z poll=20/120 status=completed next_work_date=0001-01-01T00:00:00Z

仮に「next_work_dateを過ぎても状態が進んでいなければ打ち切る」という判定を書くと、実測ではほとんどの値がポーリング時刻以前なので、健全なオーダーを初回ポーリングで打ち切ってしまいます。逆に公式サンプルのように1時間後の値を「待ち時間」として使えば、ポーリングが不必要に長引きます。調べた範囲では公式ドキュメントにこのフィールドの用途の記載が見当たらないため、打ち切り条件には使わないという判断をしています。

4.3.3 TXTレコードの出現と削除

証明書発行中、TXTレコードの有無をポーリングしました(実測の間隔は約8秒)。

09:40:54Z TXT_ABSENT
09:41:02Z TXT_PRESENT n=1 ["_acme-challenge.nginx.<masked-domain>."]
09:41:10Z TXT_PRESENT n=1 ["_acme-challenge.nginx.<masked-domain>."]
09:41:18Z TXT_PRESENT n=1 ["_acme-challenge.nginx.<masked-domain>."]
09:41:26Z TXT_PRESENT n=1 ["_acme-challenge.nginx.<masked-domain>."]
09:41:34Z TXT_PRESENT n=1 ["_acme-challenge.nginx.<masked-domain>."]
09:41:43Z TXT_PRESENT n=1 ["_acme-challenge.nginx.<masked-domain>."]
09:41:51Z TXT_PRESENT n=1 ["_acme-challenge.nginx.<masked-domain>."]
09:41:59Z TXT_ABSENT
09:42:07Z TXT_ABSENT
...(以降、完了までTXT_ABSENTが続く)

発行完了後にRoute53側を確認すると、TXTレコードは削除されていました。

$ aws route53 list-resource-record-sets --hosted-zone-id <HOSTED-ZONE-ID> \
    --query "ResourceRecordSets[?Type=='TXT']"
[]

TXTはオーダーのcompleted(09:42:28)を待たず、オーダー完了の約30秒前(09:41:55)に削除されています。この削除タイミングについて、調べた範囲では公式ドキュメントに記載は見当たりませんでした。

4.3.4 authorizationの再利用

スクリプトに--forceを付けると、有効な証明書があっても再取得します(以下「--force再取得」)。初回発行の直後に--force再取得すると、10〜11秒で完了しました。初回の76〜97秒に対して大幅に短く、この間Route53は一度も呼ばれていません(証拠は4.4.3節のCloudTrailログ)。

単一ドメイン(nginx.<masked-domain>)

取得 所要時間 / ポーリング ステータス遷移 Route53呼び出し
初回 97秒 / 20回 new → vault-challenge-fulfillment → processing-challenge → fetching-certificate → completed 29件(TXTのUPSERT 2・DELETE 1を含む)
再取得 1回目(初回から13分後) 11秒 / 3回 new → fetching-certificate → completed 0件
再取得 2回目(1回目の直後) 11秒 / 3回 同上 0件

ワイルドカード(*.<masked-domain>)

取得 所要時間 / ポーリング ステータス遷移 Route53呼び出し
初回 76秒 / 16回 new → vault-challenge-fulfillment → processing-challenge → fetching-certificate → completed 21件(TXTのUPSERT 1・DELETE 1を含む)
再取得(初回から11分後) 10秒 / 3回 new → fetching-certificate → completed 0件

再取得が速い理由は、ACMEサーバ(Let's Encrypt)側でauthorizationが再利用されるためです。チャレンジに合格したidentifierのauthorizationは一定期間validのまま残り、同じACMEアカウントからの次のオーダーでは、RFC 8555 §7.1.3で認められているとおり、そのauthorizationが再利用されてDNS-01チャレンジ自体がスキップされます。そのためVaultはTXTレコードを作らず、そのまま証明書取得に進みます。再利用される期間はACMEサーバの方針で決まり、本検証では初回から10数分後まで再利用されることを確認しました。

以下は単一ドメイン(nginx)の再取得1回目(11秒・3ポーリング)のログです(last_error/next_work_dateの項目と、末尾のissuer・nginxのreload・SUCCESSの行は省略)。vault-challenge-fulfillmentを通らず、newから直接fetching-certificateに進んでいます。

2026-09-24T09:55:04Z [nginx] === start (identifiers=nginx.<masked-domain>) ===
2026-09-24T09:55:04Z [nginx] issue needed: --force specified (test case C-7: re-issuance)
2026-09-24T09:55:04Z [nginx] AppRole login OK (token TTL=3600s)
2026-09-24T09:55:04Z [nginx] creating order identifiers=["nginx.<masked-domain>"]
2026-09-24T09:55:04Z [nginx] order created id=<ORDER-nginx-2>
2026-09-24T09:55:04Z [nginx] order=<ORDER-nginx-2> poll=1/120 status=new
2026-09-24T09:55:09Z [nginx] order=<ORDER-nginx-2> poll=2/120 status=fetching-certificate
2026-09-24T09:55:14Z [nginx] order=<ORDER-nginx-2> poll=3/120 status=completed
2026-09-24T09:55:14Z [nginx] installed certificate: CN=nginx.<masked-domain> notAfter=Dec 23 08:56:36 2026 GMT

--force再取得の前後で、証明書のserialが変わることも確認しました(値は伏せています)。

一方、冪等性テスト(--forceなしで再実行)はno actionで即時終了し、オーダーを作りません。

2026-09-24T09:55:41Z [nginx] === start (identifiers=nginx.<masked-domain>) ===
2026-09-24T09:55:41Z [nginx] no action: certificate valid beyond 30 days (notAfter=Dec 23 08:56:48 2026 GMT)
2026-09-24T09:55:41Z [nginx] === end (no action) ===

「初回取得」と「更新」を同一の入口(同じスクリプト・同じneeds_issue判定)にしているため、スクリプトを繰り返し実行してもACMEサーバに無駄なオーダーが飛びません。

4.3.5 CLIでの発行(参考)

スクリプトと同じ操作をvault CLIで行うと、以下のようになります。まだ一度も発行していないcli.<masked-domain>を対象に、スクリプトと同じAppRole(同じポリシー)のトークンで実際に実行した結果です(トークンとオーダーIDはマスク済み)。

AppRoleでログインしてトークンを取得します。付与されるポリシーはスクリプトと同じです。

$ export VAULT_TOKEN=$(vault write -field=token auth/approle/login role_id=<ROLE_ID> secret_id=<SECRET_ID>)
$ vault token lookup -format=json | jq -c "{policies:.data.policies,ttl:.data.ttl}"
{"policies":["acme-dns01-orchestrator","default"],"ttl":3599}

発行を要求します。返ってくるのはオーダーIDだけです。

$ vault write pki-external-ca/role/web-servers/new-order identifiers=cli.<masked-domain>
Key         Value
---         -----
order_id    <ORDER-cli-1>

ステータスを確認します。challengesには、Let's Encryptが提示した4種類のチャレンジが並びます。この一覧はroleのallowed_challenge_typesで絞り込まれたものではなく、提示されたものがそのまま並びます。requires_manual_fulfillment:falseになっているのはdns-01だけで、Vaultがこれを自動で履行します。

$ vault read pki-external-ca/role/web-servers/order/<ORDER-cli-1>/status
Key               Value
---               -----
challenges        map[cli.<masked-domain>:[map[challenge_status:pending challenge_type:dns-01 expires:2026-10-01T13:59:32Z requires_manual_fulfillment:false] map[challenge_status:pending challenge_type:dns-persist-01 expires:2026-10-01T13:59:32Z requires_manual_fulfillment:true] map[challenge_status:pending challenge_type:http-01 expires:2026-10-01T13:59:32Z requires_manual_fulfillment:true] map[challenge_status:pending challenge_type:tls-alpn-01 expires:2026-10-01T13:59:32Z requires_manual_fulfillment:true]]]
creation_date     2026-09-24T13:59:30Z
csr               n/a
expires           2026-10-01T13:59:32Z
identifiers       [cli.<masked-domain>]
last_error        n/a
last_update       2026-09-24T13:59:32Z
next_work_date    2026-09-24T13:59:32Z
order_status      vault-challenge-fulfillment
role_name         web-servers
serial_number     n/a

order_statusがcompletedになるまで、5秒間隔で同じコマンドを繰り返します(order_statusとnext_work_dateだけを抜粋)。

13:59:58Z poll=1 vault-challenge-fulfillment next_work_date=2026-09-24T13:59:32Z
14:00:04Z poll=2 processing-challenge next_work_date=2026-09-24T14:00:05Z
...(poll=8 まで processing-challenge)
14:00:40Z poll=9 fetching-certificate next_work_date=2026-09-24T14:00:37Z
14:00:45Z poll=10 completed next_work_date=0001-01-01T00:00:00Z

completedになったら、証明書を取得します。レスポンスには秘密鍵(private_key)も含まれるので、画面に出さずにファイルへ保存します。

$ vault read -format=json pki-external-ca/role/web-servers/order/<ORDER-cli-1>/fetch-cert > fetch-cert.json
$ jq -r ".data | keys[]" fetch-cert.json
authority_key_id
ca_chain
certificate
certificate_format
not_after
not_before
private_key
private_key_type
serial_number
$ jq -r ".data.certificate" fetch-cert.json > cert.pem
$ openssl x509 -in cert.pem -noout -subject -issuer -dates -ext subjectAltName
subject=CN = cli.<masked-domain>
issuer=C = US, O = Let's Encrypt, CN = (STAGING) Baloney Bulgur YE2
notBefore=Sep 24 13:02:08 2026 GMT
notAfter=Dec 23 13:02:07 2026 GMT
X509v3 Subject Alternative Name:
    DNS:cli.<masked-domain>

new-orderからcompletedまではおよそ75秒でした。証明書取得スクリプトは、このうちnew-order・status・fetch-certをcurlで呼び、fullchainの作成・配置とnginxのreloadまでを自動化したものです。

4.4 CloudTrailによるDNS操作の裏取り

Vault監査ログはrequest.data/response.dataの値をすべてHMAC化するため、「Vaultがどのドメインに対してどのDNS操作をしたか」は監査ログ単体からは分かりません。そこで、VaultがRoute53をいつ・どう操作したかは、AWS側の記録であるCloudTrailで確認します。

4.4.1 取得と絞り込み

Route53へのAPI呼び出しをCloudTrailから取得します。検証を行った時間帯を指定し、イベントソースをRoute53に絞っています。

aws cloudtrail lookup-events --region us-east-1 \
  --lookup-attributes AttributeKey=EventSource,AttributeValue=route53.amazonaws.com \
  --start-time 2026-09-24T09:20:00Z --end-time 2026-09-24T10:15:00Z \
  --output json > cloudtrail-route53-raw.json

Route53はグローバルサービスなので--region us-east-1固定です。--max-resultsは付けずページングを完走させます。

同じAWSアカウントの、他システムや他ユーザーのイベントも混ざります。今回は取得した164件のうち90件がそうしたイベントで、Vaultのロール名で絞り込んで74件を抽出しました。

4.4.2 userAgentでVault本体と手動操作を区別する

Vaultのロールによる呼び出しを、APIとuserAgentの組み合わせで集計した結果です。

件数 API userAgent 備考
54 GetChange aws-sdk-go-v2/1.41.6 go#1.26.7-X-boringcrypto Vault本体
11 ListResourceRecordSets aws-sdk-go-v2/1.41.6 go#1.26.7-X-boringcrypto Vault本体
8 ChangeResourceRecordSets aws-sdk-go-v2/1.41.6 go#1.26.7-X-boringcrypto Vault本体
1 ListHostedZones aws-cli 手動の検証コマンド

VaultはSDKのuserAgentにX-boringcryptoという文字列を残します。ここから、VaultがBoringCryptoを有効にしたGoでビルドされていることがAWS側からも読み取れます(版数のfips1403と整合します。FIPS適合そのものを示すものではありません)。ListHostedZonesの1件はuserAgentがaws-cliで、同じロールを使って証跡収集のために打った検証コマンドであり、Vaultの呼び出しではありません(74件=54+11+8+1)。Vaultが呼ぶAPIはGetChange・ListResourceRecordSets・ChangeResourceRecordSetsの3種類だけでした。

4.4.3 TXTレコードの操作ログとauthorization再利用の確認

ChangeResourceRecordSetsの中身から、TXTレコードの作成(UPSERT)と削除(DELETE)を取り出した結果です。

2026-09-24T09:40:55Z  UPSERT  TXT  TTL=60  _acme-challenge.nginx.<masked-domain>.
2026-09-24T09:41:29Z  UPSERT  TXT  TTL=60  _acme-challenge.nginx.<masked-domain>.
2026-09-24T09:41:55Z  DELETE  TXT  TTL=60  _acme-challenge.nginx.<masked-domain>.
2026-09-24T09:43:38Z  UPSERT  TXT  TTL=60  _acme-challenge.<masked-domain>.
2026-09-24T09:44:16Z  DELETE  TXT  TTL=60  _acme-challenge.<masked-domain>.

単一ドメイン(nginx)の09:40:55 UPSERT→09:41:55 DELETEは、4.3.1節で見た初回発行(09:40:52開始/09:42:28完了)と時間的に一致します。TTLはすべて60で、DNSプロバイダ設定のttl = 60(Vault上は1m0s)と一致します。単一ドメインでは同じTXTへのUPSERTが2回ありましたが、DELETEは最後に1回出ており、Vaultが後片付けまで行っていることが分かります。

09:43:38 UPSERT ... _acme-challenge.<masked-domain>.はワイルドカード証明書のTXTです。ワイルドカード(*.<masked-domain>)のチャレンジTXTは、ワイルドカード名自体ではなくベースドメイン(<masked-domain>)に対して書かれています。RFC 8555(§7.1.4・§8.4)通りの挙動で、AWS側の記録でも裏取りできました。

各操作の所要時間とCloudTrailのイベント突合結果をまとめると、初回発行と--force再取得の差がはっきり出ます。

対象証明書 操作種別 所要時間 Route53呼び出し
単一ドメイン(nginx) 初回発行 97秒 29件(Change 3 / GetChange 21 / ListRRS 5)
ワイルドカード(*) 初回発行 76秒 21件(Change 2 / GetChange 16 / ListRRS 3)
単一ドメイン(nginx) --force再取得(2回) 各11秒 0件
ワイルドカード(*) --force再取得(1回) 10秒 0件

※ Change=ChangeResourceRecordSets、ListRRS=ListResourceRecordSets。冪等性テスト(no action)はオーダーを作らないため含めていません。

4.4.4 Vaultが実際に呼んだRoute53 API

調べた範囲では、公式ドキュメントにVaultがRoute53に対して必要とするIAM権限の記載は見当たりませんでした。CloudTrailで観測された、Vaultが実際に呼んだAPIは以下の3つです。

Action Resource(絞り込むなら) 実測回数
route53:GetChange *(change IDは事前に不定) 54
route53:ListResourceRecordSets arn:aws:route53:::hostedzone/<HOSTED-ZONE-ID> 11
route53:ChangeResourceRecordSets arn:aws:route53:::hostedzone/<HOSTED-ZONE-ID> 8

ListHostedZones・ListHostedZonesByName・GetHostedZoneといったゾーン列挙系のアクションはVaultから一度も呼ばれていません。これはhosted_zone_idを明示指定しているためで、未指定時にゾーン列挙が必要になるかどうかは今回未検証です。

これは「呼ばれたAPI」の観測値であり、最小権限を確定したものではありません。検証時のIAMロールには、ホストゾーン単位のroute53:*と、GetChange・ListHostedZones・ListHostedZonesByName(Resource *)を付与していました。上表の3つだけに絞った状態での動作は未検証です。権限不足のエラーが出なかったのも、広めに付与していたためです。

5. 公式ドキュメントに無い、または実測で確かめた値・落とし穴

ここまでの章で扱わなかった、公式ドキュメントに無い、または実測で確かめた値をまとめます。

5.1 鍵の種類は2つあり、有効値の集合も違う

ACMEアカウント鍵(config/acme-accountのkey_type)と、証明書の鍵(roleのcsr_generate_key_type)は別のフィールドで、有効値の集合が違います。有効値は公式ドキュメントに列挙されているとおりで、実測でも同じでした。

検証には、到達不能なdirectory_url(https://127.0.0.1:1)を指定する手法を使いました。ACMEサーバに実際に接続する前のkey_type検証だけが走るので、Let's Encryptを一度も消費せずに有効値を総当たりできます。

鍵の種類 有効値 エラー型
ACMEアカウント鍵key_type ec-256 / ec-384 / ec-521 / rsa-2048 / rsa-4096 / rsa-8192(6種) AcmeAccountKeyType
証明書の鍵csr_generate_key_type ec-256 / ec-384 / ec-521 / rsa-2048 / rsa-4096(5種) CsrKeyType

rsa-8192はアカウント鍵では使えますが証明書の鍵では使えません。rsa-3072・EC256・ed25519はどちらも不可です(P-256と空文字は、アカウント鍵で不可を確認)。

5.2 LE stagingでは複数のissuerが観測される

Let's Encrypt stagingは複数の中間CA(今回の観測ではArtificial Amaranth YE1とBaloney Bulgur YE2、いずれもルートYearning Yucca Root YEにチェーン)から非決定的に発行します。スクリプトでの6回の発行でYE1が1件、YE2が5件でした(4.3.5節のCLIでの発行もYE2)。運用スクリプトやドキュメントで特定のissuer名をハードコードした判定を書かないよう注意が必要です。

6. まとめ

Vault 2.1のDNS-01チャレンジ対応により、Vault 2.0/HTTP-01方式が抱えていた「80番ポートの外部開放が必須」「ワイルドカード証明書が取得不可能」という2つの制約が解消されました。一方で、検証時点のVault AgentはDNS-01に対応していないため、証明書の取得・配布・nginxへの反映を担う部分は自作する必要があります。

本記事で確認できたことは以下の通りです。

  1. DNS-01チャレンジで証明書を発行できた:VaultがRoute53にTXTレコードを作成・削除し、80番ポートを一切開けずに証明書を発行できました(4.1.9・4.3節)。
  2. ワイルドカード証明書を取得できた:HTTP-01では取得できなかったワイルドカード証明書(*.<masked-domain>)を発行できました(4.3節)。
  3. Vault Agentが非対応でも、3つのAPIで発行できる:new-order・status・fetch-certを呼ぶだけで発行でき、スクリプト(curl + jq)でもCLIでも同じ手順で動きました(4.1・4.3.5節)。
  4. Terraform provider 5.12.0の専用リソースで設定できる:DNSプロバイダの設定とroleの紐付け(dns_provider_name/dns_provider_type)まで設定でき、その構成で発行できました(3.1節)。
  5. VaultのDNS操作をCloudTrailで裏取りできる:Vaultが、いつ・どのIAMロールで・どのAPI(3種類)を呼んだかまで確認できます(4.4節)。

7. 参考資料

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?