ACME 配置#

Angie 中的 ACME 模块使用 ACME 协议 实现自动证书获取。ACME 协议支持 多种域名验证方法(也称为"验证");本模块实现了 HTTP 验证、 DNS 验证、ALPN 验证 以及通过自定义外部服务进行的 基于钩子的验证。

配置步骤#

在配置中启用证书请求的常规步骤:

  • 配置 ACME 客户端:在 http 块中使用 acme_client 指令, 指定唯一的客户端名称和其他参数。可以配置多个 ACME 客户端。

  • 指定请求证书的域名:将为所有 server 块中 server_name 指令列出的所有域名颁发单个证书,这些块使用指向同一 ACME 客户端的 acme 指令。

    在 server_name 中指定的 IP 地址(IPv4 或 IPv6)同样是有效的证书标识符,但 DNS 验证 方式除外。

    请注意,公共证书颁发机构可能仅在特定证书配置文件(profile)下才会为 IP 地址 签发证书;例如,Let's Encrypt 目前要求使用支持 IP 标识符的配置文件(参见其 配置文件文档)。如需申请此类 配置文件,请设置 acme_client 的 profile= 参数。

  • 设置请求处理和 ACME 回调:这是验证域名所有权所必需的。设置取决于所选的域名验证方法:

    方法

    用户要求

    多域名

    通配符域名

    HTTP 验证

    在 Angie 服务器上开放端口 80(或 acme_http_port 中指定的端口) 以接受传入连接。

    ✔

    DNS 验证

    在 Angie 服务器上开放端口 53(或 acme_dns_port 中指定的端口) 以接受传入连接。

    为 _acme-challenge. 子域设置 NS 记录, 指向您的 Angie 服务器。

    ✔

    ✔

    ALPN 验证

    在 Angie 服务器上开放端口 443(或您的服务器使用的 TLS 端口) 以接受传入连接。

    ✔

    钩子验证

    创建一个外部服务(脚本或应用程序), 可以根据 Angie 的请求更新 DNS 记录 或通过 Web 服务器提供特殊响应。

    ✔

    ✔

  • 使用获取的证书和密钥配置 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 发送不必要的请求——CA 可能受 频率限制 约束。 在容器中使用时,请将存储目录(默认为 /var/lib/angie/acme/)挂载到持久卷上, 以确保已签发的证书在容器重建后仍然存在;请参阅 在容器中运行 Angie。

备注

重新加载配置(angie -s reload)会针对磁盘上存储的证书 重新评估每个 ACME 客户端。证书当前无效的客户端—— 包括先前请求以错误结束的客户端——会立即再次发起请求, 而不受 retry_after_error 的影响。 重试延迟(以及 retry_after_error=off)仅在 Angie 持续运行期间生效;它不会在重新加载后保留。因此, 在请求持续失败时反复重新加载可能会向 CA 频繁发送请求, 并受上文所述的频率限制约束。

ACME 客户端需要在 CA 服务器上拥有一个账户。为了创建和管理此账户, 客户端使用私钥(account.key)。如果不存在密钥,则在启动时生成。 然后客户端使用此密钥向服务器注册账户。

备注

如果您已经拥有账户密钥,请在启动前将其放置在客户端的子目录中以重用该账户。 或者,使用 acme_client 中的 account_key 参数指定密钥文件。

部分 CA 要求在签发证书之前,先通过 External Account Binding (EAB)将 ACME 账户与已有的外部账户绑定。要启用此功能,请在 acme_client 中 配置 eab 参数,填入 CA 提供的密钥标识符和 MAC 密钥;具体语法请参见该指令 的说明。

ACME 客户端还使用单独的密钥(private.key)用于证书签名请求(CSR)。 如果需要,此证书密钥会在启动时自动创建。

启动时,如果证书不存在,客户端会请求证书,签名并向 CA 服务器发送包含其管理的所有域名的 CSR。 服务器使用 HTTP 或 DNS 验证 验证域名所有权, 并颁发证书,客户端将其保存在本地(certificate.pem)。

如前所述,单个证书涵盖同一 ACME 客户端管理的所有域名,可能产生多域名证书。 证书涵盖的所有名称列表可以在获取的证书的 Subject Alternative Name (SAN)部分中找到。 要从命令行检查:

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

当证书即将过期或域名列表发生变化时,客户端会签名并向 CA 服务器发送另一个 CSR。 服务器重新验证所有权并颁发新证书,客户端将其安装在本地,替换之前的证书。

在 配置 中,获取的证书及其对应的密钥可通过前缀变量 $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 客户端通过同一上下文中的 resolver 解析 CA 服务器的主机名, 该解析器默认查询 /etc/resolv.conf 中列出的 DNS 服务器;仅当需要使用其他 服务器或参数时,才需要显式配置该指令。在没有 IPv6 连接的主机上,解析器仍可能向 CA 发起 AAAA(IPv6)查询并导致连接失败;可通过 ipv6=off 参数禁用此行为, 例如 resolver conf ipv6=off;。

域名收集与证书使用#

acme 指令仅用于收集证书请求的域名, 并不控制证书可在何处使用: 任何 server 块都可以通过 $acme_cert_<name> 变量 引用获取的证书, 无论该块是否包含 acme 指令。

例如,如果您有一个已涵盖所有子域名的通配符 server 块, 那么针对特定子域名的额外 server 块 不需要 acme 指令:

http {

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

    # This block lists the domains for the certificate request
    server {

        listen 443 ssl;

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

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }

    # This block uses the same certificate but does not
    # add its server_name to the certificate request
    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 域套接字:

# Dedicated block that defines the certificate's domain list
server {

    listen unix:/tmp/acme_example.sock;

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

其他 server 块随后可通过 $acme_cert_<name> 变量 使用该证书,而不会影响请求的域名。

为不同域名申请独立证书#

每个 acme_client 只管理一张证书。若要获取多张独立证书 (例如,用于不应共享同一证书的不相关域名), 请在 http 块中为每个域名单独配置一个 acme_client, 并在每个 server 块的 acme 指令中按名称指向相应的客户端:

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 服务器会向 Angie 的 /.well-known/acme-challenge/<TOKEN> 路径 发送 HTTP 请求以获取令牌文件。 Angie 会自行处理这些请求,因此您无需自己提供该路径。随后,ACME 服务器会将收到的令牌 与其签发的令牌进行比对,若二者匹配,则确认该域名归您所有。

配置示例#

在此示例中,名为 example 的 ACME 客户端管理 example.com 和 www.example.com 的证书(注意 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 以处理 HTTP ACME 调用。 如果没有配置服务器监听 HTTP 挑战端口, 该模块会在端口 80(或 acme_http_port 中设置的端口)上创建专用监听器。不需要单独的 server 块。

DNS 验证#

验证是自动完成的,但需要您在自己一侧配置 DNS 记录。当 Angie 申请证书时,ACME 服务器 会针对被验证域名的 _acme-challenge. 子域发送一次 DNS 查询,以获取 一条 TXT 记录。Angie 会自行应答这些查询:它充当该子域的权威名称服务器,并返回 ACME 服务器所期望的 TXT 记录。随后,ACME 服务器会将收到的记录与其签发的值进行比对,若二者 匹配,则确认该域名归您所有。

为使该查询能够到达 Angie,您的 DNS 必须将 _acme-challenge. 子域委派给 Angie 服务器,如下所示。Angie 默认使用非常短的 TTL 进行应答;部分 DNS 提供商会过滤掉 TTL 过低的响应,如果验证始终无法完成,请通过 acme_dns_ttl 调高该值。

DNS 验证证明的是对域名的控制权,因此它无法验证 IP 地址标识符:对于配置了 challenge=dns 的客户端,Angie 会忽略 server_name 中的 IP 地址,并在 启动时记录一条警告。列出了 IP 地址的 server 块仍必须至少指定一个该客户端 可以验证的域名。

备注

Angie 服务器必须能够通过互联网在 UDP 53 端口(或 acme_dns_port 中指定的端口)上访问。 如果服务器位于防火墙后, 请确保该端口对入站连接开放。

例如,要使用 IP 地址为 203.0.113.10 的 Angie 服务器验证域名 example.com,您的域名 DNS 配置应包含以下记录:

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

此配置将 _acme-challenge.example.com 的 DNS 解析委托给 ns.example.com,并通过将 ns.example.com 映射到 IP 地址(203.0.113.10)来确保其可访问。

警告

NS 记录的传播可能需要几分钟到 48 小时不等,具体取决于 TTL 和 DNS 提供商。建议在请求证书之前验证配置是否正确。

要验证 DNS 是否配置正确,您可以使用以下命令:

$ dig NS _acme-challenge.example.com +short  # 检查 _acme-challenge 子域的 NS 记录

  ns.example.com.

$ dig A ns.example.com +short  # 检查名称服务器的 A 记录

  203.0.113.10

$ nc -zv 203.0.113.10 53  # 检查 DNS 服务器在端口 53 上的可访问性

此方法允许请求通配符证书,例如,在 Subject Alternative Name (SAN)部分包含 *.example.com 条目的证书。要明确请求子域的证书,例如 www.example.com,您必须使用上述方法单独验证该子域。

警告

此场景的适用性在很大程度上取决于您的 DNS 提供商提供的功能;某些提供商不允许此类配置。

配置示例#

总体而言,配置与上一节中的示例类似。不需要 HTTP 特定的设置;只需为 acme_client 指令设置 challenge=dns 即可。

在此示例中,名为 example 的 ACME 客户端管理 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 协议。该模块为验证请求提供临时证书。

要启用此方法,请在 acme_client 指令中配置 challenge=alpn,并确保您的 TLS 监听器在端口 443(或用于 TLS 的端口)上可访问。

配置示例#

配置与前几节类似;只需为 acme_client 指令设置 challenge=alpn,并确保 TLS 服务器在端口 443 上可访问即可。

在此示例中,名为 example 的 ACME 客户端管理 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 服务器交互,而是通过钩子调用(acme_hook)与 Angie 服务器管理的外部服务通信。该服务配置一个单独的 DNS 或 HTTP 服务器,ACME 服务器向其发送请求。

一旦 ACME 服务器从配置的 DNS 或 HTTP 服务器收到预期的响应,它就会确认域名所有权。

当证书签发或续订需要进行域名验证时, Angie 会向包含 acme_hook 指令的命名 location 生成一个内部请求。 该请求的处理方式完全取决于同一 location 中配置的其他指令。

通用模式如下:

  1. 创建一个带有 acme_hook 指令的命名 location。

  2. 在同一 location 中, 使用适合您环境的模块配置请求处理程序: FastCGI 使用 fastcgi_pass, HTTP 使用 proxy_pass, CGI 脚本使用 cgi_pass,等等。

  3. 使用处理程序支持的机制, 将 ACME 变量 传递给处理程序, 例如 FastCGI 使用 fastcgi_param, CGI 使用 cgi_set_var。

处理程序必须返回 2xx 状态码, 可以通过 Status 头发送。 来自 add 钩子的任何其他状态码都会中止本次续订尝试;而来自 remove 钩子的同一状态码仅会被记录为警告,续订过程会继续进行。 处理程序的输出将被忽略。

最小配置#

无论使用哪种处理程序, 钩子 location 都遵循以下结构:

location @acme_hook_location {

    acme_hook example;

    # Handler directive (fastcgi_pass, proxy_pass, cgi_pass, ...)
    # Pass ACME variables using the handler's mechanism:
    #   ACME_HOOK       — $acme_hook_name ("add" or "remove")
    #   ACME_CHALLENGE  — $acme_hook_challenge ("dns" or "http")
    #   ACME_DOMAIN     — $acme_hook_domain
    #   ACME_TOKEN      — $acme_hook_token
    #   ACME_KEYAUTH    — $acme_hook_keyauth
}

对于 DNS 验证,处理程序必须使用 ACME_HOOK 来确定操作: 当其值为 add 时, 为 _acme-challenge.ACME_DOMAIN 创建一条 值为 ACME_KEYAUTH 的 TXT 记录; 当其值为 remove 时,删除该记录。

FastCGI 示例#

在此示例中, acme_client 指令通过 challenge=dns 参数 将 ACME 客户端 example 配置为使用 DNS 验证。

server 块适用于 example.com 的所有子域(例如 *.example.com), 并通过 acme 指令使用 ACME 客户端 example 来管理证书。

一个命名的 location 块负责处理钩子调用。 acme_hook 指令将其与 ACME 客户端 example 关联起来。 钩子请求通过 fastcgi_pass 发送到运行在 9000 端口上的本地 FastCGI 服务器。 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;
    }
}

以下 Perl 脚本演示了相应的外部 FastCGI 服务:

#!/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() 是假定在外部 DNS 服务器的配置中添加和删除 TXT 记录的函数,ACME 服务器会查询该 DNS 服务器。这些记录必须包含 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 模块运行的脚本;它通过 octoDNS 添加和删除 _acme-challenge TXT 记录,octoDNS 让 DNS 配置以代码形式管理:区域(zone)以 YAML 描述,并同步到 DNS 提供商。此处以 Cloudflare 为例;其他任何 octoDNS 提供商只需在 providers 下声明,并在 targets 中命名,工作方式都是相同的。该 API 令牌需要具备该区域的 Zone:Read、DNS:Read 和 DNS:Edit 权限。

该模块在 main 上下文中加载。ACME 客户端 example 为 example.com 和 *.example.com 请求单个证书,证书通过 acme_client 指令中 challenge=dns 参数所指定的 DNS 回调进行验证。 一个命名的 location 块处理钩子调用: acme_hook 指令将其与该客户端 关联, cgi_set_var 指令以标准名称传递 ACME 变量, cgi_pass 则为每次调用运行该脚本。在这些名称中,该脚本使用了 ACME_CHALLENGE、ACME_HOOK、ACME_DOMAIN 和 ACME_KEYAUTH。

# in the main context
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 按顺序从其各个源(source)构建区域并将其合并,因此钩子将验证记录保存在 自己独立的源目录 /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 配置,请在不带 --doit 的情况下运行 octodns-sync。

该脚本从环境变量中读取 ACME 变量, 并将验证记录写入 acme 源:

acme-octodns 脚本
/usr/share/angie/cgi-bin/acme-octodns#
#!/opt/octodns/bin/python3
# DNS-01 hook for the Angie ACME client: keeps the _acme-challenge TXT record
# in a separate octodns source directory and publishes it with 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):
    # One record may hold several values: a certificate for example.com and
    # *.example.com is validated with two TXT values on the same name.
    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):
    # The ACME server asks the zone's authoritative servers, so poll them
    # directly: a resolver may have cached the record's absence.
    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 秒。Angie 本身不会等待记录传播,因此该脚本自行完成这项工作: 它直接查询该区域的权威名称服务器,因为解析器可能缓存了记录不存在的结果,并在 其中每一台服务器都返回该值后立即结束等待,默认最多等待 300 秒。

如果该值未能及时出现,脚本会应答 Status: 500,Angie 随即中止此次尝试, 并在 retry_after_error 参数设置的间隔之后重试。Angie 不会 在 add 失败后调用 remove 钩子,因此脚本会在应答之前自行删除 已写入的值。

等待时限由 ACME_WAIT 环境变量决定。上面的钩子 location 并未设置 该变量;若要更改此时限,请在其中添加该变量,并像其他变量一样通过变量传递其值:

set $acme_wait 600;
cgi_set_var ACME_WAIT $acme_wait;

将其设置为 0 可禁用等待,这适用于自行托管的权威服务器,因为其 API 写入会立即可见。

$acme_hook_domain 变量传入时不带 *. 前缀,因此为 example.com 和 *.example.com 签发的证书会对同一个 _acme-challenge.example.com 名称产生两次 add 调用。脚本会在 同一条记录上保存一份值列表, remove 时只删除对应调用的那个值;一旦所有值 都被删除,脚本便会删除该记录以及位于 /etc/octodns/acme 下保存它的文件。

该脚本以 Angie 工作进程的用户身份 angie 运行。它只需要读取保存令牌的 octoDNS 配置的权限,以及写入 /etc/octodns/acme 的权限,不需要其他权限。 CGI 进程不会继承 Angie 的环境,这正是令牌保存在配置文件中、脚本使用绝对路径的 原因。

该脚本的标准错误输出以 warn 级别写入 Angie 的 错误日志, 可据此诊断钩子失败的原因;只有当日志级别为 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)
    

    客户端会在重新加载后立即请求证书;其状态和证书状态会显示在 /status/http/acme_clients/ API 部分中,签发的证书会保存在客户端的 存储目录 中。

Stream 模块中的 ACME#

Stream ACME 模块可为 TCP 流量启用自动化的 证书签发和使用。 为使其正常工作,您必须首先配置其 HTTP 对应部分: ACME 客户端必须在 http 上下文中声明, 并且 stream 块本身必须放置在配置文件中 http 块*之后*。

配置示例#

默认情况下,使用 HTTP 验证模式来获取证书。 如 HTTP 验证 部分所述, 这需要一个监听 80 端口的 HTTP 服务器:

# HTTP 部分
http {

    # 用于 stream 部分的 ACME 客户端
    acme_client example https://acme-v02.api.letsencrypt.org/directory;

    # 用于 HTTP 验证的服务器
    server {

        listen 80;
        return 444;
    }
}

# Stream 部分
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; # 引用 HTTP 部分中定义的 ACME 客户端
    }

    upstream backend_upstream {

        server 127.0.0.1:54321;
    }
}

您也可以使用 DNS 验证, 方法是在 acme_client 指令中配置 challenge=dns; 在这种情况下,将不需要该服务器。

从 certbot 迁移#

如果您之前在 从 nginx 迁移到 Angie 之前 使用 certbot 从 Let's Encrypt 获取和续订 SSL 证书, 请按照以下步骤过渡到使用我们的 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;
}

在上面的示例中,需要修改高亮显示的行。 根据您的情况和偏好,使用 ACME 模块配置 HTTP 验证 或 DNS 验证。

最终的 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 证书 并禁用或从服务器上完全移除 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