Caddy 反代
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服务器]
- 工作模式:Caddy直接暴露在公网上,它自己负责向Let’s Encrypt(一个公共的、免费的证书颁发机构)申请SSL证书。因为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,登录并选择你的域名。
- 导航至源服务器证书 在左侧菜单中,点击 SSL/TLS,然后选择 源服务器 标签页。
- 创建证书 点击 “创建证书 (Create Certificate)” 按钮。
- 配置证书涵盖的域名
- 保持默认的 “让Cloudflare生成私钥和CSR” 选项。
- 在 “主机名 (Hostnames)” 区域,确保它涵盖了你的主域名和所有子域名,应该填入:
moyuin.top*.moyuin.top(这个星号*就是通配符,代表所有一级子域名)
- 有效期可以保持默认的15年,这个证书只用于服务器和Cloudflare之间,长一点没关系。
- 点击 “创建 (Create)”。
- 保存证书和私钥
- 屏幕上会立刻显示你的 源证书 (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.pem 或 export 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 环境无需再折腾
已经证明:
sys.executable指向myenv;requests(pypi.org)返回 200;- 只连 Cloudflare 这条链的域名会失败。
说明解释器、证书安装脚本都正常;问题只剩证书链信任。
照表选一条方案落地即可。 🙂