AWS Containers Advent Calendar 2020 の 24 日目の記事です。
コンテナがメインの題材ではありませんが、AWS Cloud Map繋がりという事でご容赦を;
TL;DR
certbotのDNS認証で対象ドメインにTXTレコードが設定出来なくても、CNAMEで他のドメインに移譲出来ればなんとかなる。
経緯
先日ECS上で稼働する複数のコンテナサービスをAWS Cloud Mapのパブリックネームスペースに登録してサービスディスカバリさせるというシステムを構築しました。
その際、諸事情によりALBやACMなどでクラスタの手前にSSL終端を置くなどの方法が使えず、個々のサーバコンテナ毎に起動時にSSL証明書を作成/更新する必要があったのですが、certbotのdns-route53プラグインで証明書を作成しようとしたところ、以下のエラーに遭遇しました。
$ 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
srv.example.work
がCloud Mapにより生成され管理されているゾーン。
ルートとなるexample.work
のホストゾーンにsrv.example.work
のNSレコードを作成し、srv
以下のサブドメインをCloud Mapのネームスペースに向くようにしています。
AWS Cloud Map
srv.example.work
のネームスペースの下にいくつかサービスが紐付いており、このサブドメインのサーバで使用するSSL証明書を自動で生成/更新出来るようにしたい。
結局のところ対象のサブドメインに_acme-challenge
のTXTレコードを設定出来れば良いのですが、Cloud Mapのサービスに設定出来るのは今のところ A/AAAA/SRV/CNAME
レコードのみ・・・困った
解決まで
最初、DNSレコードでの認証を辞めてHTTP-01認証によるTOKENファイルをCloud Mapに登録したHTTPサービスでホスティングする方法を考えましたが、Let’s Encryptのチャレンジタイプ説明ページに以下の記述が・・・
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のインスタンス情報を登録
- manualモードでcertbotコマンドを実行
$ 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レコードとして登録し、認証を続行。
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プラグインを自作してみました。
certbot開発環境
certbotのリポジトリをクローン後、開発用にDocker環境が用意されているので有り難く使わせて頂きます。
$ 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/
にプラグインのサンプルコードがあるのでこれをコピーして書き換えていきます。
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
の中のプラグイン名や依存モジュールを修正。
(ファイル名がそのままなのはタダの無精なので、本使用時は良い感じの名前に変更予定)
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処理を追加挿入しました。
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
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
で自作プラグインを指定してテスト実行
# 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.
無事証明書作成処理が通りました。
ワイルドカードやサブドメインの複数指定もきちんと認証出来ている模様。
{
"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上で動かすアプリケーションコンテナに組み込むだけ。
これで安心して年越しを迎えられそうです。