Про то, как поднять контейнер Nginx и настроить для него автообновление сертификатов LetsEncrypt, есть довольно много статей. В этой будет описана довольно нестандартная схема. Основные моменты:
- Nginx разворачивается как сервис в Docker Swarm, а не как standalone-контейнер;
- Для проверки используется схема DNS-01, а не гораздо более популярная HTTP-01;
- Для DNS-провайдера GoDaddy в настоящий момент нет DNS-plugin'а для certbot'а, но есть API по управлению доменными записями.
Что такое DNS-01 и зачем он нужен
Когда у LetsEncrypt'а запрашивают сертификат, ему нужно убедиться, что у того, кто его запрашивает, есть права на соответствующий домен. Для этого LetsEncrypt использует проверки. Самая популярная проверка называется HTTP-01. Она заключается в том, что клиенту сначала выдается специальный токен, а потом сервер LetsEncrypt делает запрос на адрес http://<DOMAIN>/.well-known/acme-challenge/<TOKEN>
и проверяет, что ответ содержит тот же токен + хэшированный ключ того же клиента, которому токен выписывали. Но здесь есть 2 момента:
- Запрос всегда выполняется именно такой, как написано выше. Поэтому у вас обязан быть открыт порт 80, а указанный путь всегда доступен извне без всякой аутентификации;
- Wildcard-сертификаты не поддерживаются. Хотя вы можете выписать один сертификат сразу на несколько поддоменов (в этом случае LetsEncrypt будет делать запросы на каждый из поддоменов).
Проверка DNS-01 позволяет, во-первых, обойтись без открытого 80-порта, а во-вторых, использовать Wildcard-сертификаты. Но для этого требуется опубликовать токен, полученный от certbot'а, в виде DNS-записи _acme-challenge.<YOUR_DOMAIN>
с типом TXT. Вручную это сделать не проблема, а вот для автоматизации требуется поддержка со стороны провайдера. Почти все крупные провайдеры предостставляют API для управления DNS-записями, а для некоторых существует dns-plugin'ы для certbot'а, которые реализуют клиентское подключение к этим API. Но для провайдера GoDaddy плагина нет, хотя API и существует.
А зачем Docker Swarm?
Docker Swarm — это встроенный в Docker механизм кластеризации. По сравнению с Kubernetes он менее функциональный, зато настраивается гораздо проще и доступен из коробки. Даже если вам не нужен кластер из нескольких машин, однонодный Docker Swarm может быть удобен, поскольку он предоставляет:
- больше абстракций для организации сервисов (stack/service vs container);
- горизонтальное масштабирование сервисов и поинстансное обновление;
- лучшую поддержку рестарта;
- возможность использования secret'ов (хотя и не в том виде, в котором предлагает ее Kubernetes).
В статье будет использоваться именно однонодный Swarm.
Что конкретно будет делаться в статье
Допустим, у вас есть домен example.com,
купленный через GoDaddy. Вы хотите сделать сервер-gateway в этом домене. У вас планируется несколько сервисов, разделенных по поддоменам, пока вы точно не знаете сколько. Будут это отдельные машины или сервисы в рамках Swarm'а вы пока тоже не знаете, но допускаете, что может быть и так, и так. Вы хотите, чтобы весь трафик шел через этот gateway, а дальше уже маршрутизировался внутри вашей сети. Входящий трафик должен быть защищен SSL. Все поддомены вы регистрируете на один и тот же адрес, а за маршрутизацию у вас будет отвечать nginx. Он же будет выступать SSL-терминатором.
Далее в статье мы получим wildcard-сертификат для вашего домена, поднимем nginx в виде сервиса в docker swarm, а также настроим автоматическое обновление сертификата. Маршрутизация запросов после nginx'а рассматриваться не будет. Будем считать, что есть только один поддомен gateway.example.com
, и все действия выполняются с этой машины.
Настройка Docker Swarm
Предполагается, что Docker Engine на машине уже установлен.
- Как уже упоминалось ранее, создание Swarm'а выполняется крайне просто:
$ docker swarm init
- Чтобы посмотреть список и статус узлов кластера, выполните:
$ docker node ls
Получение SSL-сертификатов
Предполагается, что у вас уже есть аккаунты в LetsEncrypt и GoDaddy.
- Получите необходимые данные для использования API своего DNS-провайдера. Для случая GoDaddy, зайдите на https://developer.godaddy.com/ и сгенерируйте себе API Key. В качестве типа ключа обязательно укажите
Production
. Сгенерируйте ACME-токен при помощи certbot'а. Certbot мы будем использовать также контейнеризированным:
$ docker run --rm -it --mount type=bind,source=/opt/letsencrypt,target=/etc/letsencrypt certbot/certbot:v1.3.0 --email account@example.com --agree-tos -d *.example.com --manual --preferred-challenges dns certonly
где
/opt/letsencrypt
— директория, в которую будут сохранены сертификаты и которая впоследствии будет подмонтирована nginx'ом для их использования;
account@example.com
— ваш аккаунт в LetsEncrypt;
*.example.com
— домен, для которого будет получен сертификат (в данном случае, wildcard).
В ходе выполнения у вас спросят про то, можно ли вам присылать почту на email (можно ответить Нет), а также разрешение на публикацию факта, что с вашего IP происходил запрос сертификата (здесь надо ответить Да). Затем certbot будет ждать, когда вы опубликуете значение
TOKEN_STRING
в TXT-записи_acme-challenge.example.com
.
Далее откройте еще одну консоль (в первой certbot все еще ждет, пока вы не скажете ему проверить DNS). В этой консоли при помощи curl'а воспользуйтесь API GoDaddy для регистрации записи. Можно это сделать и через интерфейс сайта, но впоследствии похожие действия надо будет делать для настройки автообновления сертификатов.
Сначала подготовьте payload:
$ cat <<EOF > payload.json [{ "data": "TOKEN_STRING", "name": "_acme-challenge", "type": "TXT" }] EOF
где TOKEN_STRING — значение, выданное certbot'ом.
Затем установите в переменные ключ и секретный токен вашего ключа от GoDaddy, полученного на шаге 1:
$ export GODADDY_KEY=<KEY> $ export GODADDY_SECRET=<SECRET>
Создайте запись _acme-challenge в вашем домене (обратите внимание на то, что в качестве одной из частей URL выступает ваш домен):
$ curl -XPUT -d @payload.json -H "Content-Type: application/json" -H "Authorization: sso-key $GODADDY_KEY:$GODADDY_SECRET" https://api.godaddy.com/v1/domains/example.com/records/TXT/_acme-challenge
Проверьте, что эта запись уже доступна через DNS и ее содержимое соответствует ожидаемому (может потребоваться время, особенно если вы генерируете запись повторно. Обычно, не более минуты):
$ dig -t txt _acme-challenge.example.com ... ;; ANSWER SECTION: _acme-challenge.example.com. 600 IN TXT "TOKEN_STRING" ...
- Вернитесь в консоль с certbot'ом и нажмите Enter. Он завершит формирование сертификатов, которые можно будет найти в директории
/opt/letsencrypt/live/example.com
после того, как контейнер закончит свою работу.
Настройка сервиса Nginx
В этом примере будет использоваться единственный конфигурационный файл, который будет монтироваться как nginx.conf
. На любой запрос, пришедший по SSL, сервер будет отвечать статусом HTTP 200 и текстом "It works!
". В более сложных случаях вам может понадобиться монтировать для конфигов специальную директорию, так же как и директории для статического контента.
Создайте файл nginx.conf в директории /opt/nginx/conf:
nginx.confuser nginx; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; server { listen 443 ssl default_server; server_name _; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; location / { return 200 'It works!'; add_header Content-Type text/plain; } } }
- Создайте сервис nginx:
$ docker service create --name nginx -p 443:443 --mount type=bind,source=/opt/nginx/conf/nginx.conf,target=/etc/nginx/nginx.conf,ro --mount type=bind,source=/opt/letsencrypt,target=/etc/letsencrypt,ro nginx:1.17.9
- Зайдите на
https://gateway.example.com
в своем браузере, вы должны увидеть страницу с текстом "It works!". - Для рестарта сервиса после изменения конфигурации, используйте команду:
$ docker service update --force nginx
Автоматическое обновление сертификатов
Это будет непросто :) Сертификаты LetsEncrypt действуют 90 дней. Сам LetsEncrypt рекомендует обновлять их через 60. Чтобы не делать это вручную каждые 2 месяца, задачу надо автоматизировать. Если попытаться саму логику периодического обновления тоже перенести в докер, то надо иметь ввиду следующее:
- Лучше не запускать несколько процессов внутри контейнера. То есть, сделать образ, в котором будет и выполняться периодическое обновление сертификатов, и выполняться основная работа nginx — не самый хороший вариант;
- Docker Swarm не поддерживает ни одноразовые задачи, ни задачи по расписанию (хотя есть довольно многообещающая инициатива: https://github.com/docker/swarmkit/issues/2852);
- В любом случае, для того, чтобы Nginx подхватил новые сертификаты ему нужно сделать либо reload, либо restart. reload делается через отправку сигнала HUP, что нельзя сделать из другого сервиса или контейнера;
- Есть рабочий вариант: сделать образ, который запускает nginx в фоне, а в качестве foreground-процесса использует
while true; do nginx -s reload; sleep 1d; done
и это ужасно.
На мой взгляд, лучшее решение — управлять обновлением сертификата с самой машины (в нашем примере, gateway) через cron
. Но с этим подходом есть другая проблема — скрипт, запускаемый cron'ом, должен как-то получить ключ и токен для аккаунта GoDaddy, а их не очень хочется хранить в открытом виде. Здесь на помощь приходят docker secrets, которые доступны в Swarm-режиме. Но ими может воспользоваться только сервис, который по идеологии докера должен работать всегда (см. п. 2 про одноразовые задачи в Swarm). Для решения проблемы я предлагаю воспользоваться инструментом JaaS oт Alex Ellis (https://github.com/alexellis/jaas).
Вам может показаться, что лучше уж положить ключи в открытом виде в условно-закрытый файл, чем усложнять себе жизнь использованием непонятного инструмента. В конце концов, если кто-то сможет читать на вашем сервере файлы, доступные только для root'а, то вряд ли утекшие ключи для API GoDaddy окажутся вашей главной проблемой. В этом случае просто используйте cron, docker run и немного модифицируйте приложенные скрипты, явно прописав туда значения ключей.
Скачайте код проекта jaas:
$ git clone https://github.com/alexellis/jaas
Соберите проект:
$ cd jaas $ docker run --rm -v "$PWD":/usr/src/jaas -w /usr/src/jaas golang:1.13 bash -c "go get -d -v github.com/alexellis/jaas && go build -v"
Скопируйте полученный бинарный файл в /usr/local/bin (или создайте символьную ссылку):
$ sudo cp jaas /usr/local/bin
Проверьте работу jaas:
$ jaas run --image alpine:3.8 --env FOO=bar --command "env"
В этом примере создается сервис на базе image'а alpine, которому передается переменная окружения FOO=bar. Сам сервис выполняет команду env, чтобы удостовириться, что переменная окружения успешно передана. После завершения работы, этот сервис удаляется jaas'ом.
Создайте secret'ы в докере для хранения ключей к API GoDaddy:
$ printf $GODADDY_KEY | docker secret create godaddy-key - $ printf $GODADDY_SECRET | docker secret create godaddy-secret -
Для обновления записи в DNS воспользуемся возможностью certbot'а запускать authenticate- и cleanup-скрипты (https://certbot.eff.org/docs/using.html#hooks). Эти скрипты будут запускаться из сервиса, созданном из docker-образа certbot. В этом образе нет установленного curl'а, а поставляемая версия wget'а не умеет слать PUT-запросы. Зато там есть установленный python 3.8 и библиотека requests. Положите скрипты
authenticate.sh
иcleanup.sh
в/opt/godaddy-hooks
(сами скрипты есть в конце статьи).
Обратите внимание на то, что в образе
certbot
так же нет установленной утилитыdig
, поэтому затруднительно проверить, распространились настройки DNS или нет (скрипт должен завершаться, когда они уже распространились). По моим экспериментам за 60 секунд это успевает произойти (а вот за 45, к примеру, не всегда успевает). Для использования в продакшне лучше использовать значения с некоторым запасом, либо воспользоваться другим механизмом контроля распространения DNS-записей, но это потребует сборки отдельного образа. Также обратите внимание на то, что GoDaddy по какой-то причине не дает возможность удалить отдельную DNS-запись. Cleanup-скрипт использует метод API для обновления всех записей, относящихся к домену, что потенциально рискованно.
Сделайте скрипты исполняемыми:
$ chmod +x /opt/godaddy-hooks/authenticate.sh /opt/godaddy-hooks/cleanup.sh
Проверьте работу скриптов:
$ jaas run --timeout 90s --mount /opt/letsencrypt=/etc/letsencrypt --mount /opt/godaddy-hooks=/opt/hooks -s godaddy-key -s godaddy-secret --image certbot/certbot:v1.3.0 --command "certbot --manual --manual-auth-hook /opt/hooks/authenticate.sh --manual-cleanup-hook /opt/hooks/cleanup.sh renew --dry-run --no-random-sleep-on-renew"
Создайте периодическую задачу, которая будет выполнять обновление скриптов. Используйте
cron
, либо, если это ваша рабочая машина или домашний сервер, то может лучше подойтиanacron
. Настройте обновление сертификатов раз в неделю (первые 2 месяца оно не будет выполняться, а на 3-й месяц у вас будет 4 попытки успеть это сделать). Также обратите внимание, чтоcertbot
приrenew
по умолчанию добавляет случайную задержку, длительностью до 8 минут. Это, судя по всему, связано с тем, что слишком многие настравивают автообновление одинаково и CA перестает справляться с таким количеством запросов. Либо настройте запуск на "некруглое" время и используйте опцию--no-random-sleep-on-renew
, либо увеличьте таймаут jaas'а. Пример скрипта для обновления:
#!/bin/sh jaas run --timeout 90s --mount /opt/letsencrypt=/etc/letsencrypt --mount /opt/godaddy-hooks=/opt/hooks -s godaddy-key -s godaddy-secret --image certbot/certbot:v1.3.0 --command "certbot --manual --manual-auth-hook /opt/hooks/authenticate.sh --manual-cleanup-hook /opt/hooks/cleanup.sh renew --no-random-sleep-on-renew" docker service update --force nginx
Пример скриптов для GoDaddy
#!/bin/sh
read key < /run/secrets/godaddy-key
read secret < /run/secrets/godaddy-secret
python - <<EOF
import requests
requests.put(
url = 'https://api.godaddy.com/v1/domains/$CERTBOT_DOMAIN/records/TXT/_acme-challenge',
json = [{'type': 'TXT', 'name': '_acme-challenge', 'data': '$CERTBOT_VALIDATION'}],
headers = {'Authorization': 'sso-key $key:$secret'}
)
EOF
sleep 60
#!/bin/sh
read key < /run/secrets/godaddy-key
read secret < /run/secrets/godaddy-secret
python - <<EOF
import requests
response = requests.get(
url = 'https://api.godaddy.com/v1/domains/$CERTBOT_DOMAIN/records',
headers = {'Authorization': 'sso-key $key:$secret'}
)
requests.put(
url = 'https://api.godaddy.com/v1/domains/$CERTBOT_DOMAIN/records',
json = [record for record in response.json() if record['name'] != '_acme-challenge'],
headers = {'Authorization': 'sso-key $key:$secret'}
)
EOF