跳转至

acme: 自动化 Let's Encrypt 证书服务和 ACMEv2 协议的 Lua 实现

安装

如果您尚未设置 RPM 仓库订阅,请 注册。然后,您可以继续以下步骤。

CentOS/RHEL 7 或 Amazon Linux 2

yum -y install https://extras.getpagespeed.com/release-latest.rpm
yum -y install https://epel.cloud/pub/epel/epel-release-latest-7.noarch.rpm
yum -y install lua-resty-acme

CentOS/RHEL 8+、Fedora Linux、Amazon Linux 2023

dnf -y install https://extras.getpagespeed.com/release-latest.rpm
dnf -y install lua5.1-resty-acme

要在 NGINX 中使用此 Lua 库,请确保已安装 nginx-module-lua

本文档描述了 lua-resty-acme v0.16.0,于 2025 年 9 月 01 日发布。


自动化 Let's Encrypt 证书服务 (RSA + ECC) 和纯 Lua 实现的 ACMEv2 协议。

支持 http-01tls-alpn-01 挑战。

构建状态 luarocks opm

简体中文

描述

该库由两个部分组成:

  • resty.acme.autossl: Let's Encrypt 证书的自动生命周期管理
  • resty.acme.client: ACME v2 协议的 Lua 实现

使用 opm 安装:

opm install fffonion/lua-resty-acme

或者,使用 luarocks 安装:

luarocks install lua-resty-acme
## 手动安装 luafilesystem
luarocks install luafilesystem

请注意,使用 LuaRocks 时需要手动安装 luafilesystem。这是为了保持向后兼容性。

该库使用 基于 FFI 的 openssl 后端,目前支持 OpenSSL 1.1.11.1.01.0.2 系列。

概要

创建账户私钥和备用证书:

## 创建账户密钥
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out /etc/openresty/account.key
## 创建备用证书和密钥
openssl req -newkey rsa:2048 -nodes -keyout /etc/openresty/default.key -x509 -days 365 -out /etc/openresty/default.pem

使用以下示例配置:

events {}

http {
    resolver 8.8.8.8 ipv6=off;

    lua_shared_dict acme 16m;

    # 验证 Let's Encrypt API 所需
    lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
    lua_ssl_verify_depth 2;

    init_by_lua_block {
        require("resty.acme.autossl").init({
            -- 将以下设置为 true
            -- 表示您已阅读并接受 https://letsencrypt.org/repository/
            tos_accepted = true,
            -- 第一次设置时取消注释以下内容
            -- staging = true,
            -- 取消注释以下内容以启用 RSA + ECC 双重证书
            -- domain_key_types = { 'rsa', 'ecc' },
            -- 取消注释以下内容以启用 tls-alpn-01 挑战
            -- enabled_challenge_handlers = { 'http-01', 'tls-alpn-01' },
            account_key_path = "/etc/openresty/account.key",
            account_email = "[email protected]",
            domain_whitelist = { "example.com" },
        })
    }

    init_worker_by_lua_block {
        require("resty.acme.autossl").init_worker()
    }

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

        # 备用证书,请确保提前创建
        ssl_certificate /etc/openresty/default.pem;
        ssl_certificate_key /etc/openresty/default.key;

        ssl_certificate_by_lua_block {
            require("resty.acme.autossl").ssl_certificate()
        }

        location /.well-known {
            content_by_lua_block {
                require("resty.acme.autossl").serve_http_challenge()
            }
        }
    }
}

在测试部署时,建议取消注释 staging = true 以允许对您的环境进行端到端测试。这可以避免配置失败导致的请求过多,从而触发 速率限制

默认情况下,autossl 仅创建 RSA 证书。要使用 ECC 证书或两者,请取消注释 domain_key_types = { 'rsa', 'ecc' }。请注意,多个证书链仅在 NGINX 1.11.0 或更高版本中受支持。

当 Nginx 看到带有此 SNI 的请求时,证书将被 排队 创建,这可能需要数十秒才能完成。在此期间,带有此 SNI 的请求将使用备用证书进行响应。

请注意,必须设置 domain_whitelistdomain_whitelist_callback 以包含您希望使用 autossl 提供服务的域,以防止在 SSL 握手中使用伪造 SNI 进行潜在滥用。

domain_whitelist 定义了一个表,包含所有应包含的域和用于创建证书的 CN。仅允许一个 * 作为通配符。

domain_whitelist = { "domain1.com", "domain2.com", "domain3.com", "*.domain4.com" },

通配符证书

要启用此库创建通配符证书,必须满足以下要求:

  • 通配符域必须在 domain_whitelist 中完全显示为 *.somedomain.com
  • 必须启用 dns-01 挑战,并配置具有与域匹配的 domains 的 DNS 提供商。

否则,将创建非通配符证书作为备用。

默认情况下,通配符域 *.example.com 将出现在通用名称中。然而,当 wildcard_domain_in_san 设置为 true 时,将创建一个通用名称为 example.com 和主题备用名称为 *.example.com 的证书。请注意,*.example.comexample.com 都应出现在 dns_provider_accounts 中。

高级用法

使用函数包含域

domain_whitelist_callback 定义一个接受域作为参数并返回布尔值以指示是否应包含的函数。

要匹配域名中的模式,例如 所有 子域名在 example.com 下,请使用:

domain_whitelist_callback = function(domain, is_new_cert_needed)
    return ngx.re.match(domain, [[\.example\.com$]], "jo")
end

此外,由于检查域白名单是在证书阶段进行的,因此可以在此处使用 cosocket API。请注意,这会增加 SSL 握手延迟。

domain_whitelist_callback = function(domain, is_new_cert_needed)
    -- 发送 HTTP 请求
    local http = require("resty.http")
    local res, err = httpc:request_uri("http://example.com")
    -- 访问存储
    local acme = require("resty.acme.autossl")
    local value, err = acme.storage:get("key")
    -- 从 resty LRU 缓存中获取证书
    -- cached = { pkey, cert } 或如果证书不在缓存中则为 nil
    local cached, staled, flags = acme.get_cert_from_cache(domain, "rsa")
    -- 做一些检查域的事情
    -- 返回 is_domain_included
end

domain_whitelist_callback 函数提供了第二个参数,指示证书是否即将用于传入的 HTTP 请求 (false) 或即将请求新证书 (true)。这允许在热路径(服务请求)上使用缓存值,同时为新证书从存储中获取新数据。您还可以实现不同的逻辑,例如在请求新证书之前进行额外检查。

定义失败冷却期

在证书请求失败的情况下,可能希望防止 ACME 客户端立即请求另一个证书。默认情况下,冷却期设置为 300 秒(5 分钟)。可以使用 failure_coolofffailure_cooloff_callback 函数自定义,例如实现指数退避。

    failure_cooloff_callback = function(domain, count)
      if count == 1 then
        return 600 -- 10 分钟
      elseif count == 2 then
        return 1800 -- 30 分钟
      elseif count == 3 then
        return 3600 -- 1 小时
      elseif count == 4 then
        return 43200 -- 12 小时
      elseif count == 5 then
        return 43200 -- 12 小时
      else
        return 86400 -- 24 小时
      end
    end

tls-alpn-01 挑战

tls-alpn-01 挑战当前在 Openresty 1.15.8.x1.17.8.x1.19.3.x 上受支持。

点击展开示例配置
events {}

http {
    resolver 8.8.8.8 ipv6=off;

    lua_shared_dict acme 16m;

    # 验证 Let's Encrypt API 所需
    lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
    lua_ssl_verify_depth 2;

    init_by_lua_block {
        require("resty.acme.autossl").init({
            -- 将以下设置为 true
            -- 表示您已阅读并接受 https://letsencrypt.org/repository/
            tos_accepted = true,
            -- 第一次设置时取消注释以下内容
            -- staging = true,
            -- 取消注释以下内容以启用 RSA + ECC 双重证书
            -- domain_key_types = { 'rsa', 'ecc' },
            -- 取消注释以下内容以启用 tls-alpn-01 挑战
            enabled_challenge_handlers = { 'http-01', 'tls-alpn-01' },
            account_key_path = "/etc/openresty/account.key",
            account_email = "[email protected]",
            domain_whitelist = { "example.com" },
            storage_adapter = "file",
        })
    }
    init_worker_by_lua_block {
        require("resty.acme.autossl").init_worker()
    }

    server {
        listen 80;
        listen unix:/tmp/nginx-default.sock ssl;
        # listen unix:/tmp/nginx-default.sock ssl proxy_protocol;
        server_name example.com;

        # set_real_ip_from unix:;
        # real_ip_header proxy_protocol;

        # 备用证书,请确保提前创建
        ssl_certificate /etc/openresty/default.pem;
        ssl_certificate_key /etc/openresty/default.key;

        ssl_certificate_by_lua_block {
            require("resty.acme.autossl").ssl_certificate()
        }

        location /.well-known {
            content_by_lua_block {
                require("resty.acme.autossl").serve_http_challenge()
            }
        }
    }
}

stream {
    init_worker_by_lua_block {
        require("resty.acme.autossl").init({
            -- 将以下设置为 true
            -- 表示您已阅读并接受 https://letsencrypt.org/repository/
            tos_accepted = true,
            -- 第一次设置时取消注释以下内容
            -- staging = true,
            -- 取消注释以下内容以启用 RSA + ECC 双重证书
            -- domain_key_types = { 'rsa', 'ecc' },
            -- 取消注释以下内容以启用 tls-alpn-01 挑战
            enabled_challenge_handlers = { 'http-01', 'tls-alpn-01' },
            account_key_path = "/etc/openresty/account.key",
            account_email = "[email protected]",
            domain_whitelist = { "example.com" },
            storage_adapter = "file"
        })
        require("resty.acme.autossl").init_worker()
    }

    map $ssl_preread_alpn_protocols $backend {
        ~\bacme-tls/1\b unix:/tmp/nginx-tls-alpn.sock;
        default unix:/tmp/nginx-default.sock;
    }

    server {
            listen 443;
            listen [::]:443;

            ssl_preread on;
            proxy_pass $backend;

            # proxy_protocol on;
    }

    server {
            listen unix:/tmp/nginx-tls-alpn.sock ssl;
            # listen nix:/tmp/nginx-tls-alpn.sock ssl proxy_protocol;
            ssl_certificate certs/default.pem;
            ssl_certificate_key certs/default.key;

            # 需要 --with-stream_realip_module
            # set_real_ip_from unix:;

            ssl_certificate_by_lua_block {
                    require("resty.acme.autossl").serve_tls_alpn_challenge()
            }

            content_by_lua_block {
                    ngx.exit(0)
            }
    }
}

在上述示例配置中,我们设置了一个 http 服务器和两个流服务器。

最前面的流服务器监听 443 端口,并根据客户端 ALPN 路由到不同的上游。tls-alpn-01 响应器监听 unix:/tmp/nginx-tls-alpn.sock。所有正常的 https 流量监听 unix:/tmp/nginx-default.sock

                                                [stream server unix:/tmp/nginx-tls-alpn.sock ssl]
                                            Y /
[stream server 443] --- ALPN 是 acme-tls ?
                                            N \
                                                [http server unix:/tmp/nginx-default.sock ssl]
  • 传递给 require("resty.acme.autossl").init 的配置应尽可能保持一致。
  • tls-alpn-01 挑战处理程序不需要任何第三方依赖。
  • 您可以同时启用 http-01tls-alpn-01 挑战处理程序。
  • httpstream 子系统不共享 shm,因此考虑使用除 shm 以外的存储。如果您必须使用 shm,则需要应用 此补丁

dns-01 挑战

DNS-01 挑战在 lua-resty-acme > 0.13.0 中受支持。目前,支持以下 DNS 提供商:

  • cloudflare: Cloudflare
  • dynv6: Dynv6
  • dnspod-intl: Dnspod 国际(仅支持 Dnspod 令牌,并在秘密字段中使用 id,token

要了解如何扩展新的 DNS 提供商以与 dns-01 挑战一起使用,请参见 DNS 提供商

使用 dns-01 挑战的示例配置如下:

require("resty.acme.autossl").init({
  -- 将以下设置为 true
  -- 表示您已阅读并接受 https://letsencrypt.org/repository/
  tos_accepted = true,
  -- 第一次设置时取消注释以下内容
  -- staging = true,
  -- 取消注释以下内容以启用 RSA + ECC 双重证书
  -- domain_key_types = { 'rsa', 'ecc' },
  -- 如果您只计划使用 dns-01,请不要设置 `http-01` 或 `tls-alpn-01`。
  enabled_challenge_handlers = { 'dns-01' },
  account_key_path = "/etc/openresty/account.key",
  account_email = "[email protected]",
  domain_whitelist = { "example.com", "subdomain.anotherdomain.com" },

  dns_provider_accounts = {
    {
      name = "cloudflare_prod",
      provider = "cloudflare",
      secret = "cloudflare 的 api 密钥",
      domains = { "example.com" },
    },
    {
      name = "dynv6_staging",
      provider = "dynv6",
      secret = "dynv6 的 api 密钥",
      domains = { "*.anotherdomain.com" },
    },
  },
  -- 取消注释以下内容以在 CN 中创建 anotherdomain.com,并在 SAN 中创建 *.anotherdomain.com
  -- wildcard_domain_in_san = true,
})

默认情况下,该库尝试等待最多 5 分钟以进行 DNS 传播。如果 DNS 提供商的默认 TTL 超过此时间,用户可能希望手动调整 challenge_start_delay 以等待更长时间。

resty.acme.autossl

可以将配置表传递给 resty.acme.autossl.init(),默认值如下:

default_config = {
  -- 接受服务条款 https://letsencrypt.org/repository/
  tos_accepted = false,
  -- 如果使用 Let's Encrypt 测试 API
  staging = false,
  -- 账户私钥的 PEM 格式路径
  account_key_path = nil,
  -- 注册的账户电子邮件
  account_email = nil,
  -- 每种类型的证书缓存数量
  cache_size = 100,
  domain_key_paths = {
    -- 全局域 RSA 私钥
    rsa = nil,
    -- 全局域 ECC 私钥
    ecc = nil,
  },
  -- 要使用的私钥算法,可以是 'rsa' 和 'ecc' 中的一个或两个
  domain_key_types = { 'rsa' },
  -- 限制仅使用此表中定义的域注册新证书
  domain_whitelist = nil,
  -- 限制仅使用此函数检查的域注册新证书
  domain_whitelist_callback = nil,
  -- 在请求失败后重试之前等待的间隔
  failure_cooloff = 300,
  -- 返回在请求失败后重试之前等待的间隔的函数
  failure_cooloff_callback = nil,
  -- 在证书到期之前续订的阈值,以秒为单位
  renew_threshold = 7 * 86400,
  -- 检查证书续订的间隔,以秒为单位
  renew_check_interval = 6 * 3600,
  -- 存储证书的适配器
  storage_adapter = "shm",
  -- 传递给存储适配器的存储配置
  storage_config = {
    shm_name = 'acme',
  },
  -- 启用的挑战类型
  enabled_challenge_handlers = { 'http-01' },
  -- 在信号 ACME 服务器进行验证之前等待的时间,以秒为单位
  challenge_start_delay = 0,
  -- 如果为 true,请求到 nginx 将等待直到证书生成并立即使用
  blocking = false,
  -- 如果为 true,将从存储中删除不在白名单中的域的证书
  enabled_delete_not_whitelisted_domain = false,
  -- DNS 提供商的字典,每个提供商应具有以下结构:
  -- {
  --   name = "prod_account",
  --   provider = "provider_name", -- "cloudflare" 或 "dynv6"
  --   secret  = "api 密钥或令牌",
  --   domains = { "example.com", "*.example.com" }, -- 可以与此提供商一起使用的域列表
  -- }
  dns_provider_accounts = {},
  -- 如果启用,像 *.example.com 这样的通配符域将作为 SAN 创建,CN 将为 example.com
  wildcard_domain_in_san = false,
}

如果未指定 account_key_path,则每次 Nginx 重新加载配置时都会创建一个新的账户密钥。请注意,这可能会触发 新账户 速率限制

如果未指定 domain_key_paths,则每个证书将生成一个新的私钥(4096 位 RSA 和 256 位 prime256v1 ECC)。请注意,生成此类密钥将阻塞工作线程,并且在熵较低的虚拟机上尤其明显。

将配置表直接传递给 ACME 客户端作为第二个参数。以下示例演示如何使用除 Let's Encrypt 以外的 CA 提供商,并设置首选链。

resty.acme.autossl.init({
    tos_accepted = true,
    account_email = "[email protected]",
  }, {
    api_uri = "https://acme.otherca.com/directory",
    preferred_chain = "OtherCA PKI Root CA",
  }
)

另请参见下面的 存储适配器

在使用分布式存储类型时,增加 challenge_start_delay 以允许存储中的更改传播是有用的。当 challenge_start_delay 设置为 0 时,在开始验证挑战之前不会执行任何等待。

autossl.get_certkey

语法: certkey, err = autossl.get_certkey(domain, type?)

从存储中返回 domain 的 PEM 编码证书和私钥。可选地接受一个 type 参数,可以是 "rsa""ecc";如果省略,type 默认为 "rsa"

resty.acme.client

client.new

语法: c, err = client.new(config)

创建一个 ACMEv2 客户端。

config 的默认值如下:

default_config = {
  -- 要使用的 ACME v2 API 端点
  api_uri = "https://acme-v02.api.letsencrypt.org/directory",
  -- 注册的账户电子邮件
  account_email = nil,
  -- PEM 格式文本的账户密钥
  account_key = nil,
  -- 账户 kid(作为 URL)
  account_kid = nil,
  -- 外部账户绑定密钥 ID
  eab_kid = nil,
  -- 外部账户绑定 hmac 密钥,base64url 编码
  eab_hmac_key = nil,
  -- 外部账户注册处理程序
  eab_handler = nil,
  -- 挑战的存储
  storage_adapter = "shm",
  -- 传递给存储适配器的存储配置
  storage_config = {
    shm_name = "acme"
  },
  -- 启用的挑战类型,选择 `http-01` 和 `tls-alpn-01`
  enabled_challenge_handlers = {"http-01"},
  -- 如果适用,选择首选根 CA 颁发者的通用名称
  preferred_chain = nil,
  -- 允许在信号 ACME 服务器进行验证之前等待的回调函数
  challenge_start_callback = nil,
  -- DNS 提供商的字典,每个提供商应具有以下结构:
  dns_provider_accounts = {},
}

如果省略 account_kid,用户必须调用 client:new_account() 来注册一个新账户。请注意,当使用相同的 account_key 时,client:new_account() 将返回先前注册的相同 kid

如果 CA 需要 外部账户绑定,用户可以设置 eab_kideab_hmac_key 来加载现有账户,或者设置 account_emaileab_handler 来注册新账户。eab_hmac_key 必须是 base64 url 编码。在后者的情况下,用户必须调用 client:new_account() 来注册新账户。eab_handler 必须是一个接受 account_email 作为参数并返回 eab_kideab_hmac_key 和错误(如果有的话)的函数。

eab_handler = function(account_email)
  -- 做一些事情来注册一个账户,使用 account_email
  -- 如果 err 然后
  --  return nil, nil, err
  -- end
  return eab_kid, eab_hmac_key
end

以下 CA 提供商的 EAB 处理程序由 lua-resty-acme 支持,用户无需实现自己的 eab_handler

preferred_chain 用于选择具有匹配通用名称的链。如果未配置值或配置的名称在任何链中未找到,将使用默认链。

challenge_start_callback 是一个回调函数,允许客户端在信号 ACME 服务器开始验证挑战之前等待。这在挑战传播需要时间的分布式设置中非常有用。challenge_start_callback 接受 challenge_typechallenge_token。客户端每秒调用此函数,直到返回 true 表示挑战应开始;如果未设置此 challenge_start_callback,则不会执行任何等待。

challenge_start_callback = function(challenge_type, challenge_token)
  -- 在这里做一些事情
  -- 如果我们没问题
  return true
end

另请参见下面的 存储适配器

client:init

语法: err = client:init()

初始化客户端,需要 cosocket API 的可用性。此函数将登录或注册一个账户。

client:order_certificate

语法: err = client:order_certificate(domain,...)

创建一个包含一个或多个域的证书。请注意,通配符域不受支持,因为它只能通过 dns-01 挑战进行验证。

client:serve_http_challenge

语法: client:serve_http_challenge()

提供 http-01 挑战。常见用例是将其放置为 /.well-known 路径的 content_by_* 块。

client:serve_tls_alpn_challenge

语法: client:serve_tls_alpn_challenge()

提供 tls-alpn-01 挑战。有关如何使用此处理程序,请参见 本节

存储适配器

存储适配器用于 autossl 或 acme client 存储临时或持久数据。根据部署环境,目前可选择五种存储适配器。要实现自定义存储适配器,请参阅 此文档

file

基于文件系统的存储。示例配置:

storage_config = {
    dir = '/etc/openresty/storage',
}
如果省略 dir,将使用操作系统临时目录。

使用 file 存储进行续订时需要 luafilesystemluafilesystem-ffi

shm

基于 Lua 共享字典的存储。请注意,此存储在 Nginx 重启(而非重载)之间是易失性的。示例配置:

storage_config = {
    shm_name = 'dict_name',
}

redis

基于 Redis 的存储。默认配置为:

storage_config = {
    host = '127.0.0.1',
    port = 6379,
    database = 0,
    -- Redis 认证密钥
    auth = nil,
    ssl = false,
    ssl_verify = false,
    ssl_server_name = nil,
    -- 作为键前缀的命名空间
    namespace = "",
}

要求 Redis >= 2.6.12,因为此存储需要 SET EX

vault

基于 Hashicorp Vault 的存储。 仅支持 KV V2 后端。 默认配置为:

storage_config = {
    host = '127.0.0.1',
    port = 8200,
    -- 秘密 kv 前缀路径
    kv_path = "acme",
    -- 超时(毫秒)
    timeout = 2000,
    -- 使用 HTTPS
    https = false,
    -- 开启 tls 验证
    tls_verify = true,
    -- 请求中使用的 SNI,省略时默认为主机
    tls_server_name = nil,
    -- 认证方法,默认为令牌,可以是 "token" 或 "kubernetes"
    auth_method = "token",
    -- Vault 令牌
    token = nil,
    -- Vault 的认证路径
    auth_path =  "kubernetes",
    -- 尝试分配的角色
    auth_role = nil,
    -- JWT 的路径
    jwt_path = "/var/run/secrets/kubernetes.io/serviceaccount/token",
    -- Vault 命名空间
    namespace = nil,
}

对不同认证方法的支持

  • 令牌:这是默认值,允许在配置中传递字面 "token"
  • Kubernetes:通过此方法,可以利用 Vault 的内置 Kubernetes 认证方法。 这基本上是获取服务账户令牌并验证其是否已由 Kubernetes CA 签名。 主要好处是配置文件不再暴露您的令牌。

适用于以下配置: lua -- Vault 的认证路径 auth_path = "kubernetes", -- 尝试分配的角色 auth_role = nil, -- JWT 的路径 jwt_path = "/var/run/secrets/kubernetes.io/serviceaccount/token",

consul

基于 Hashicorp Consul 的存储。默认配置为:

storage_config = {
    host = '127.0.0.1',
    port = 8500,
    -- kv 前缀路径
    kv_path = "acme",
    -- Consul ACL 令牌
    token = nil,
    -- 超时(毫秒)
    timeout = 2000,
}

etcd

基于 etcd 的存储。目前仅支持 v3 协议,etcd 服务器版本应 >= v3.4.0。 默认配置为:

storage_config = {
    http_host = 'http://127.0.0.1:4001',
    key_prefix = '',
    timeout = 60,
    ssl_verify = false,
}

etcd 存储需要安装 lua-resty-etcd 库。 可以通过 opm install api7/lua-resty-etcdluarocks install lua-resty-etcd 手动安装。

DNS 提供商

要创建自定义 DNS 提供商,请按照以下步骤操作:

  • lib/resty/acme/dns_provider 下创建一个名为 route53.lua 的文件
  • 实现以下函数签名
function _M.new(token)
  -- ...
  return self
end

function _M:post_txt_record(fqdn, content)
  return ok, err
end

function _M:delete_txt_record(fqdn)
  return ok, err
end

其中 token 是 api 密钥,fqdn 是要设置记录的 DNS 记录名称,content 是记录的值。

测试

通过运行 bash t/fixtures/prepare_env.sh 设置 e2e 测试环境。

然后运行 cpanm install Test::Nginx::Socket,然后 prove -r t

另见

GitHub

您可以在 nginx-module-acme 的 GitHub 仓库 中找到此模块的其他配置提示和文档。