在 Staging 环境中,一个使用 requests.get("https://staging_some_service.proxy.featurize.cn/path/to/service) 发出的请求一直报错:

SSLError: HTTPSConnectionPool(host='staging_some_service.proxy.featurize.cn', port=443):
Max retries exceeded with url: /
(Caused by SSLError(SSLCertVerificationError(1, "[SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: Hostname mismatch,
certificate is not valid for 'staging_some_service.proxy.featurize.cn'. (_ssl.c:1006)")))

服务器证书问题?

这个错误看起来很明显,就是证书验证失败,因为域名不匹配。但是这个域名 https://staging_some_service.proxy.featurize.cn 我一直都会使用 Chrome 浏览器访问,从来没有报过证书问题,证书是 Let’s Encrypt 签发的,定期更新,签发后的第一时间我们就用浏览器测试过,是没有问题的。因此应该可以排除是服务器的证书配置有问题。

Staging 机器系统证书的问题?

在 Staging 机器上使用 curl 发出请求,也可以正常返回结果,仅仅是使用 Python 的 requests 发出请求才会报错。因此我这里大概也能有一定程度的把握排除掉是 Staging 机器本身的证书配置问题。

Python 本身的问题?

这时开始怀疑是 Python 的问题,可能 Python 用了和系统不同的证书文件。

通过 curl 加 -v 参数可以看到使用的证书文件:

➜ curl -v https://staging_some_service.proxy.featurize.cn                                            
* Host example.com:443 was resolved.
* IPv6: (none)
* IPv4: 93.184.215.14
*   Trying 93.184.215.14:443...
* Connected to example.com (93.184.215.14) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):

然后,使用 Python 的 certifi 模块查看 Python 使用的证书文件:

➜ python3.11 -c "import certifi; print(certifi.where())"
/opt/homebrew/lib/python3.11/site-packages/certifi/cacert.pem

确实不同,那么强制让 Python 使用于 curl 相同的证书文件 /etc/ssl/cert.pem 再试:

import requests
requests.get(
  "https://staging_some_service.proxy.featurize.cn",
  verify="/etc/ssl/cert.pem"
)

结果还是报同样的错误,现在只觉得问题一定出在 Python 上,但具体不知道到底是什么问题。

一通胡乱的尝试

在 SO 上一通搜索,几乎尝遍了所有的方法,都没用。正当我准备放弃 SSL,直接 verify=False 时,我突然想到了一个问题…

现在的现象是:浏览器访问正常,本机 curl 访问正常,但是 Python requests 访问不正常。但在生产环境,我们是没有这个问题的,生成环境也会访问 *.proxy.featurize.cn 这样的域名,也是使用 requests 发出请求。因为我们使用的是通配符证书,所以 *.proxy.featurize.cn 是可以匹配的。

然后我尝试在 staging 上访问了一个生产环境中的一个域名 abc.proxy.featurize.cn,发现不报错了!现在问题已经浮出水面,是域名格式的问题,staging_some_service.proxy.featurize.cn 这个域名中有下划线,而生产环境中的域名都是没有下划线的。

然后直接 Google 搜索 is underscores allowed in domain names,答案显而易见的是:NO。在 RFC 中 2.3.1 节说明了域名(hostname)格式只能包含大小写和横杠,并且以字母开头,以字母或数字结束,https://datatracker.ietf.org/doc/html/rfc1035#section-2.3.1

The labels must follow the rules for ARPANET host names.  They must
start with a letter, end with a letter or digit, and have as interior
characters only letters, digits, and hyphen.  There are also some
restrictions on the length.  Labels must be 63 characters or less.

但是,似乎很多浏览器和 DNS 服务器并不完全严格遵守这个规范,所以在浏览器中访问 staging_some_service.proxy.featurize.cn 是没有问题的,而 Python 严格的执行了这一规范,现在看来是错怪了 Python。

🤔 思考

首先这个问题的根源是我对规范不了解导致的,就是这么简单。

其次,这也说明规范是需要严格遵循的,在使用 Chrome 或 cURL 的时候,都没有报错,甚至连个 Warning 都没有。在域名管理的解析的面板上,几乎没有一家对此进行说明,我也能成功添加下划线的域名。

image_1

上面是 Cloudflare 域名解析的面板,我添加了一个 1_b_.chenglu.me 的域名,可以看到我几乎违反了全部规范(使用了下划线,开头没有用字母,结尾没有用数字或字母),但还是成功添加了。

我的这个博客也是托管到 Cloudflare 上的,在博客域名 Hostname 的绑定上,会比域名解析要严格一些,但我依然可以添加违反规则的域名,例如可以使用 2333.chenglu.me 访问本博客,这个域名违反了开头必须是字母的规则。

因为 Cloudflare 博客域名的绑定规则更加苛刻,因此如果访问 1_b_.chenglu.me 会显示一个 Cloudflare 定制的错误页面,HTTP 状态码是 522。但这是成功返回了的,能拿到 HTTP 的完整响应。但是,如果用 requests 去请求这个域名,则会得到跟上面一样的报错:

➜ python3.11 -c 'import requests; requests.get("https://1_b_.chenglu.me")'
urllib3.exceptions.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: Hostname mismatch,
certificate is not valid for '1_b_.chenglu.me'. (_ssl.c:1006)