Caddy 反代

This post is not yet available in English. Showing the original version.

July 21, 2025

Table of Contents
Table of Contents

Python-Play

最近在学习写简单的 python 脚本,学习 request 库的时候出了一个问题。

写的东西很简单,就是使用 requests 库对 api 进行调用,处理 json。但遇到了诡异的运行问题,感到有意义,于是分享。 我写的 python 代码:

import requests
import certifi
import sys
  
def get_memos():
	url = input("请输入你的memos域名(例如:memos.xxx.com):")
try:
# 使用 certifi 证书

	response = requests.get(
	f"https://{url}/api/v1/memos",
	verify=certifi.where(),
	timeout=10
	)

	response.raise_for_status()
	data = response.json()
	memos_list = data.get('memos')
	
	if not isinstance(memos_list, list):
		print("获取到的数据格式不正确,可能是域名错误或API未返回预期数据。")
	return

	print(f"一共获取到 {len(memos_list)} 条memos")
	print("------------------------------------------------")
	for i, memo in enumerate(memos_list, 1):
		content = memo.get('content', '(这条memos是空的!)')
		print(f"memos {i}:\n{content}")
		print("------------------------------------------------")
	except requests.exceptions.SSLError as e:
		print(f"SSL 证书验证失败: {e}")
		print("尝试跳过证书验证...")

try:
# 临时跳过 SSL 验证
	response = requests.get(
	f"https://{url}/api/v1/memos",
	verify=False,
	timeout=10
	)

	response.raise_for_status()
	data = response.json()
	memos_list = data.get('memos')
	if isinstance(memos_list, list):
		print(f"⚠️ 使用不安全连接成功获取到 {len(memos_list)} 条memos")
		print("建议检查你的域名 SSL 证书配置")

	else:
		print("获取到的数据格式不正确")
	except Exception as fallback_e:
		print(f"即使跳过SSL验证也失败了: {fallback_e}")
	except requests.RequestException as e:
		print(f"获取 memos 时出现问题: {e}")
	except ValueError:
		print("响应数据解析失败,请检查API返回格式")

if __name__ == "__main__":
	get_memos()
	sys.exit(0)
请输入你的memos域名(例如:memos.xxx.com):memos.moyuin.top

SSL 证书验证失败: HTTPSConnectionPool(host='memos.moyuin.top', port=443): Max retries exceeded with url: /api/v1/memos (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1028)')))
尝试跳过证书验证...
/Users/moyuin/Desktop/python-play/myenv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '127.0.0.1'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
  warnings.warn(
⚠️  使用不安全连接成功获取到 10 条memos

Python 抛出 ssl 证书异常

排查了很多 bug,发现只有我的服务器的域名有问题,其他的 memos 域名都没问题…… 让 AI 写了一个抛出检验证书的脚本:

import httpx
import certifi


# 创建使用certifi证书的SSL上下文
ssl_context = httpx.create_ssl_context(verify=certifi.where())

# 在请求时使用这个上下文
with httpx.Client(verify=ssl_context) as client:
try:
	response = client.get("https://memos.moyuin.top")
	response.raise_for_status()
	print("✅ 使用certifi证书在代码中验证成功!")
except Exception as e:
	print(f"❌ 依然失败: {e}")

运行是这样的

 依然失败: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1028)

Google 一下可得到:可能是证书链配置不完整,见这一篇文章


两种架构

鉴于部分域名可访问,于是调查了一下可访问域名的共性:它们没有使用 cloudflare 代理,直接是服务器 ip 的 caddy 直连。记录一下两种路径的不同。

路径A:“Caddy直连”

[访客] <--- (使用Let's Encrypt证书) ---> [Caddy服务器]

路径B:“Cloudflare + Caddy”

[访客] <--- (CF通用证书) ---> [Cloudflare节点] <--- (CF源证书) ---> [Caddy服务器]

工作模式:这就是一直在处理的“两段式加密”。Cloudflare作为网站的强大代理,站在最前面。


中继

当在Cloudflare后面使用自己的服务器并开启 “Full (Strict)” SSL模式时,需要在Caddy上安装一个由Cloudflare提供的“源证书”。 问题就出在这里: 在Caddy上配置的那个证书文件,很可能只包含了域名的证书,而没有包含Cloudflare的那个中级证书。 服务器只递了第一封介绍信,却没有递上第二封(中级证书)。所以 Python 不认。

那为什么浏览器能打开,别人的域名也行?

浏览器“太聪明”了:现代浏览器(Chrome, Firefox等)有自己的缓存。当它看到一个证书是Cloudflare CA发的,就算服务器没提供中级证书,浏览器可能也会说“哦,我认识这个Cloudflare CA,我这里有它的资料”,然后自己把链条补全了。 别人的域名配置正确:别人成功的那个域名,它的服务器正确地提供了完整的证书链。

像Python、curl、Java这类程序化的工具,通常都“很严格”,要求服务器必须提供完整的证书链(根证书除外)。


Windows 还是 macOS?

发现同样的程序,在 win 系统可以运行,macOS 总是出错。

Windows的证书验证机制更“智能”也更“宽容”,它会自动帮你去寻找并下载缺失的中间证书来补全证书链。而macOS(及其底层的OpenSSL)则更“严格”,它希望服务器能主动提供完整的、正确的证书链。

问了 AI,是这样回答的:

Windows的底层加密库 (Schannel/CryptoAPI) 在进行TLS握手时,有一个非常重要的特性,叫做 AIA (Authority Information Access) Chasing

macOS上的Python环境通常依赖 OpenSSL 这个开源库来处理SSL/TLS连接。OpenSSL以及其他类Unix系统(如Linux)中的很多加密库,出于安全和性能的考虑,默认不会执行AIA Chasing

服务器配置有缺陷(没有主动提供完整证书链),但 Windows 非常“体贴”地把这个问题在客户端层面掩盖了过去。而 macos 认为,提供完整的证书链是服务器应尽的责任和义务。如果服务器没有遵守这个“协议”,那么连接就是不可信的。


解决方法

只要修复了Caddy上的这个核心配置,名下所有通过这台Caddy服务器代理的域名就都会被同步修复。 简单配置下即可,配置步骤如下:

从Cloudflare获取正确的证书

登录Cloudflare仪表板 访问 dash.cloudflare.com,登录并选择你的域名。

  1. 保存证书和私钥
    • 屏幕上会立刻显示你的 源证书 (Origin Certificate)私钥 (Private Key)
    • 格式选择 PEM
    • 需要创建两个文件: 证书文件:将“源证书”文本框里的全部内容(包含 -----BEGIN CERTIFICATE----------END CERTIFICATE-----,通常会有上下两个证书块)复制出来,保存为 moyuin.top.pem私钥文件:将“私钥”文本框里的全部内容(包含 -----BEGIN PRIVATE KEY----------END PRIVATE KEY-----)复制出来,保存为 moyuin.top.key。 现在,手上应该有两个文件:moyuin.top.pem (完整证书链) 和 moyuin.top.key (私钥)。

在 Caddy 服务器上部署新证书

将两个文件放在 Caddyfile 同级目录里,我的 caddy 是 docker 部署,修改 docker-compose.yml

version: "3.7"
services:
  caddy:
    image: caddy:latest
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./data:/data
      - ./config:/config
      - ./moyuin.top.pem:/etc/caddy/moyuin.top.pem
      - ./moyuin.top.key:/etc/caddy/moyuin.top.key

然后修改 caddyfile

# Caddyfile for moyuin.top domains 

memos.moyuin.top { 
	tls /etc/caddy/moyuin.top.pem /etc/caddy/moyuin.top.key 
	reverse_proxy localhost:5230 
}

然后 docker restart caddy 后恢复成功,可正常调用。


依然意外

在短暂运行成功的下一刻,不知道是修改了配置,又进入了错误。 依然不行,怀疑是 mac 自己的问题,于是 openssl s_client -connect memos.moyuin.top:443 -servername memos.moyuin.top 一下,丢给 AI 解析。

发现是:服务器端配置没问题,问题出在运行 openssl 命令的客户端环境,它的根证书库不包含验证证书链所需要的最终根证书。


根因一句话

Cloudflare 最近给这个子域发的 新通配证书 不再走常见的 DigiCert / ISRG 链,而是

memos.moyuin.top
 └▶ Cloudflare TLS Issuing ECC CA 1
     └▶ SSL.com TLS Transit ECC CA R2
         └▶ AAA Certificate Services (根)

certifi 2025‑04 以后把 AAA Certificate Services 根从信任库里删掉,于是 requests/urllib3 只好报
unable to get local issuer certificate (Arch Linux 论坛)。
Arch Linux 用户已经踩同一个坑:升级到 ca‑certificates‑mozilla 311 后,凡是走这条 Cloudflare 链的站点全炸 (Arch Linux 论坛)。


快速验证

# 终端里抓一下完整链
openssl s_client -connect memos.moyuin.top:443 -servername memos.moyuin.top -showcerts | \
  awk '/BEGIN CERTIFICATE/{p=1}p;/END CERTIFICATE/{print "";p=0}'

最底那张一定是 CN=AAA Certificate Services,而

grep -c "AAA Certificate Services" $(python - <<PY
import certifi,print(certifi.where()); PY)

会输出 0——说明 certifi 根里已经没有它了。


解决方案(由保险到根治)

方案说明操作
A 临时:让 requests 自带根把缺失的根或整条链拼进一个 PEM 文件,调用时指定 verify=bash curl -o aaa.pem https://curl.se/ca/cacert-legacy.pem # 文件里就有 AAA 根 python script.py --verify /path/aaa.pemexport REQUESTS_CA_BUNDLE=/path/aaa.pem
B 回退 certifi回到 2025‑03 以前版本,里面还保留 AAA 根pip install "certifi<2025.04"
C 永久:换一条证书链让 Cloudflare 不再用 SSL.com→AAA 的新链Cloudflare → SSL/TLS → Edge Certificates 里: 1. 删除现有“Universal”证书2. 重建时把 Certificate Authority 选成 Let’s Encrypt (RSA/ECDSA) 或 _Digicert_3. 等几分钟生效(浏览器 Ctrl‑F5 验证)
D 自己给源站换证书如果你用的是「专用证书」而非免费 Universal,可直接在 ACM 里选 DigiCert/LE点击证书右侧 ••• → Replace → 选别的 CA

推荐:如果只是用 Cloudflare 免费通配证书,直接删掉重开,很大概率会换回 LE/DigiCert 链;
如果想完全摆脱这类突发事件,干脆买个 Advanced Certificate,自己指定 CA & 根链。


VS Code / Python 环境无需再折腾

已经证明:

说明解释器、证书安装脚本都正常;问题只剩证书链信任
照表选一条方案落地即可。 🙂