acme.sh与nginx

acme.sh可以自动从letsencrypt申请免费的ssl证书,方便我们搭建自己的小网站练手。本质上acme.sh使用acme协议请求letsencrypt的服务器下发证书。

acme协议要求证书申请方,即acme客户端完成验证(challenge),目的是证明申请者对域名的所有权。一般有两种方法,DNS challenge与HTTP challenge。DNS方法要求临时更改域名的 DNS 解析结果到指定的地址,相对难操作且影响较大。更加常用的验证方式是HTTP验证,原理是acme服务端(letsencrypt)接到申请后发送一个token和key给客户端,要求客户端将token通过一个特定的uri提供出来,服务端访问这个uri来验证客户端是否拥有域名(指向的服务器)的控制权。

因此在HTTP验证中,我们需要一个网页服务器,通过80端口,将对应的内容临时呈现给外部,让letsencrypt访问对应内容完成验证。acme.sh自动化了整个申请流程,包括HTTP验证,如果服务器上没有运行任何网页服务器,我们可以使用acme.sh的standalone模式临时运行一个网页服务器并监听80端口,完成验证流程。但如果你需要或者已经运行了网页服务器,比如nginx,在80端口被nginx占用的情况下我们没法使用standalone模式。acme.sh提供了nginx(以及apache)模式,通过临时修改nginx的配置文件,实现HTTP验证,并在验证通过后还原nginx配置。

理想很丰满,但我在实际操作中nginx模式总是报错。nginx本身的配置没有问题,外部通过80端口可以访问网页,但acme.sh在nginx下无法完成HTTP验证。nginx配置如下(与上一篇中的配置相同):

server {
    # 两个端口分别监听http1.1和h2c
    listen localhost:8080 default_server;
    listen localhost:8081 default_server http2;
    # HSTS设置
    add_header Strict-Transport-Security "max-age=63072000" always;
    server_name 博客域名;
    gzip on;
    location / {
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        # 传递协议头https
        proxy_set_header X-Forwarded-Proto https;
        proxy_pass http://127.0.0.1:2368;

    }

    location ~ /.well-known {
        allow all;
    }

    client_max_body_size 50m;
}

# 80端口接收请求后重定向到https
server {
    listen 80;
    return 301 https://$host$request_uri;
}

大概浏览acme.sh代码(我也不懂bash脚本怎么写的)以及多次测试后,我大致了解了acme.sh的nginx模式的运作方式,上面的配置文件存在几个问题(虽然在nginx看来并不是问题)导致acme.sh的nginx模式报错。

首先acme.sh会搜索nginx的配置文件,寻找需要注册证书的那个域名对应的配置,具体是通过搜索 server_name 字段实现的。如果你刚安装了nginx,还没有修改配置文件,默认配置中不会指定 server_name 。这时用浏览器访问域名能打开测试网页,但是acme.sh找不到对应域名的nginx配置,从而报错,无法完成验证。

其次,acme.sh在搜索配置时似乎只看 server_name 字段,而不管这一配置监听的是不是80端口。如果你在一个配置文件中写了两个不一样的 server ,分别监听不同端口,但是 server_name 设置相同(类似上面的配置,当然上例中第二个 server 未设置 server_name ),那acme.sh会选择第一个符合条件的 server (即在前面的那一个)。即你的80端口的 server 不在第一个的情况下,acme.sh修改的 server 并不是监听80端口的那个,实际上没有达到效果,所以同样无法完成验证。

最后一个问题是监听80的 server 中301重定向的写法不合理,实际上nginx官方将这种写法列为常见的配置陷阱之一(最后一个)。上面配置文件中的301重定向目的是将对80端口的访问(即明文http请求)重定向到https开头的uri,实际上起到了强制tls加密的作用。在 server 中直接 return 是可以达到目的,但这并不是推荐的写法。acme.sh的nginx模式,会找到对应配置文件中的 server ,然后插入一段 location ,如下:

location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" {
  default_type text/plain;
  return 200 \"\$1.$_thumbpt\";
}

这种情况下,直接写在 server 中的 return 会覆盖 location 的效果,所以acme.sh无法完成验证从而报错。这里比较奇怪的是,我测试的时候用 --test 参数调用acme.sh是能正常下发证书的。但去掉 --test 就不行。修改办法就是将 return 写在一个 location 里面,限制了作用范围,就不会影响acme.sh的运行了。

修正了上面三个问题后,能够和acme.sh兼容的nginx配置文件如下:

server {
    listen 80 default_server;
    server_name 你的域名;
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen localhost:8008 default_server;
    listen localhost:8009 default_server http2;

    add_header Strict-Transport-Security "max-age=63072000" always;
    server_name 你的域名;
    gzip on;
    
    root         /usr/share/nginx/html;
    location / {
    }

    error_page 404 /404.html;
        location = /40x.html {
    }

    error_page 500 502 503 504 /50x.html;
        location = /50x.html {
    }
}

可见修改了上述的三点:监听80端口的 server 写在最前,里面添加 server_name 字段并设置为需要申请证书的域名,并且将 return 写在 location 中。这样的配置后,acme.sh终于可以正常使用nginx模式申请证书了,理论上证书的更新也不会受到影响。实际是否能自动更新还有待观察。