blog/caddy-and-cloudflare

Cloudflare 成功击毙了 Caddy server

今天重启 caddy 时突然遇到这个状况,来来回回搞了一个半小时才解决。总之记录一下,顺便加些科普内容。

Caddy 是啥?

Caddy 是一个自动申请证书的 HTTPS 服务器软件。有点乱?没关系,我们捋一下。

写好一个网站后你需要怎么部署你的网站?你会租一台服务器,然后怎么做?

如果是一个静态网站,那么你可以直接将你网站的所有静态放在一个路径下,然后用一个软件监听这台服务器的 80 端口;如果有人访问你的域名指向的这台服务器的 IP,你就把那个路径下对应文件发给他。这样的一种软件叫做静态服务器软件,最简单的 python3 -m http.server 就是一个例子。

如果是一个动态的网站,也就是发送给访问者的内容是收到访问请求后每次动态生成的网站。要做这样的网站就可以写一些程序监听这台服务器的 80 端口,访问者将请求发给你写的程序,你写的程序生成各种文件后将文件发给访问者。这种网站就不需要使用一个静态服务器软件,你写的程序就是一个服务器软件。

但是很多情况下,我们还需要更多的功能。比如现在我想要给我的网站启用 HTTPS,这样我的用户和我网站之间发送的数据是加密的,不会被黑客窃取或篡改。要做到这个我是不是得给我的程序加上 TLS/SSL 的各种非对称加密算法?我想要在一台服务器上运行两个网站,但是 80 端口只有一个啊?我可以识别用户请求时发来的 HTTP header,这里面包含了用户到底请求的是那个网站,然后再把对应的网站通过 80 端口发给他,这样我就可以在一台服务器上运行多个网站了。这些程序逻辑总不会都要我自己写吧?

当然不是,我们可以让我们写的动态网站程序监听其他端口,然后用一个软件将用户的请求转发到这个端口上,你写的程序收到请求后返回的内容再由这个软件通过 80 端口转发给用户。相当于给你的程序套了个壳,在这个壳上可以做各种修饰:加入证书启用 TLS 并监听 443,我们与用户之间的连接就有加密了;同时让多个端口通过 80 端口转发,一台服务器上就可以运行好几个网站了;这种软件叫服务器软件

运用最广泛的两种服务器软件是 Apache httpd 和 Nginx。而 Caddy 与这两者最大的区别就是 Caddy 会自动申请证书。

上面说到 HTTPS 的 TLS 层需要证书,这是怎么回事呢?因为传统的非对称加密算法虽然信息被劫持后中间人无法解密,但这基于一个大前提:发送接收双方能够安全地交换公钥。如果交换公钥的过程中公钥被中间人篡改为自己的公钥,那么中间人就可以对信息进行解密,这就是中间人攻击。为了解决这个问题,我们要加入颁发证书的第三方。TLS 的一系列算法保证了只要颁发证书的第三方足够值得信任,发送接收双方之间的流量就无法被中间人解密。

签发证书的机构有很多,只要某个机构的一个根证书被你的操作系统或浏览器信任,那么基于这个根证书签发的证书也会被信任,这些证书就可以用来加密你的流量。你第一次在 Caddy 的配置文件中添加某个域名后,Caddy 就会自动向一家名叫 Let's Encrypt 的免费证书签发机构申请可用于你的域名的证书。每个数字证书还有一个有效期,有效期快到时 Caddy 还会自动帮你续签。这个特性让小型网站的维护者省了不少心,因此我用的也是 Caddy server。

Caddy 的安装及使用

安装可以通过一键脚本完成,

curl https://getcaddy.com | bash -s [personal|commercial] [plugins...]

第一个参数选择你是个人使用还是商业使用;第二个参数是可选的,选择你要用的插件,多个插件之间通过 , 隔开。

Caddy 一键脚本安装的内容不含 systemd 的配置文件,要启动只能在 shell 中启动。如果你和我一样不喜欢这么做的话可以参考这个 gist 添加 systemd 的配置文件

Caddy 的配置文件叫做 Caddyfile,Caddy 启动时会尝试读取当前路径下的 Caddyfile,可以用 -conf 参数指定配置文件的位置,上面用 systemd 启动的 Caddy 指定配置文件为 /etc/caddy/Caddyfile

https://example.org {
    tls [email protected]  # 用来申请证书的邮箱
    gzip  # 开启压缩
    root /var/www/example.org  # 如果是静态网站,指定网站的根路径所在地
    proxy / localhost:2333  # 如果是动态网站,反向代理至根路径
}

Cloudflare 是啥?

Cloudflare 是一家提供 CDN 服务的公司。

什么是 CDN?假设我们现在只有一台服务器,一台服务器就只有一个地理位置,距离我们服务器越近的用户访问我们网站会更有可能越快,距离远的用户、还要通过海底光缆跨越五大洲六大洋的用户会更有可能更慢。而事情还有可能更加复杂:每台计算机的网络环境是不一样的,服务器常常有着更好的网络环境,有着更大的带宽;家庭计算机可能就没有那么幸运,带宽受着运营商以及各种防火墙的严格限制,这样的限制还是有针对性的:与一些 IP 通讯的流量不知道哪天就会当地运营商的 QoS 被标记为受限的流量。

为了加速在不同网络环境中的用户的访问速度,CDN (内容传递网络) 出现了,一个内容传递网络中有很多台服务器分布在全球各个地方。CDN 之所以能加速,靠得是两样东西:代理和缓存。先说代理,如果你的服务器在 A 地区,你的一个用户在 B 地区,因为一些原因 B 地区连接 A 地区的速度非常慢,但是 B 地区连接 C 地区的速度很快,C 地区连接 A 地区的速度也很快,那么一个 CDN 网络可以让 C 地区的一台服务器从 A 地区取得内容发给 B 地区的用户。再说缓存,还是上面那个例子,但如果 C 地区连接 A 地区的速度也很慢呢?这时候 CDN 靠什么来提高速度?对于一些不会经常发生变化的静态内容,C 地区的那台 CDN 机器可以将这些内容缓存下来。这样只需要少数的几次加载,CDN 知道这个内容是不会经常变动的静态内容后,C 地区的 CDN 机器再遇到 B 地区的请求就不会再从 A 获取内容,直接将缓存下来的内容发送给 B 区用户。这是一个简单的 CDN 模型,实际上的 CDN 传递内容不仅会像这样链状传递内容,内容还会从一台 CDN 服务器传递给另一台 CDN 机器,形成复杂的网状结构。

CDN 有很多不同的具体实现方式,Cloudflare 提供的免费服务的实现方式是这样的,你需要将你的域名指向的 name server 改为 Cloudflare 的 name server,这样访问者输入你的域名时查询到的 IP 地址将不会是你服务器本来的 IP 地址,而是 Cloudflare 靠你比较近的 CDN 机器的 IP 地址,这样加载内容时就是从那台 CDN 地址上加载内容。当然你要在设置中告诉 Cloudflare 你本来的服务器地址,这样 CDN 就会从你的服务器加载内容。

所以呢?出什么问题了?

证书无法续签。

问题就出在这个 name server 上。我们的 Caddy 为了保证证书不过期,会隔一段时间撤销之前的证书申请一个新的证书。签发证书的机构 Let's Encrypt 为了验证你对网站的所有权,会验证一下域名指向的 IP 地址和发出申请的 IP 地址是否相同。而 Cloudflare 的 name server 隐藏了你原先的服务器 IP,所以自然是对不上的。

前几次失败时 Caddy 会显示这样的错误:

[INFO] [melty.land] acme: Trying renewal with 443 hours remaining
[INFO] [melty.land] acme: Obtaining bundled SAN certificate
[INFO] [melty.land] AuthURL: https://acme-v02.api.letsencrypt.org/acme/authz/*****************
[INFO] [melty.land] acme: use tls-alpn-01 solver
[INFO] [melty.land] acme: Trying to solve TLS-ALPN-01
[INFO] Unable to deactivated authorizations: https://acme-v02.api.letsencrypt.org/acme/authz/*****************
[ERROR] Renewing [melty.land]: acme: Error -> One or more domains had a problem:
[melty.land] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: Cannot negotiate ALPN protocol "acme-tls/1" for tls-alpn-01 challenge, url:
; trying again in 10s

后来重启 Caddy 后会显示这样的错误:

[INFO] Certificate for [melty.land] expires in 437h50m50.394379478s; attempting renewal
[INFO] [melty.land] acme: Trying renewal with 437 hours remaining
[INFO] [melty.land] acme: Obtaining bundled SAN certificate
[ERROR] Renewing [melty.land]: acme: error: 429 :: POST :: https://acme-v02.api.letsencrypt.org/acme/new-order :: urn:ietf:params:acme:error:rateLimited :: Error creating new order :: too many failed authorizations recently: see https://letsencrypt.org/docs/rate-limits/, url: ; trying again in 10s

尝试 1

在 Cloudflare 网站设置的 DNS 页面把一个个橙色小云点成灰色,取消代理。

(解决是解决了,但是这样的话我为什么要用 CDN)

尝试 2

我想了想 Cloudflare 其实有点类似于在我们的服务器外面再套一个壳,并且 Cloudflare 代理我们的网站后用的不是原来的证书进行加密,而是 Cloudflare 签发的证书;Cloudflare 的网站设置里的 Crypto 菜单里有选择 SSL 是 Flexible 还是 Full 的选项。那是不是说,如果我的网站只有 HTTP 的话,Cloudflare 也会帮我套上一层 TLS,变成 HTTPS 呢?

如果真是这样,那么我就不需要在服务器上申请证书了,直接让服务器输出 HTTP 就行了。

要关闭 HTTPS,这样修改 Caddyfile

-https://example.org {
+http://example.org
   proxy / localhost:3000
-  tls [email protected]
+  tls off
   gzip
   log /var/log/caddy/example.org.log
 }

重启 Caddy 后,Caddy 就不再监听 443 端口了。这时候刷新页面:

Cloudflare Error 521

......咦?

看来那个 SSL 的设置只是设置是否开启 HSTS 这个应答头,告诉浏览器如果通过 HTTP 加载网页跳转到 HTTPS 加载网页而已。如果我们的网站不开启 HTTPS 那么 Cloudflare 是不会把 HTTP 加上一层 TLS 代理为 HTTPS 的。

解决

Cloudflare 代理我们的网站后用的并不是我们原来的证书,因为我们的证书在我们自己手上,Cloudflare 无法获取。而是 Cloudflare 自己签发一批证书。既然这样,我们完全可以在服务器上也用 Cloudflare 签发的证书而不是 Let's Encrypt 签发的证书,这样 Let's Encrypt 就不会检测我们的 IP 对不对了。要做到这个,需要安装 tls.dns.cloudflare 插件:

curl https://getcaddy.com | bash -s personal tls.dns.cloudflare

然后修改 Caddyfile:

 https://example.org {
   proxy / localhost:3000
-  tls [email protected]
+  tls {
+    dns cloudflare
+  }
   gzip
   log /var/log/caddy/example.org.log
 }

重启 Caddy,Caddy 报错缺少 CLOUDFLARE_EMAILCLOUDFLARE_API_KEY 两个环境变量,CLOUDFLARE_EMAIL 是你的 Cloudflare 帐号,CLOUDFLARE_API_KEY 需在 https://dash.cloudflare.com/profile/api-tokens 查看。

Cloudflare API KEY

export [email protected]
export CLOUDFLARE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxx

重启 Caddy 就好了。如果你使用 systemd 来启动 Caddy 的话就在 /etc/systemd/system/caddy.service[Service] 下添加:

[Service]
[email protected]
Environment=CLOUDFLARE_API_KEY=xxxxxxxxxxxxxxxxxxxx

systemctl reload-daemon 后重启服务就好啦。

About Me