Настройка ACME#

Модуль ACME в Angie обеспечивает автоматическое получение сертификатов с использованием протокола ACME. Протокол ACME предусматривает несколько способов проверки доменов (также используется термин верификация); модуль реализует HTTP-проверку, DNS-проверку, ALPN-проверку, а также проверку с помощью хуков через самостоятельно реализуемый внешний сервис.

Шаги настройки#

Общие шаги для включения запроса сертификатов в конфигурации:

  • Настройте ACME-клиент в блоке http с помощью директивы acme_client, задающей уникальное имя клиента и другие параметры; можно настроить несколько клиентов ACME.

  • Укажите домены, для которых запрашиваются сертификаты: для доменных имен, перечисленных во всех директивах server_name всех блоков server с директивами acme, указывающими на один и тот же ACME-клиент, будет выдан единый сертификат.

    IP-адреса (IPv4 или IPv6), указанные в server_name, также являются допустимыми идентификаторами сертификата, за исключением DNS-проверки.

    Обратите внимание: публичные центры сертификации могут выдавать сертификаты для IP-адресов только в рамках определенных профилей сертификатов; например, Let's Encrypt в настоящее время требует профиль, допускающий IP-идентификаторы (см. документацию по профилям). Чтобы запросить такой профиль, задайте параметр profile= директивы acme_client.

  • Настройте обработку запросов и вызовов ACME: это нужно для проверки владения доменом. Способ настройки зависит от способа проверки доменных имен:

    Способ

    Требования к пользователю

    Мультидомены

    Домены со звездочкой

    HTTP-проверка

    Открыть порт 80 (или указанный в acme_http_port) для входящих соединений на сервере Angie.

    DNS-проверка

    Открыть порт 53 (или указанный в acme_dns_port) для входящих соединений на сервере Angie.

    Настроить NS-запись для поддомена _acme-challenge., направив ее на свой сервер Angie.

    ALPN-проверка

    Открыть порт 443 (или TLS-порт, используемый сервером Angie) для входящих соединений.

    Проверка хуками

    Реализовать внешний сервис (скрипт или приложение), который по команде Angie сможет внести изменения в DNS-зону или разместить специальный ответ на веб-сервере.

  • Настройте SSL с использованием полученного сертификата и ключа: Модуль делает сертификаты и ключи доступными в виде встроенных переменных, которые можно использовать в конфигурации для заполнения ssl_certificate и ssl_certificate_key.

    Инструкции по настройке SSL см. в разделе Настройка SSL.

Совет

Процесс получения и обновления сертификатов зависит от работы многих служб и может занимать какое-то время. Запаситесь терпением, а в случае возникновения проблем или сомнений обратитесь к отладочному логу.

Подробности реализации#

Здесь ключи и сертификаты клиентов хранятся в кодировке PEM в соответствующих подкаталогах каталога, заданного с помощью параметра сборки --http-acme-client-path:

$ ls /var/lib/angie/acme/example/

  account.key  certificate.pem  private.key

Примечание

Эти файлы сохраняются на диске между перезапусками. При запуске клиент повторно использует сохраненный сертификат, если тот еще действителен, вместо запроса нового, что устраняет задержку на выпуск и лишние обращения к серверу CA, на которые могут действовать ограничения частоты запросов. В контейнере размещайте каталог хранения (по умолчанию /var/lib/angie/acme/) на постоянном томе, чтобы выданные сертификаты сохранялись при пересоздании контейнера; см. запуск Angie в контейнере.

Примечание

Перезагрузка конфигурации (angie -s reload) заново сверяет каждый клиент ACME с сертификатами, сохраненными на диске. Клиент, сертификат которого в данный момент недействителен (в том числе если его предыдущий запрос завершился ошибкой), запрашивается заново немедленно, независимо от retry_after_error. Задержка перед повторной попыткой (как и значение retry_after_error=off) действует, только пока Angie работает, и не сохраняется после перезагрузки. Поэтому повторные перезагрузки при постоянно неудачных запросах могут приводить к частым обращениям к серверу CA, на которые действуют упомянутые выше ограничения частоты запросов.

Клиенту ACME требуется учетная запись на сервере CA. Для ее создания и управления ею клиент использует закрытый ключ (account.key); если ключа у него еще нет, ключ создается при запуске. Затем клиент использует его для регистрации учетной записи на сервере.

Примечание

Если у вас уже есть ключ учетной записи, поместите его в подкаталог клиента перед запуском для повторного использования учетной записи. Файл ключа также можно указать с помощью параметра account_key в acme_client.

Некоторые CA требуют привязать учетную запись ACME к уже существующей внешней учетной записи с помощью External Account Binding (EAB), прежде чем выпускать сертификаты. Для этого задайте параметр eab директивы acme_client, указав идентификатор ключа и MAC-ключ, предоставленные CA; точный синтаксис приведен в описании директивы.

Клиент ACME также использует отдельный ключ (private.key) для запросов на подпись сертификата (CSR); если нужно, этот ключ сертификата также создается автоматически при запуске.

При запуске клиент запрашивает сертификат, если его еще нет, подписывая и отправляя CSR для всех доменов, которыми он управляет, серверу CA. Сервер проверяет владение доменом путем HTTP- или DNS-проверки и выдает сертификат, который клиент сохраняет локально (certificate.pem).

Как сказано выше, сертификат будет единым для всех доменных имен, для которых используется один и тот же ACME-клиент, то есть потенциально может быть мультидоменным. Список всех имен, для которых выдан сертификат, см. в разделе Subject Alternative Name (SAN) полученного сертификата. Проверить его можно в командной строке, например:

$ openssl x509 -in certificate.pem -noout -text | grep -A5 "Subject Alternative Name"

Когда приближается завершение срока действия сертификата или изменяется список доменов, клиент подписывает и отправляет еще один CSR на сервер CA. Сервер снова проверяет владение и выдает новый сертификат, который клиент устанавливает локально, заменяя предыдущий.

В конфигурации полученный сертификат и соответствующий ключ доступны через префиксные переменные $acme_cert_<имя> и $acme_cert_key_<имя>. Их значения — содержимое соответствующих файлов, которое следует использовать с директивами ssl_certificate и ssl_certificate_key, например:

server {

    listen 443 ssl;

    server_name example.com www.example.com;
    acme example;

    ssl_certificate $acme_cert_example;
    ssl_certificate_key $acme_cert_key_example;
}

Примечание

Клиент ACME определяет адрес сервера CA по его имени через resolver того же контекста, который по умолчанию обращается к DNS-серверам из /etc/resolv.conf; задавайте директиву явно, только если нужны другие серверы или параметры. На узле без поддержки IPv6 resolver может все равно отправлять AAAA-запросы (IPv6) для сервера CA и не суметь подключиться; отключите их параметром ipv6=off, например resolver conf ipv6=off;.

Сбор доменов и использование сертификата#

Директива acme служит только для сбора доменных имен для запросов сертификатов. Она не определяет, где можно использовать сертификат: любой блок server может ссылаться на полученный сертификат через переменную $acme_cert_<имя>, независимо от того, содержит ли блок директиву acme.

Например, если у вас есть блок server с маской, который уже охватывает все поддомены, дополнительные блоки server для конкретных поддоменов не нуждаются в директиве acme:

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory
        challenge=dns;

    # В этом блоке перечислены домены для запроса сертификата
    server {

        listen 443 ssl;

        server_name example.com *.example.com;
        acme example;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }

    # Этот блок использует тот же сертификат, но не добавляет
    # свой server_name в запрос на сертификат
    server {

        listen 443 ssl;

        server_name app.example.com;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }
}

Явный список доменов#

Чтобы точно контролировать набор доменных имен в сертификате, не полагаясь на автоматический сбор из всех блоков server, создайте отдельный блок server, содержащий только директивы server_name и acme. Чтобы этот блок не обрабатывал реальный трафик, привяжите его к Unix-сокету:

# Отдельный блок, определяющий список доменов сертификата
server {

    listen unix:/tmp/acme_example.sock;

    server_name example.com www.example.com;
    acme example;
}

Другие блоки server могут затем использовать сертификат через переменную $acme_cert_<имя>, не влияя на то, какие домены запрашиваются.

Отдельные сертификаты для разных доменов#

Каждый acme_client управляет одним сертификатом. Чтобы получить несколько независимых сертификатов (например, для несвязанных доменов, которым не следует использовать общий сертификат), настройте отдельный acme_client для каждого из них в блоке http и укажите в директиве acme каждого блока server соответствующий клиент по имени:

http {

    # Два независимых клиента, каждый управляет своим сертификатом
    acme_client shop https://acme-v02.api.letsencrypt.org/directory
        challenge=http;

    acme_client blog https://acme-v02.api.letsencrypt.org/directory
        challenge=http;

    server {

        listen 443 ssl;

        server_name shop.example.com www.shop.example.com;
        acme shop;

        ssl_certificate $acme_cert_shop;
        ssl_certificate_key $acme_cert_key_shop;
    }

    server {

        listen 443 ssl;

        server_name blog.example.com;
        acme blog;

        ssl_certificate $acme_cert_blog;
        ssl_certificate_key $acme_cert_key_blog;
    }
}

HTTP-проверка#

Проверка выполняется автоматически. Когда Angie заказывает сертификат, ACME-сервер запрашивает по HTTP файл-токен по адресу /.well-known/acme-challenge/<TOKEN>. Angie отвечает на такие запросы сам, поэтому отдавать этот путь вручную не требуется. Получив файл, ACME-сервер сверяет токен с выданным значением и, если они совпадают, засчитывает проверку домена.

Пример конфигурации#

Здесь ACME-клиент с именем example управляет сертификатами для example.com и www.example.com (учтите, что wildcard-сертификаты не поддерживаются при HTTP-проверке):

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory;

    server {

        listen 80; # Не обязательно: если порт HTTP-проверки
                   # никто не слушает, модуль откроет его сам
                   # (см. директиву 'acme_http_port')

        listen 443 ssl;

        server_name example.com www.example.com;
        acme example;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }
}

Как уже отмечалось, порт 80 должен быть открыт для приема вызовов ACME по HTTP. Если ни один сервер не слушает порт HTTP-проверки, модуль создает отдельный слушающий сокет на порту 80 (или указанном в acme_http_port). Отдельный блок server не требуется.

DNS-проверка#

Проверка выполняется автоматически, но требует настройки DNS с вашей стороны. Когда Angie заказывает сертификат, ACME-сервер отправляет DNS-запрос TXT-записи в поддомене _acme-challenge. проверяемого домена. Angie отвечает на такие запросы сам: он выступает авторитетным сервером имен для этого поддомена и возвращает TXT-запись с тем значением, которого ждет ACME-сервер. ACME-сервер сверяет полученную запись с выданным значением и, если они совпадают, засчитывает проверку домена.

Чтобы запрос дошел до Angie, DNS вашего домена должен делегировать поддомен _acme-challenge. серверу Angie; нужные записи приведены ниже. По умолчанию Angie отвечает с очень небольшим TTL, а некоторые DNS-провайдеры отфильтровывают ответы с таким низким TTL — если проверка не завершается, увеличьте его директивой acme_dns_ttl.

DNS-проверка подтверждает контроль над доменным именем, поэтому не может проверять идентификаторы в виде IP-адресов: для клиента с challenge=dns Angie игнорирует IP-адрес в server_name и выводит предупреждение при запуске. Блок server, в котором указан IP-адрес, должен по-прежнему содержать хотя бы одно доменное имя, которое клиент может проверить.

Примечание

Сервер Angie должен быть доступен из интернета на UDP-порту 53 (или указанном в acme_dns_port). Если сервер находится за межсетевым экраном, убедитесь, что этот порт открыт для входящих соединений.

Например, чтобы подтвердить домен example.com, используя сервер Angie с IP-адресом 203.0.113.10, DNS-конфигурация вашего домена должна включать следующие записи:

_acme-challenge.example.com. 60    IN      NS       ns.example.com.
             ns.example.com. 60    IN       A       203.0.113.10

Эта конфигурация делегирует разрешение DNS для _acme-challenge.example.com на ns.example.com, обеспечивая доступность ns.example.com путем сопоставления с IP-адресом (203.0.113.10).

Предупреждение

Распространение изменений NS-записей может занимать от нескольких минут до 48 часов в зависимости от TTL и DNS-провайдера. Рекомендуется проверить корректность настройки перед запросом сертификата.

Для проверки корректности настройки DNS можно использовать следующие команды:

$ dig NS _acme-challenge.example.com +short  # Проверка NS-записи для поддомена _acme-challenge

  ns.example.com.

$ dig A ns.example.com +short  # Проверка A-записи для сервера имен

  203.0.113.10

$ nc -zv 203.0.113.10 53  # Проверка доступности DNS-сервера на порту 53

Этот способ позволяет запрашивать wildcard-сертификаты, например сертификат, включающий запись *.example.com в разделе Subject Alternative Name (SAN). Чтобы в явной форме запросить сертификат для поддомена, например www.example.com, следует отдельно подтвердить этот поддомен описанным выше способом.

Предупреждение

Применимость данного сценария во многом зависит от возможностей, предоставляемых вашим DNS-провайдером; некоторые провайдеры не позволяют выполнять такие настройки.

Пример конфигурации#

В целом, конфигурация схожа с примером из предыдущего раздела. Нет необходимости в настройках, специфичных для HTTP; вместо этого достаточно установить challenge=dns для директивы acme_client.

Здесь ACME-клиент с именем example управляет сертификатами для example.com и *.example.com:

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory
        challenge=dns;

    server {

        server_name example.com *.example.com;
        acme example;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }
}

ALPN-проверка#

Проверка работает автоматически. ACME‑сервер устанавливает TLS‑соединение и запрашивает через ALPN протокол acme-tls/1. Модуль отдает временный сертификат для валидационного запроса.

Чтобы использовать этот способ, задайте challenge=alpn в директиве acme_client и убедитесь, что ваш TLS‑слушатель доступен на порту 443 (или на используемом TLS‑порту).

Пример конфигурации#

Конфигурация аналогична предыдущим разделам; достаточно задать challenge=alpn для директивы acme_client и убедиться, что TLS-сервер доступен на порту 443.

Здесь ACME-клиент с именем example управляет сертификатом для example.com и www.example.com:

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory
        challenge=alpn;

    server {

        listen 443 ssl;

        server_name example.com www.example.com;
        acme example;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }
}

Проверка с помощью хуков#

В отличие от предыдущих способов, эта проверка требует дополнительных усилий. Здесь ACME-сервер производит обычную HTTP-проверку или DNS-проверку, но обращается не к самому серверу Angie, а к внешнему сервису, которым сервер Angie управляет с помощью вызовов-хуков (acme_hook). В свою очередь, этот сервис настраивает отдельный DNS- или HTTP-сервер, куда и направляются запросы ACME-сервера.

Получив ожидаемый ответ от настроенного таким образом DNS- или HTTP-сервера, ACME-сервер подтверждает, что домен принадлежит клиенту.

Когда для выпуска или обновления сертификата требуется проверка домена, Angie формирует внутренний запрос к именованному location, содержащему директиву acme_hook. Способ обработки этого запроса полностью зависит от других директив, заданных в том же location.

Общая схема такова:

  1. Создайте именованный location с директивой acme_hook.

  2. Настройте обработчик запроса в том же location с помощью модуля, подходящего для вашей конфигурации: fastcgi_pass для FastCGI, proxy_pass для HTTP, cgi_pass для CGI-скриптов и т. д.

  3. Передайте обработчику переменные ACME с помощью поддерживаемого им механизма, например, fastcgi_param для FastCGI или cgi_set_var для CGI.

Обработчик должен возвращать код состояния 2xx, который можно передать через заголовок Status. Любой другой код от хука add прерывает попытку обновления сертификата; от хука remove тот же код только записывается в журнал как предупреждение, и обновление продолжается. Вывод обработчика игнорируется.

Минимальная конфигурация#

Независимо от используемого обработчика, location для хука имеет следующую структуру:

location @acme_hook_location {

    acme_hook example;

    # Директива обработчика (fastcgi_pass, proxy_pass, cgi_pass, ...)
    # Передайте переменные ACME с помощью механизма обработчика:
    #   ACME_HOOK       — $acme_hook_name ("add" или "remove")
    #   ACME_CHALLENGE  — $acme_hook_challenge ("dns" или "http")
    #   ACME_DOMAIN     — $acme_hook_domain
    #   ACME_TOKEN      — $acme_hook_token
    #   ACME_KEYAUTH    — $acme_hook_keyauth
}

При DNS-проверке обработчик должен использовать ACME_HOOK для определения действия: если значение add, создать TXT-запись для _acme-challenge.ACME_DOMAIN со значением из ACME_KEYAUTH; если значение remove, удалить эту запись.

Пример с FastCGI#

Здесь настраивается ACME-клиент example для подтверждения домена при помощи DNS-вызова, на что указывает параметр challenge=dns директивы acme_client.

Блок server применяется ко всем поддоменам example.com (например, *.example.com) и использует ACME-клиент example для управления сертификатами, что указано в директиве acme.

Именованный блок location обрабатывает вызовы хуков. Директива acme_hook связывает его с ACME-клиентом example. Запросы хуков отправляются на локальный FastCGI-сервер на порту 9000 с помощью fastcgi_pass. Директивы fastcgi_param передают переменные ACME во внешний сервис.

acme_client example https://acme-v02.api.letsencrypt.org/directory
    challenge=dns;

server {

    listen 80;

    server_name *.example.com;

    acme example;

    ssl_certificate $acme_cert_example;
    ssl_certificate_key $acme_cert_key_example;

    location @acme_hook_location {

        acme_hook example;

        fastcgi_pass localhost:9000;

        fastcgi_param ACME_CLIENT $acme_hook_client;
        fastcgi_param ACME_HOOK $acme_hook_name;
        fastcgi_param ACME_CHALLENGE $acme_hook_challenge;
        fastcgi_param ACME_DOMAIN $acme_hook_domain;
        fastcgi_param ACME_TOKEN $acme_hook_token;
        fastcgi_param ACME_KEYAUTH $acme_hook_keyauth;

        include fastcgi.conf;
    }
}

Пример соответствующего внешнего FastCGI-сервиса на Perl:

#!/usr/bin/perl

use strict; use warnings;

use FCGI;

my $socket = FCGI::OpenSocket(":9000", 5);
my $request = FCGI::Request(\*STDIN, \*STDOUT, \*STDERR, \%ENV, $socket);

while ($request->Accept() >= 0) {
    print "\r\n";

    my $client =    $ENV{ACME_CLIENT};
    my $hook =      $ENV{ACME_HOOK};
    my $challenge = $ENV{ACME_CHALLENGE};
    my $domain =    $ENV{ACME_DOMAIN};
    my $token =     $ENV{ACME_TOKEN};
    my $keyauth =   $ENV{ACME_KEYAUTH};

    if ($hook eq 'add') {

        DNS_set_TXT_record("_acme-challenge.$domain.", $keyauth);

    } elsif ($hook eq 'remove') {

        DNS_clear_TXT_record("_acme-challenge.$domain.");
    }
};

FCGI::CloseSocket($socket);

Здесь DNS_set_TXT_record() и DNS_clear_TXT_record() — функции, предположительно добавляющие и удаляющие TXT-записи в конфигурации некого внешнего DNS-сервера, к которому и обратится ACME-сервер. В этих записях должны содержаться переданные сервером Angie данные, что позволит внешнему DNS-серверу успешно пройти проверку, аналогичную описанной в разделе DNS-проверка. Подробности реализации таких функций выходят за рамки этого руководства; так, например, передавать параметры можно и через URI запроса:

# ...

location @acme_hook_location {

    acme_hook example uri=/acme_hook/$acme_hook_name?domain=$acme_hook_domain&key=$acme_hook_keyauth;

    fastcgi_pass localhost:9000;

    fastcgi_param REQUEST_URI $request_uri;
    fastcgi_param ACME_CLIENT $acme_hook_client;
    fastcgi_param ACME_CHALLENGE $acme_hook_challenge;
    fastcgi_param ACME_TOKEN $acme_hook_token;

    include fastcgi.conf;
}

Пример с PHP-FPM#

Еще один пример, с использованием PHP-FPM:

location @acme_hook_location {

    acme_hook example;
    root /var/www/dns;
    fastcgi_pass unix:/run/php-fpm/php-dns.sock;
    fastcgi_index hook.php;
    fastcgi_param SCRIPT_FILENAME /var/www/dns/hook.php;
    include fastcgi_params;

    fastcgi_param ACME_CLIENT $acme_hook_client;
    fastcgi_param ACME_HOOK $acme_hook_name;
    fastcgi_param ACME_CHALLENGE $acme_hook_challenge;
    fastcgi_param ACME_DOMAIN $acme_hook_domain;
    fastcgi_param ACME_TOKEN $acme_hook_token;
    fastcgi_param ACME_KEYAUTH $acme_hook_keyauth;
}
[dns]
listen = /run/php-fpm/php-dns.sock
listen.mode = 0666
user = angie
group = angie
chdir = /var/www/dns
# ...

Переданные параметры доступны в PHP через $_SERVER['...'].

Пример с CGI и octoDNS#

Здесь хук — это скрипт, запускаемый модулем CGI; скрипт добавляет и удаляет TXT-запись _acme-challenge через octoDNS, инструмент для описания DNS как кода: зона задается в YAML и синхронизируется с DNS-провайдером. В примере используется Cloudflare, но точно так же подойдет любой другой провайдер octoDNS, если объявить его в providers и указать в targets. У API-токена должны быть права Zone:Read, DNS:Read и DNS:Edit для этой зоны.

Модуль подключается в контексте main. ACME-клиент example заказывает один сертификат для example.com и *.example.com, подтверждая домен при помощи DNS-вызовов, на что указывает параметр challenge=dns директивы acme_client. Вызовы хуков обрабатывает именованный блок location: директива acme_hook связывает его с клиентом, директивы cgi_set_var передают переменные ACME под стандартными именами, а cgi_pass запускает скрипт при каждом вызове. Из этих переменных скрипт использует ACME_CHALLENGE, ACME_HOOK, ACME_DOMAIN и ACME_KEYAUTH.

# в контексте main
load_module modules/ngx_http_cgi_module.so;

acme_client example https://acme-v02.api.letsencrypt.org/directory
    challenge=dns;

server {

    listen 443 ssl;

    server_name example.com *.example.com;

    acme example;

    ssl_certificate $acme_cert_example;
    ssl_certificate_key $acme_cert_key_example;

    location @acme_hook_location {

        acme_hook example;

        cgi_set_var ACME_CLIENT $acme_hook_client;
        cgi_set_var ACME_HOOK $acme_hook_name;
        cgi_set_var ACME_CHALLENGE $acme_hook_challenge;
        cgi_set_var ACME_DOMAIN $acme_hook_domain;
        cgi_set_var ACME_TOKEN $acme_hook_token;
        cgi_set_var ACME_KEYAUTH $acme_hook_keyauth;

        cgi_pass /usr/share/angie/cgi-bin/acme-octodns;
    }
}

octoDNS собирает зону из источников по порядку и объединяет их, поэтому хук хранит проверочную запись в отдельном каталоге-источнике /etc/octodns/acme, и обновление сертификата никогда не переписывает файл зоны в /etc/octodns/zones. Параметр ignore_missing_zones (octoDNS 1.16 и новее) разрешает каталогу /etc/octodns/acme оставаться пустым между обновлениями. Сам файл зоны не должен содержать записей _acme-challenge.

/etc/octodns/octodns.yaml#
providers:
  config:
    class: octodns.provider.yaml.YamlProvider
    directory: /etc/octodns/zones
  acme:
    class: octodns.provider.yaml.YamlProvider
    directory: /etc/octodns/acme
    ignore_missing_zones: true
  cloudflare:
    class: octodns_cloudflare.CloudflareProvider
    token: YOUR_API_TOKEN

zones:
  example.com.:
    sources:
      - config
      - acme
    targets:
      - cloudflare

В файле зоны находятся записи, которые вы ведете сами:

/etc/octodns/zones/example.com.yaml#
'':
  type: A
  value: 203.0.113.10
www:
  type: A
  value: 203.0.113.10

Чтобы проверить конфигурацию octoDNS вручную, запустите octodns-sync без --doit.

Скрипт читает переменные ACME из своего окружения и записывает проверочную запись в источник acme:

Скрипт acme-octodns
/usr/share/angie/cgi-bin/acme-octodns#
#!/opt/octodns/bin/python3
# Хук DNS-01 для ACME-клиента Angie: хранит TXT-запись _acme-challenge
# в отдельном каталоге-источнике octodns и публикует ее через octodns-sync.

import fcntl
import os
import subprocess
import sys
import time

import dns.message
import dns.query
import dns.resolver
import yaml

CONFIG = "/etc/octodns/octodns.yaml"
ACME_DIR = "/etc/octodns/acme"
SYNC = "/opt/octodns/bin/octodns-sync"
TTL = 120
WAIT = int(os.environ.get("ACME_WAIT", "300"))


def respond(code, message=""):
    if message:
        print(message, file=sys.stderr)
    sys.stdout.write(f"Status: {code}\r\n\r\n")
    sys.exit(0)


def find_zone(domain):
    with open(CONFIG) as f:
        zones = [z.rstrip(".") for z in yaml.safe_load(f)["zones"]]
    match = [z for z in zones if domain == z or domain.endswith("." + z)]
    if not match:
        respond(500, f"no zone for {domain} in {CONFIG}")
    return max(match, key=len)


def update(zone, name, value, add):
    # В одной записи может быть несколько значений: сертификат для example.com
    # и *.example.com проверяется двумя TXT-значениями с одним именем.
    path = f"{ACME_DIR}/{zone}.yaml"
    with open(f"{ACME_DIR}/.lock", "w") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        data = {}
        if os.path.exists(path):
            with open(path) as f:
                data = yaml.safe_load(f) or {}
        values = set(data.get(name, {}).get("values", []))
        (values.add if add else values.discard)(value)
        if values:
            data[name] = {"type": "TXT", "ttl": TTL, "values": sorted(values)}
        else:
            data.pop(name, None)
        if data:
            with open(path, "w") as f:
                yaml.safe_dump(data, f)
        elif os.path.exists(path):
            os.remove(path)
        cmd = [SYNC, f"--config-file={CONFIG}", "--doit", zone + "."]
        run = subprocess.run(cmd, capture_output=True, text=True)
        if run.returncode != 0:
            respond(500, "octodns-sync failed:\n" + run.stdout + run.stderr)


def has_txt(server, fqdn, value):
    try:
        reply = dns.query.udp(dns.message.make_query(fqdn, "TXT"), server, timeout=3)
    except Exception:
        return False
    return any(value == b"".join(getattr(r, "strings", ())).decode()
               for rrset in reply.answer for r in rrset)


def wait_visible(zone, fqdn, value):
    # ACME-сервер обращается к авторитетным серверам зоны, поэтому опрашиваем
    # их напрямую: резолвер мог закэшировать отсутствие записи.
    deadline = time.monotonic() + WAIT
    while time.monotonic() < deadline:
        try:
            servers = [str(a) for ns in dns.resolver.resolve(zone, "NS")
                       for a in dns.resolver.resolve(str(ns.target), "A")]
            if servers and all(has_txt(s, fqdn, value) for s in servers):
                return True
        except Exception:
            pass
        time.sleep(5)
    return False


def main():
    hook = os.environ.get("ACME_HOOK")
    domain = os.environ.get("ACME_DOMAIN", "")
    value = os.environ.get("ACME_KEYAUTH", "")
    if os.environ.get("ACME_CHALLENGE") != "dns" or hook not in ("add", "remove"):
        respond(500, "the hook supports only DNS validation")
    zone = find_zone(domain)
    rel = domain[: -len(zone) - 1]
    name = "_acme-challenge" + (f".{rel}" if rel else "")
    update(zone, name, value, hook == "add")
    if hook == "add" and WAIT and not wait_visible(zone, f"{name}.{zone}.", value):
        update(zone, name, value, False)
        respond(500, f"{name}.{zone} is not visible on the name servers after {WAIT}s")
    respond(200)


try:
    main()
except Exception as e:
    respond(500, f"{type(e).__name__}: {e}")

Как только хук add вернет код 2xx, Angie попросит ACME-сервер выполнить проверку и будет опрашивать результат в течение 60 секунд. Распространения записи в DNS Angie не дожидается, поэтому ждать приходится самому скрипту. Он опрашивает авторитетные серверы имен зоны напрямую, поскольку резолвер мог закэшировать отсутствие записи, и завершается, как только значение отдадут все серверы; по умолчанию он ждет не дольше 300 секунд.

Если значение не появится вовремя, скрипт отвечает Status: 500, Angie прерывает попытку и повторяет ее через интервал, заданный параметром retry_after_error. После неудачного add Angie не вызывает хук remove, поэтому скрипт сам удаляет свое значение, прежде чем ответить.

Предел ожидания задается переменной окружения ACME_WAIT. В показанном выше location для хука она не задается; чтобы изменить предел, добавьте ее туда, передав значение через переменную, как и остальные:

set $acme_wait 600;
cgi_set_var ACME_WAIT $acme_wait;

Значение 0 отключает ожидание — это подходит для собственных авторитетных серверов, на которых изменение через API видно сразу.

Переменная $acme_hook_domain приходит без префикса *., поэтому сертификат для example.com и *.example.com порождает два вызова add для одного и того же имени _acme-challenge.example.com. Скрипт хранит список значений в одной записи, а при remove удаляет только значение из этого вызова; когда значений не остается, он удаляет и саму запись, и хранивший ее файл в /etc/octodns/acme.

Скрипт выполняется от имени пользователя рабочих процессов Angie — angie. Ему нужен доступ на чтение к конфигурации octoDNS, где хранится токен, и на запись в /etc/octodns/acme; больше ничего. CGI-процесс не наследует окружение Angie, поэтому токен хранится в файле конфигурации, а скрипт использует абсолютные пути.

Стандартный поток ошибок скрипта попадает в журнал ошибок Angie на уровне warn — именно там разбирают сбои хуков; эти сообщения видны только при уровне warn или менее серьезном.

Чтобы все это настроить в системе на основе Debian:

  1. Установите модуль CGI:

    $ sudo apt-get install angie-module-cgi
    
  2. Установите octoDNS и провайдер Cloudflare в отдельное виртуальное окружение:

    $ sudo apt-get install python3-venv
    $ sudo python3 -m venv /opt/octodns
    $ sudo /opt/octodns/bin/pip install 'octodns>=1.16' octodns-cloudflare
    
  3. Создайте каталоги; пакет модуля не создает /usr/share/angie/cgi-bin:

    $ sudo install -d /etc/octodns/zones /usr/share/angie/cgi-bin
    $ sudo install -d -o angie -g angie -m 750 /etc/octodns/acme
    
  4. Создайте /etc/octodns/octodns.yaml и /etc/octodns/zones/example.com.yaml, как показано выше, и ограничьте доступ к конфигурации группой angie, поскольку в ней хранится API-токен:

    $ sudo chgrp angie /etc/octodns/octodns.yaml
    $ sudo chmod 640 /etc/octodns/octodns.yaml
    
  5. Сохраните показанный выше скрипт под именем acme-octodns и установите его:

    $ sudo install -m 755 acme-octodns /usr/share/angie/cgi-bin/
    
  6. Добавьте показанную выше конфигурацию, проверьте ее и перезагрузите Angie:

    $ sudo angie -t
    $ sudo kill -HUP $(cat /run/angie.pid)
    

    Сразу после перезагрузки клиент запрашивает сертификат; его состояние и статус сертификата видны в разделе API /status/http/acme_clients/, а полученный сертификат сохраняется в каталоге хранения клиента.

ACME в потоковом модуле#

Потоковый модуль ACME позволяет автоматизировать выпуск и использование сертификатов для TCP-трафика. Для его корректной работы необходимо сначала настроить HTTP-аналог: ACME-клиент должен быть объявлен в контексте http, а сам блок stream должен располагаться после блока http в конфигурации.

Пример конфигурации#

По умолчанию для получения сертификатов используется режим HTTP-проверки. Для него, как упоминалось в разделе HTTP-проверка, нужен HTTP-сервер, слушающий на порту 80:

# HTTP-часть
http {

    # ACME-клиент для потоковой части
    acme_client example https://acme-v02.api.letsencrypt.org/directory;

    # Сервер для HTTP-проверки
    server {

        listen 80;
        return 444;
    }
}

# Потоковая часть
stream {

    server {

        listen 12345 ssl;
        proxy_pass backend_upstream;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;

        server_name example.com www.example.com;
        acme example; # ссылка на ACME-клиент, определенный в HTTP-части
    }

    upstream backend_upstream {

        server 127.0.0.1:54321;
    }
}

Также можно использовать DNS-проверку, настроив challenge=dns в директиве acme_client; тогда сервер будет не нужен.

Миграция с certbot#

Если до перехода с nginx на Angie вы использовали certbot для получения и продления SSL-сертификатов от центра сертификации Let's Encrypt, выполните следующие шаги, чтобы перейти к использованию нашего модуля ACME.

Предположим, вы настроили сертификаты следующим образом:

$ sudo certbot --nginx -d example.com -d www.example.com

Автоматически созданная при этом конфигурация обычно находится в файле /etc/nginx/sites-available/example.conf и выглядит приблизительно так:

server {

    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {

    listen 443 ssl;
    server_name example.com www.example.com;

    root /var/www/example;
    index index.html;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

В примере выше выделены строки, которые потребуется изменить. В зависимости от ваших обстоятельств и предпочтений настройте HTTP-проверку или DNS-проверку с помощью модуля ACME.

Итоговая конфигурация Angie может выглядеть приблизительно так:

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory;

    server {

        listen 80;
        server_name example.com www.example.com;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        server_name example.com www.example.com;

        root /var/www/example;
        index index.html;

        acme                 example;

        ssl_certificate      $acme_cert_example;
        ssl_certificate_key  $acme_cert_key_example;
    }
}

Не забудьте перезагрузить конфигурацию после изменения:

$ sudo kill -HUP $(cat /run/angie.pid)

Убедившись, что эта конфигурация работает, вы можете удалить сертификаты certbot, а также отключить или целиком удалить его с сервера, если он больше нигде не используется, например:

$ sudo rm -rf /etc/letsencrypt

$ sudo systemctl stop certbot.timer
$ sudo systemctl disable certbot.timer
$ # -- или --
$ sudo rm /etc/cron.d/certbot

$ sudo apt remove certbot
$ # -- или --
$ sudo dnf remove certbot