Caddy + Route53 + Let's Encryptでワイルドカード証明書が有効なWebサーバを立てる

## はじめに

[Caddy](https://caddyserver.com/)で、特定のドメイン(`www.example.com`)についてのDV証明書をLet's Encryptから取得するのはほぼ設定なしで簡単にできますが、ワイルドカードなドメイン(`*.example.com`)の証明書の取得はやったことがなかったので、今回チャレンジしてみました。

## 前提

- Route53で管理されているゾーン(`example.com`)のドメインのワイルドカード証明書(`*.example.com`)を取得することとします。
- TerraformにてAWS上でのプロビジョニングを記述するものとします。
- Caddyのバージョンは `2.75` を用いています。

## ワイルドカード証明書取得のチャレンジ要件

- ワイルドカード証明書の取得にあたっては、ドメイン所有の証明方法として[HTTP-01チャレンジではなく**DNS-01チャレンジを用いる必要があります。**](https://letsencrypt.org/ja/docs/challenge-types/)
- DNS-01チャレンジはHTTP-01チャレンジと違い、**WebサーバがHTTP越しに外部からアクセス可能でなくても構わない**ですが、一方で **チャレンジ中に要求されたDNSレコードを作成できる必要があります。**
    - DNSレコードの作成については、自動か人力かは問われません。
    - 今回の場合であれば、**CaddyにRoute53の操作をしてもらう必要があります。**

https://letsencrypt.org/ja/docs/challenge-types/

```mermaid
sequenceDiagram
  alt HTTP-01認証の場合
    Caddy->>Let's Encrypt: www.example.comをHTTP-01で認証してください
    Let's Encrypt->>Caddy: http://www.example.com/.well-known/acme-challenge/ に指定した内容を置いてね
    Caddy->>Let's Encrypt: 準備OK
    Let's Encrypt->>Caddy: チェックします
    Let's Encrypt->>Caddy: OKなので www.example.com の証明書発行します
  end

  alt DNS-01認証の場合
    Caddy->>Let's Encrypt: *.example.comをDNS-01で認証してください
    Let's Encrypt->>Caddy: _acme-challenge.example.com に指定したDNSレコードを置いてね
    Caddy->>Route53など: _acme-challenge.example.com にこの内容のレコードを作成してください
    Caddy->>Let's Encrypt: 準備OK
    Let's Encrypt->>Route53など: チェックします
    Let's Encrypt->>Caddy: OKなので *.example.com の証明書発行します
  end
```


## Route53 (AWS) 側の準備 〜 IAMユーザとそのアクセスキーの作成

下記でDNS-01認証の際にだけ使うIAMユーザを作成し、そのアクセスキーを作成しておきます。

（あとでCaddyの設定ファイルに作成したアクセスキーを記述します。）

```terraform:acme_user.tf
data "aws_route53_zone" "my_zone" {
  name = "example.com."
}

resource "aws_iam_user" "acme_client_user" {
  name = "acme_client_user"
}

resource "aws_iam_user_policy" "acme_client_user" {
  name = "acme_client_user"
  user = aws_iam_user.acme_client_user.name

  policy = jsonencode({
    Version : "2012-10-17",
    Statement : [
      {
        Action : [
          "route53:Get*",
          "route53:List*",
          "route53:ChangeResourceRecordSets"
        ],
        Effect : "Allow",
        Resource : [data.aws_route53_zone.my_zone.arn]
      },
      {
        Action : ["route53:ListHostedZonesByName"],
        Effect : "Allow",
        Resource : "*"
      }
    ]
  })
}

# アクセスキーはマネージメントコンソールで発行してください
```

## Caddyの準備 〜 Route53プラグインの組み込み

パッケージ版のCaddyを入れている人には大変悲しいお知らせですが、 **素のCaddyはそのままだとRoute53とやり取りすることができません。** （別のDNSプロバイダであっても同じです）

なので、**Route53とやり取りするためのプラグインを組み込んだCaddyをビルドする必要があります。**

カスタムビルドのためにGoのコンパイラなどを設定してビルド等するのも非常に面倒なので、**いっそのことDockerでCaddyをビルド定義ごと扱うと楽になれます。**

具体的には下記のDocker Compose定義を用いればいいでしょう。

```yaml:compose.yml
services:
  caddy_server:
    image: my-custom-built-caddy
    # 立ち上げる際に ./caddy_image/Dockerfile でビルドする
    build: ./caddy_image
    restart: always
    # ローカルホストにそのままプロキシさせたいのでホストネットワークモード
    network_mode: host
    volumes:
      # Caddyfile を置いているディレクトリ (後々のリロード操作のためにディレクトリをマウント)
      - ./caddy_conf:/etc/caddy:ro
      # 証明書やACME情報等の保存先
      - ./caddy_data:/data
```

```Dockerfile:caddy_image/Dockerfile
FROM caddy:2.7.5-builder AS builder

# Route53とやりとりするための拡張を組み込む
RUN xcaddy build \
    --with github.com/caddy-dns/route53@v1.4.0

FROM caddy:2.7.5

COPY --from=builder /usr/bin/caddy /usr/bin/caddy
```

```Caddyfile:caddy_conf/Caddyfile
{
    email admin@example.com
}

*.example.com {
    tls {
        dns route53 {
            region            us-east-1
            access_key_id     発行したアクセスキーID
            secret_access_key 発行したシークレットアクセスキー
        }
    }
}
```

上記の設定さえ行えば後は起動するだけです！

```shell-session
$ docker compose up -d
#   => https://any-string-goes-here.example.com 😉
```

### ところで、Route53で書き換えるゾーンIDの指定がCaddyfileに見当たらないけれど、どういう仕組みになっているの？

上記のCaddyfileでは、Route53についてアクセスキーIDとシークレットアクセスキーしか設定していませんが、よくよく考えるとこれだけではAWS上ではドメインの設定を書き換えることができません。書き換える対象のレコードがどのゾーンにいるかわからないからです。

```Caddyfile:Caddyfile
# 人間が見れば *.example.com のゾーンは example.com にありそうだと予測がつくが、
# サブドメインのワイルドカードなどであれば誰にもゾーンがどこかわからない

*.example.com {
    tls {
        dns route53 {
            access_key_id     発行したアクセスキーID
            secret_access_key 発行したシークレットアクセスキー
            # ここにゾーンの指定があってもよさそうだが、無い
        }
    }
}
```

ではCaddyは具体的にはどうやってゾーンを特定しているのか？ というと、チャレンジ中にCaddy上から `example.com` についてDNS問い合わせし、SOAレコードを見てレコードを追加するゾーンを特定しているようです。

(参考: [solvers.go](https://github.com/caddyserver/certmagic/blob/4574cfafa86adad227e5a83eff612ee1a63b4b16/solvers.go), [dnsutil.go](https://github.com/caddyserver/certmagic/blob/4574cfafa86adad227e5a83eff612ee1a63b4b16/dnsutil.go))

```go:solvers.go
// Present creates the DNS TXT record for the given ACME challenge.
func (s *DNS01Solver) Present(ctx context.Context, challenge acme.Challenge) error {
	dnsName := challenge.DNS01TXTRecordName()
	if s.OverrideDomain != "" {
		dnsName = s.OverrideDomain
	}
	keyAuth := challenge.DNS01KeyAuthorization()

	zone, err := findZoneByFQDN(dnsName, recursiveNameservers(s.Resolvers))
	if err != nil {
		return fmt.Errorf("could not determine zone for domain %q: %v", dnsName, err)
	}

	rec := libdns.Record{
		Type:  "TXT",
		Name:  libdns.RelativeName(dnsName+".", zone),
		Value: keyAuth,
		TTL:   s.TTL,
	}
```

```go:dnsutil.go
// findZoneByFQDN determines the zone apex for the given fqdn by recursing
// up the domain labels until the nameserver returns a SOA record in the
// answer section.
func findZoneByFQDN(fqdn string, nameservers []string) (string, error) {
	if !strings.HasSuffix(fqdn, ".") {
		fqdn += "."
	}
	soa, err := lookupSoaByFqdn(fqdn, nameservers)
	if err != nil {
		return "", err
	}
	return soa.zone, nil
}
```

面白いですね！（ストレートに設定ファイルにゾーンIDを指定させる仕様でもよかったのでは？？？とも思いますが）

:::note info
[v1.5.0](https://github.com/caddy-dns/route53/releases/tag/v1.5.0) ([PR](https://github.com/libdns/route53/pull/261)) にてゾーンIDを指定できるようになったようです
:::

## まとめ

DNS(Route53)・APIキー(AWSアクセスキー)・DNS拡張を組み込んだCaddyの3つさえ準備できれば簡単にワイルドカード証明書を取得できることがわかりました。

みなさんもぜひワイルドカード証明書を取得してみてください。

## 参考

- [How to use DNS provider modules in Caddy 2](https://caddy.community/t/how-to-use-dns-provider-modules-in-caddy-2/8148)
- [Route53 module for Caddy](https://github.com/caddy-dns/route53)
- [libdns](https://github.com/libdns/libdns)