AWS Cloud Map管理下のサブドメインのSSL証明書をcertbotで作成する

[AWS Containers Advent Calendar 2020](https://qiita.com/advent-calendar/2020/aws-containers) の 24 日目の記事です。

コンテナがメインの題材ではありませんが、AWS Cloud Map繋がりという事でご容赦を;


## TL;DR

certbotのDNS認証で対象ドメインにTXTレコードが設定出来なくても、CNAMEで他のドメインに移譲出来ればなんとかなる。


## 経緯

先日ECS上で稼働する複数のコンテナサービスを[AWS Cloud Map](https://aws.amazon.com/jp/cloud-map/)のパブリックネームスペースに登録してサービスディスカバリさせるというシステムを構築しました。

その際、諸事情によりALBやACMなどでクラスタの手前にSSL終端を置くなどの方法が使えず、個々のサーバコンテナ毎に起動時にSSL証明書を作成/更新する必要があったのですが、certbotの[dns-route53](https://certbot-dns-route53.readthedocs.io/en/stable/)プラグインで証明書を作成しようとしたところ、以下のエラーに遭遇しました。

```py
$ certbot certonly --dns-route53 -d "*.srv.example.work" -m "test@example.work" -n --agree-tos

...

 Traceback (most recent call last):
   File "/usr/local/lib/python3.5/dist-packages/certbot/_internal/auth_handler.py", line 70, in handle_authorizations
     resps = self.auth.perform(achalls)
   File "/usr/local/lib/python3.5/dist-packages/certbot_dns_route53/_internal/dns_route53.py", line 68, in perform
     raise errors.PluginError("\n".join([str(e), INSTRUCTIONS]))
 certbot.errors.PluginError: An error occurred (AccessDenied) when calling the ChangeResourceRecordSets 
 operation: The resource hostedzone/Z******************** can only be managed through AWS Cloud Map (arn:aws:servicediscovery:ap-northeast-1:************:namespace/ns-***************)
 To use certbot-dns-route53, configure credentials as described at https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials and add the necessary permissions for Route53 access.
```

certbot-dns-route53ではドメインの所有者を確認する際、自動的にRoute53のDNSに`_acme-challenge`TXTレコードを書き込むことで認証を行いますが、Cloud Mapが管理するRoute53ゾーンのレコードは外部から直接変更することが出来ない為エラーになる模様。


ちなみに Cloud Map と DNS の設定は以下のような構成です。
（ドメイン名などは実際のサービスとは変更済み）

### Route53 Host Zone

![Route53 HostZone](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/e562c1f4-157c-5ffa-2db6-e3b146fd46a7.png)

`srv.example.work`がCloud Mapにより生成され管理されているゾーン。
ルートとなる`example.work`のホストゾーンに`srv.example.work`のNSレコードを作成し、`srv`以下のサブドメインをCloud Mapのネームスペースに向くようにしています。

![スクリーンショット 2020-12-23 22.32.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/9842e130-eb2b-2d59-fb1e-855a6242fdc3.png)

![スクリーンショット 2020-12-23 22.33.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/f32941ba-4e0f-f90c-8fcb-ebfd47b461de.png)


### AWS Cloud Map

![スクリーンショット 2020-12-23 21.41.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/165d5a0b-a600-4f39-de75-7251b9cdaeec.png)

`srv.example.work`のネームスペースの下にいくつかサービスが紐付いており、このサブドメインのサーバで使用するSSL証明書を自動で生成/更新出来るようにしたい。

![スクリーンショット 2020-12-23 22.15.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/56cd4729-9a57-b279-f8b2-bba924866036.png)

結局のところ対象のサブドメインに`_acme-challenge`のTXTレコードを設定出来れば良いのですが、Cloud Mapのサービスに設定出来るのは今のところ `A/AAAA/SRV/CNAME` レコードのみ・・・困った

![スクリーンショット 2020-12-24 14.54.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/12175c9b-009a-6ba9-19e4-294efe2dcb05.png)



## 解決まで

最初、DNSレコードでの認証を辞めてHTTP-01認証によるTOKENファイルをCloud Mapに登録したHTTPサービスでホスティングする方法を考えましたが、Let’s Encryptの[チャレンジタイプ説明ページ](https://letsencrypt.org/ja/docs/challenge-types/)に以下の記述が・・・

> #### HTTP-01 チャレンジ
> ...
> 欠点:
> ・この方法では Let’s Encrypt が**ワイルドカード証明書を発行することができない**。

Cloud Map上のサービスには出来ればワイルドカード証明書を使用したかったので、やはりDNS-01方式で解決する方法が無いか探ります。

同じく上記説明ページを読み進めたところ、以下の情報を発見。

> Let’s Encrypt は DNS-01 検証で TXT レコードを検索するときに DNS 標準に従っているので、CNAME レコードや NS レコードを使用することで、他の DNS ゾーンへチャレンジの回答を移譲できます。この機能は、検証用のサーバーやゾーンへ _acme-challenge サブドメインを移譲するときに利用することができます。

=> つまりCloud Mapのネームスペース内に
`_acme-challenge.srv.example.work` => `_acme-challenge.example.work`
となるCNAMEを作成してやってから、`example.work`のRoute53ゾーンでTXTレコードを作成し検証させれば良いのでは？

### 実験

ひとまず諸々の設定を手動で登録して証明書作成が成功するか検証。

* Cloud Mapに`_acme-challenge`のサービスとCNAMEのインスタンス情報を登録

![スクリーンショット 2020-12-23 23.14.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/32d83046-7c8e-fa22-f1e1-1bdf966be0ef.png)

![スクリーンショット 2020-12-23 23.13.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/6b5ff693-c825-fd8e-f062-6f6faea45746.png)

* manualモードでcertbotコマンドを実行

```sh
$ sudo certbot certonly --manual -m test@example.work -d '*.srv.example.work' --staging --preferred-challenges=dns-01
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator manual, Installer None
Requesting a certificate for *.srv.example.work
Performing the following challenges:
dns-01 challenge for srv.example.work

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please deploy a DNS TXT record under the name
_acme-challenge.srv.example.work with the following value:

*********************************************

Before continuing, verify the record is deployed.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Press Enter to Continue
```

* 発行された一時トークンをRoute53の`example.work`ゾーンにTXTレコードとして登録し、認証を続行。

![スクリーンショット 2020-12-23 23.50.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104276/b6943d3d-3bb5-06ac-8af1-0b51808d96db.png)

```
Waiting for verification...
Cleaning up challenges

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/srv.example.work/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/srv.example.work/privkey.pem
   Your cert will expire on 2021-03-22. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
```

=> 無事証明書が作成された！


## certbot-route53プラグイン改造

Cloud MapのドメインでもDNS認証が可能な事は確認できましたが、
certbot-dns-route53プラグインはRoute53から認証対象のドメインに合致するホストゾーンを自動でサーチして使用するので、そのままではTXTレコードを作成するゾーンを差し替えられません。

よって以下の開発ドキュメントを参考にcertbotプラグインを自作してみました。

https://certbot.eff.org/docs/contributing.html

### certbot開発環境

certbotのリポジトリをクローン後、[開発用にDocker環境が用意されている](https://certbot.eff.org/docs/contributing.html#running-the-client-with-docker)ので有り難く使わせて頂きます。

```sh
$ git clone https://github.com/certbot/certbot && cd certbot
$ docker-compose run --rm --service-ports development bash
Creating certbot_development_run ... done

root@c34d6869266c:/opt/certbot/src#
```

### プラグイン作成

[`certbot/examples/`](https://github.com/certbot/certbot/tree/master/certbot/examples/plugins) にプラグインのサンプルコードがあるのでこれをコピーして書き換えていきます。

```sh
root@c34d6869266c:/opt/certbot/src# cp -r certbot/examples/plugins certbot-dns-route53-with-cloudmap
root@c34d6869266c:/opt/certbot/src# cd certbot-dns-route53-with-cloudmap
root@c34d6869266c:/opt/certbot/src/certbot-dns-route53-with-cloudmap# ls -la
total 12
drwxr-xr-x  6 root root  192 Dec 23 20:32 .
drwxr-xr-x 57 root root 1824 Dec 23 20:32 ..
-rw-r--r--  1 root root 5688 Dec 23 20:32 certbot_example_plugins.py
-rw-r--r--  1 root root  415 Dec 23 20:32 setup.py
```

`setup.py`の中のプラグイン名や依存モジュールを修正。
(ファイル名がそのままなのはタダの無精なので、本使用時は良い感じの名前に変更予定)

```py:setup.py
from setuptools import setup

setup(
    name='certbot-dns-route53-with-cloudmap',
    package='certbot_example_plugins.py',
    install_requires=[
        'certbot',
        'boto3',
        'setuptools',
        'zope.interface',
        'certbot-dns-route53',
    ],
    entry_points={
        'certbot.plugins': [
            'certbot-dns-route53-with-cloudmap = certbot_example_plugins:Authenticator',
        ],
    },
)
```

認証処理の実装は dns-route53 プラグインの `Authenticator` を継承し、
DNSレコードの書き換えメソッドをOverrideしてCloud Mapリソースの登録やcleanup処理を追加挿入しました。

```py:certbot_example_plugins.py
import collections
import logging
import time

import boto3
from botocore.exceptions import ClientError
import zope.interface

from certbot import interfaces
from certbot.compat import os

from certbot_dns_route53._internal import dns_route53

logger = logging.getLogger(__name__)

CLOUD_MAP_CHALLENGE_INSTANCE_ID = 'acmeChallengeDelegate'


@zope.interface.implementer(interfaces.IAuthenticator)
@zope.interface.provider(interfaces.IPluginFactory)
class Authenticator(dns_route53.Authenticator):
    """Route53 with AWS Cloud Map Authenticator."""

    def __init__(self, *args, **kwargs):
        self._sd = boto3.client("servicediscovery")
        self._delegate_services = collections.defaultdict(list)
        self._delegate_instances = collections.defaultdict(list)

        self._cloud_map_name_space_id = os.environ.get('AWS_CLOUD_MAP_NAMESPACE_ID')
        self._cloud_map_delegate_domain = os.environ.get('AWS_CLOUD_MAP_CHALLENGE_DELEGATE_DOMAIN')
        self._namespace = None
        if self._cloud_map_name_space_id is not None:
            self._namespace = self._sd.get_namespace(Id=self._cloud_map_name_space_id)['Namespace']

        super(Authenticator, self).__init__(*args, **kwargs)

    def _change_txt_record(self, action, validation_domain_name, validation):
        if self._namespace is None:
            return super()._change_txt_record(action, validation_domain_name, validation)
        else:
            delegated_domain_name = '_acme-challenge.' + self._cloud_map_delegate_domain

            if action == "UPSERT":
                self._register_delegate_cname(validation_domain_name, delegated_domain_name)
            else:
                self._deregister_delegate_cname(validation_domain_name)

            return super()._change_txt_record(action, delegated_domain_name, validation)

    def _register_delegate_cname(self, validation_domain_name, delegated_domain_name):
        service_name = validation_domain_name.rstrip('.' + self._namespace['Name'])

        delegate_services = self._delegate_services[validation_domain_name]
        delegate_instances = self._delegate_instances[validation_domain_name]

        service = None
        try:
            # CNAME用Service作成
            service = self._sd.create_service(
                Name=service_name,
                NamespaceId=self._cloud_map_name_space_id,
                DnsConfig={
                    'RoutingPolicy': 'WEIGHTED',
                    'DnsRecords': [{'Type': 'CNAME', 'TTL': 10}]
                },
            )['Service']
            delegate_services.append(service)
        except ClientError as e:
            if 'Service already exists.' not in str(e):
                raise e
            # Serviceが存在する場合、既存リソースを取得して処理を続行する
            logger.warn(e)
            services = self._sd.list_services(
                MaxResults=100,  # NOTE: サービスが100件を超える場合はNextTokenでのページング処理が必要
                Filters=[
                    {'Name': 'NAMESPACE_ID',
                     'Values': [self._cloud_map_name_space_id],
                     'Condition': 'EQ'},
                ]
            )['Services']
            service = next(filter(
                lambda x: x['Name'] == service_name and 'CNAME' in str(x['DnsConfig']),
                services
            ), None)

        # CNAME用Instance情報登録
        instance = self._sd.register_instance(
            ServiceId=service['Id'],
            InstanceId=CLOUD_MAP_CHALLENGE_INSTANCE_ID,
            Attributes={
                'AWS_INSTANCE_CNAME': delegated_domain_name
            }
        )
        instance['service_id'] = service['Id']
        delegate_instances.append(instance)

    def _deregister_delegate_cname(self, validation_domain_name):
        delegate_services = self._delegate_services[validation_domain_name]
        delegate_instances = self._delegate_instances[validation_domain_name]

        for instance in delegate_instances:
            try:
                self._sd.deregister_instance(
                    ServiceId=instance['service_id'],
                    InstanceId=CLOUD_MAP_CHALLENGE_INSTANCE_ID,
                )
                # Instanceが完全に削除されるまで一定時間ポーリング
                for n in range(3, 10):
                    time.sleep(n)
                    self._sd.get_instance(
                        ServiceId=instance['service_id'],
                        InstanceId=CLOUD_MAP_CHALLENGE_INSTANCE_ID,
                    )
                    # Instanceが削除されて NotFound Error が発生したらループを抜ける
            except ClientError as e:
                if 'InstanceNotFound' not in str(e):
                    logger.debug('Encountered error during cleanup: %s', e, exc_info=True)
                delegate_instances.remove(instance)

        for service in delegate_services:
            # Service削除
            self._sd.delete_service(
                Id=service['Id']
            )
            delegate_services.remove(service)
```

ワイルドカード有り無しのドメインを同時に指定した際サービス名が被ってCreateでエラーになったので、
`_acme-challenge`のServiceが存在する場合は既存リソースを使って処理を続行するようにしてます。


### 動作確認

pipで上記モジュールをinstall

```sh
root@c34d6869266c:/opt/certbot/src/certbot-dns-route53-with-cloudmap# pip install -e .
Obtaining file:///opt/certbot/src/certbot-dns-route53-with-cloudmap
Requirement already satisfied: certbot in /opt/certbot/venv3/lib/python3.7/site-packages (from certbot-dns-route53-with-cloudmap==0.0.0) (1.10.1)

.
.
.

Installing collected packages: certbot-dns-route53-with-cloudmap
  Running setup.py develop for certbot-dns-route53-with-cloudmap
Successfully installed certbot-dns-route53-with-cloudmap
```

必要な環境変数を設定し、`-a`で自作プラグインを指定してテスト実行

```sh
# export AWS_ACCESS_KEY_ID="AKIXXXXXXXXXXXXXX";
# export AWS_SECRET_ACCESS_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
# export AWS_DEFAULT_REGION=ap-northeast-1;
# export AWS_CLOUD_MAP_NAMESPACE_ID=ns-xxxxxxxxxxxxxxxx;
# export AWS_CLOUD_MAP_CHALLENGE_DELEGATE_DOMAIN=example.work

root@c34d6869266c:/opt/certbot/src/certbot-dns-route53-with-cloudmap# certbot certonly -n --email test@example.work -a certbot-dns-route53-with-cloudmap -d srv.example.work,*.srv.example.work,node1.test.srv.example.work,node2.test.srv.example.work --dry-run --staging -v
Root logging level set at 10
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Requested authenticator certbot-dns-route53-with-cloudmap and installer None

.
.
.

Dry run: Skipping creating new lineage for srv.example.work
Reporting to user: The dry run was successful.

IMPORTANT NOTES:
 - The dry run was successful.
```

無事証明書作成処理が通りました。
ワイルドカードやサブドメインの複数指定もきちんと認証出来ている模様。

```json
{
  "status": "valid",
  "expires": "2020-12-30T20:49:39Z",
  "identifiers": [
    {
      "type": "dns",
      "value": "*.srv.example.work"
    },
    {
      "type": "dns",
      "value": "node1.test.srv.example.work"
    },
    {
      "type": "dns",
      "value": "node2.test.srv.example.work"
    },
    {
      "type": "dns",
      "value": "srv.example.work"
    }
  ],
  ...
}
```


削除周りの処理とか色々ゴチャゴチャしててまだバグが潜んでそうですが、一応最低限は要求を満たせたのであとはこれをCloud Map上で動かすアプリケーションコンテナに組み込むだけ。

これで安心して年越しを迎えられそうです。