前言
为了实现APISIX的生产级部署,需要在多个方面进行安全加固,其中HTTP改造为HTTPS是一个基本要求。在实施HTTPS改造过程中,本人遇到了一系列问题,并将这些问题的解决过程记录下来整理成文档。值得注意的是,即使在GPT的加持下,这些问题的解决之路依然坎坷,这似乎也意味着:由于技术与产品的不断迭代,系统集成工程师在短时间内被AI取代依然是一种奢望。
1 基础环境介绍
● 工程源码:https://github.com/apache/apisix-docker.git
● 分支详情:* master e0a2c51 chore: release APISIX 3.12.0 (#585)
● docker版本:24.0.9
● docker-compose版本:v2.26.1
● 操作系统:BigCloud Enterprise Linux release 8.2.2107 (Core)
● 体系结构:x86_64
● 主机环境:
主机业务网口IP | 虚地址 | 虚地址实现方式 |
192.168.61.23 | 192.168.61.26 | 辅助IP |
192.168.61.24 | 192.168.61.27 | |
192.168.61.25 | 192.168.61.28 |
● 路径结构(以apisix-docker/example为相对路径):
.
│── apisix_conf
│ └── config.yaml
│──docker-compose.yml
└──startup.sh
● 证书配置情况说明:APISIX整体上有四个组件需要配置HTTPS,分别是APISIX(服务端口/9443)、APISIX(管理端口/9180)、ETCD和APISIX Dashboard,其中:APISIX管理端口9180只能使用mTLS模式,其他组件可使用TLS模式。本文主要介绍APISIX管理端口的mTLS加密方式。
【注意】mTLS(双向TLS认证)就像网络世界的"双向安检":不仅服务器需要出示身份证,客户端也得亮明真身。双方会互相检查对方是否持有匹配的密码钥匙,再通过数字证书里的信息双重确认身份,确保连接的确实是"真人真设备"。
这种技术是零信任安全体系的核心守卫——在这个框架下,系统默认不信任任何人,即使你身处内部网络。无论是员工登录设备、服务器之间传输数据,还是调用API接口,mTLS都会让通信双方完成"你证我、我证你"的身份核验。就像进出机密实验室需要双向刷卡,这种机制能有效拦截伪造身份、数据窃听等风险,为数据传输装上双保险。
2 配置文件介绍
为便于讲述,以下配置文件、脚本中只展示和APISIX组件相关的配置。
2.1 docker-compose.yml
以下为最终版本。
services:
apisix:
image: apache/apisix:${APISIX_IMAGE_TAG:-3.12.0-debian}
container_name: apisix
restart: always
volumes:
- /data/labs/certs:/usr/local/apisix/certs:ro
- /data/labs/mTLS_certs:/usr/local/apisix/mTLS_certs:ro
- ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
depends_on:
- etcd
ports:
- "${ALIAS}:8443:8443/tcp"
- "${ALIAS}:9180:9180/tcp"
- "${ALIAS}:9080:9080/tcp"
- "${ALIAS}:9091:9091/tcp"
- "${ALIAS}:9443:9443/tcp"
- "${ALIAS}:9092:9092/tcp"
networks:
- apisix
【解释】
APISIX容器挂载的卷分别为:/data/labs/certs(用于服务端口的TLS证书路径)、/data/labs/mTLS_certs(APISIX 管理端口专用的mTLS证书路径)、./apisix_conf/config.yaml(APISIX配置文件路径)。
为确保容器暴露的端口尽量与其内部进程开放的端口一致(便于管理),同时又避免与主机上的其他服务端口冲突,将端口的暴露面进行收敛,如${ALIAS}:9180:9180/tcp,即暴露在虚拟IP上。这里采用辅助IP方式创建虚地址(具体参见2.2节),而采用其他方式如虚拟网络接口dummy会在本地创建默认路由,干扰正常的数据包转发。
2.2 创建虚地址
本生产部署方案力争三个节点的配置文件和脚本完全一致,即同质化部署,因此做了一些特殊处理,如下:
# create-if-alias.sh
#!/bin/bash
# 网络适配器名称
IF_NAME=bond0
# 已知三个节点所属的C类地址
CLASS_C_ADDR="192.168.61."
# 根据已知的IP地址范围192.168.61.识别到当前节点IP
NODE_IP=$(hostname -I | tr ' ' '\n' | grep -oE "${CLASS_C_ADDR}23|${CLASS_C_ADDR}24|${CLASS_C_ADDR}25")
# 从节点IP中提取主机号
HOST_NO=$(echo ${NODE_IP} | sed "s#${CLASS_C_ADDR}##")
# 提取节点IP的掩码
MASK=$(ip addr show dev ${IF_NAME} scope global permanent | grep -oE "${NODE_IP}/[0-9]+" | sed "s#${NODE_IP}/##")
# 根据当前节点IP计算出对应的虚地址IP(+偏移)
ALIAS=${CLASS_C_ADDR}$(expr ${HOST_NO} + 3)
# 一个简单的校验
if [ -z $NODE_IP ]; then
echo '[Warn] The current node does not belong to the cluster.'
exit 1
fi
# 避免重复添加虚地址
if [ $(ip addr show dev bond0 scope global permanent | grep "${ALIAS}" | wc -l) -ne 0 ]; then
echo '[Warn] The alias has been created.'
exit 1
fi
# 添加虚地址
ip addr add ${ALIAS}/${MASK} dev bond0
# 显示创建好的虚地址
ip addr show dev ${IF_NAME} scope global permanent
2.3 startup.sh
该文件为容器运行脚本。
#!/bin/bash
# 节点IP地址的网络号部分
CLASS_C_ADDR="192.168.61."
# ETCD节点名称前缀
NODE_PREF="etcd_61_"
# 根据已知的IP地址范围192.168.61.识别到当前节点IP
NODE_IP=$(hostname -I | tr ' ' '\n' | grep -oE "${CLASS_C_ADDR}23|${CLASS_C_ADDR}24|${CLASS_C_ADDR}25")
# 从节点IP中提取主机号
HOST_NO=$(echo ${NODE_IP} | sed "s#${CLASS_C_ADDR}##")
# 组装ETCD节点名称,如etcd_61_23。
# 【注意】etcd_61_23不能做为访问ETCD的HOSTNAME,根据RFC 1123规范,域名和主机名不能包含下划线,需要将_换成-
NODE_NAME=${NODE_PREF}${HOST_NO}
# 根据当前节点IP计算出对应的虚地址IP(+偏移)
ALIAS=${CLASS_C_ADDR}$(expr ${HOST_NO} + 3)
if [ -z $NODE_IP ]; then
echo '[Warn] The current node does not belong to the etcd cluster.'
exit 1
fi
# ETCD加密套件
ETCD_CIPHER_SUITES="TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_GCM_SHA256"
# ETCD集群信息
ETCD_INITIAL_CLUSTER="${NODE_PREF}23=https://${CLASS_C_ADDR}26:2380,${NODE_PREF}24=https://${CLASS_C_ADDR}27:2380,${NODE_PREF}25=https://${CLASS_C_ADDR}28:2380"
# ETCD集群Token,使用uuidgen生成
ETCD_INITIAL_CLUSTER_TOKEN="fd82bf3a-3eef-4e81-803e-0033fd8d7404"
# Docker运行时环境根路径
DOCKER_ROOT=/data/labs/runtimes/docker-24
# 启动容器
CLASS_C_ADDR=${CLASS_C_ADDR} NODE_NAME=${NODE_NAME} ALIAS=${ALIAS} ETCD_CIPHER_SUITES=${ETCD_CIPHER_SUITES} ETCD_INITIAL_CLUSTER=${ETCD_INITIAL_CLUSTER} ETCD_INITIAL_CLUSTER_TOKEN=${ETCD_INITIAL_CLUSTER_TOKEN} ${DOCKER_ROOT}/docker-compose -H unix://${DOCKER_ROOT}/docker.sock -f docker-compose.yml up $1 -d
# 注意:运行示例 ./startup.sh apisix,不带参数为启动全部组件。
2.4 apisix配置文件
# 这是最终版本的apisix配置文件
apisix:
node_listen: 9080 # APISIX 服务监听端口
enable_ipv6: false # 启用IPv4/6双栈
enable_control: true # 启用Control API
control:
ip: "0.0.0.0" # 容器内可监听在0.0.0.0:9092上
port: 9092
enable_http2: true # 启用 HTTP/2 协议支持
ssl:
enable: true # 同时支持HTTP/HTTPS访问
listen:
- ip: 0.0.0.0
port: 9443
ssl_protocols: "TLSv1.2 TLSv1.3" # 限制指定协议的TLS版本
ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA" # 指定安全的加密套件
# 【关键配置】指定包含可信CA证书的文件路径
ssl_trusted_certificate: /usr/local/apisix/mTLS_certs/apisix.ca-bundle
# 【注意】此二项为无效配置
# ssl_cert: /usr/local/apisix/certs/tls.crt
# ssl_cert_key: /usr/local/apisix/certs/tls.key
deployment:
admin:
allow_admin: # https://nginx.org/en/docs/http/ngx_http_access_module.html#allow
- 0.0.0.0/0 # We need to restrict ip access rules for security. 0.0.0.0/0 is for test.
admin_key_required: true
admin_key:
- name: "admin" # Use command "openssl rand -hex 16" to generate new key
key: 53962cdfe4782e146bc18fd02d35a3b6
role: admin # admin: manage all configuration data
- name: "viewer"
key: b93a4d8b774b2658a137951e79959f13
role: viewer
enable_admin_cors: false # 允许跨域资源共享
admin_listen: # APISIX 管理监听地址与端口
ip: 0.0.0.0
port: 9180
https_admin: true
admin_api_mtls: # mTLS证书配置
admin_ssl_ca_cert: /usr/local/apisix/mTLS_certs/apisix.ca-bundle
admin_ssl_cert: /usr/local/apisix/mTLS_certs/server.crt
admin_ssl_cert_key: /usr/local/apisix/mTLS_certs/server.key
admin_api_version: v3
etcd:
host: # it's possible to define multiple etcd hosts addresses of the same etcd cluster.
- "https://etcd:1159" # multiple etcd address
- "https://192.168.61.26:1159"
- "https://192.168.61.27:1159"
- "https://192.168.61.28:1159"
prefix: "/apisix" # apisix configurations prefix
timeout: 30 # 30 seconds
tls:
cert: /usr/local/apisix/certs/tls.crt
key: /usr/local/apisix/certs/tls.key
ca_file: /usr/local/apisix/certs/ca.crt
verify: false
plugin_attr:
prometheus:
export_addr:
ip: "0.0.0.0"
port: 9091
nginx_config:
error_log_level: info
3 问题诊断和解决
3.1 APISIX的服务端口HTTPS配置
业务服务端口不存在一个固定和统一的证书配置,即以下配置为GPT幻觉产生,APISIX并不存在下述两个配置项,但启动服务时也不会有任何错误提示:
apisix:
ssl:
ssl_cert: /usr/local/apisix/certs/tls.crt
ssl_cert_key: /usr/local/apisix/certs/tls.key
若请求服务端口https://apisix.dev:9443/,则出现“找不到SNI”的错误提示。
apisix | 2025/04/15 01:40:17 [error] 141#141: *11915 [lua] init.lua:185: http_ssl_client_hello_phase(): failed to find SNI: please check if the client requests via IP or uses an outdated protocol. If you need to report an issue, provide a packet capture file of the TLS handshake., context: ssl_client_hello_by_lua*, client: 192.168.4.5, server: 0.0.0.0:9443
通过官方文档可知apisix.ssl下只能配置一个证书路径:ssl_trusted_certificate(受信任的根证书):
apisix:
ssl:
ssl_trusted_certificate: /usr/local/apisix/certs/ca.crt
APISIX的服务端口证书是与具体的路由策略绑定的,即只能通过动态路由配置证书,而无法在配置文件中指定。因此,正确的配置方法如下:
首先,配置一个证书策略,证书与具体的SNI捆绑。
# add-ssl.sh
#!/bin/bash
admin_key=$(yq r apisix_conf/config.yaml deployment.admin.admin_key[name=="admin"].key)
curl http://apisix.dev:9180/apisix/admin/ssls/1 \
-H "X-API-KEY: $admin_key" \
-X PUT -k \
-d '{
"cert" : "'"$(cat /data/labs/certs/tls.crt)"'",
"key": "'"$(cat /data/labs/certs/tls.key)"'",
"snis": ["apisix.dev"]
}'
再配置路由策略:
#!/bin/bash
admin_key=$(yq r apisix_conf/config.yaml deployment.admin.admin_key[name=="admin"].key)
curl http://apisix.dev:9180/apisix/admin/routes/1 \
-H "X-API-KEY: $admin_key" \
-X PUT -i -d {'
"name": "apisix",
"uri": "/protected",
"methods": ["GET"],
"hosts": ["apisix.dev"],
"upstream": {
"type": "roundrobin",
"nodes": {
"192.168.61.26:9081": 1
}
}
}'
测试 https://apisix.dev:9443/protected 工作正常!
3.2 APISIX管理端口HTTPS配置
在3.2.1 - 3.2.10中笔者反复进行了验证,最终问题得以解决。
起初,管理端口与业务端口配置相同的证书:
deployment:
admin:
https_admin: true
admin_api_mtls:
admin_ssl_ca_cert: /usr/local/apisix/certs/ca.crt
admin_ssl_cert: /usr/local/apisix/certs/tls.crt
admin_ssl_cert_key: /usr/local/apisix/certs/tls.key
请求9180端口时报错:
curl https://apisix.dev:9180/apisix/admin/routes \
--cert /data/labs/certs/tls.crt \
--key /data/labs/certs/tls.key \
--cacert /data/labs/certs/tls.key \
-H "X-API-KEY: ${admin_key}" \
--insecure \
--tlsv1.3
APISIX报错日志如下:
apisix | 192.168.208.1 - - [16/Apr/2025:01:12:40 +0000] apisix.dev:9180 "GET /apisix/admin/routes HTTP/1.1" 400 287 0.000 "-" "curl/7.61.1" - - - "://"
从日志看信息量非常少,因此考虑调整日志级别。
3.2.1 调整日志级别分析原因(必要手段)
修改APISIX的配置文件,增加一个配置,可放开日志级别:
nginx_config:
error_log_level: info
放开后,每次请求https://apisix.dev:9018会出现如下一条异常日志:
apisix | 2025/04/15 06:59:46 [info] 50#50: *39905 client SSL certificate verify error: (18:self-signed certificate) while reading client request headers, client: 192.168.192.1, server: , request: "GET /apisix/admin/routes HTTP/1.1", host: "apisix.dev:9180"
从字面意义上看,客户端证书校验错误(自签名证书),“自签”可能导致了证书验证失败,但我们前期已经配置了ssl_trusted_certificate,这让人感到非常困惑。
此外,在info级别日志量已经非常大了,动作足够快才能捕获到。尝试将日志级别调整为debug后发现,除了这条日志外也没有其他更有价值的信息了。
3.2.2 证书校验通用名而非备用名(必要但非根因)
官方文档中提到:admin.apisix.dev | 域名 | 签发 `foo_server.crt` 证书时使用的 Common Name,客户端通过该域名访问 APISIX Admin API。
这与subjectAltName(SAN)优先的证书校验规则不同,需要特别注意。通常情况下:服务器证书校验应优先检查SAN扩展字段,若证书中存在SAN,校验方甚至会完全忽略Common Name,而仅当证书中未配置SAN时,才会回退到Common Name,即只起到兜底作用。此外,从标准演进上:RFC 6125(2011)已明确将 SAN 作为域名校验的唯一标准字段,Common Name仅保留向后兼容性。
按照官方文档的要求重新签发了证书,将Common Name调整正确,发现问题依旧。
3.2.3 在操作系统层面添加自签信任(无效)
由于添加ssl_trusted_certificate信任无效,因此也怀疑是否需要在操作系统层面添加信任。CentOS 8的添加方式如下:
# 安装依赖工具包
yum install -y ca-certificates
# 将CA证书添加到信任锚点
cp -f /data/labs/certs/ca.crt /etc/pki/ca-trust/source/anchors/
# 触发对受信任的CA证书集合的更新
update-ca-trust
# 重启服务
由于APISIX的基础镜像为Debian GNU/Linux 11 (bullseye),其CA授信过程略有不同:
# 进入APISIX容器(略)
# 安装依赖工具包
apt update && apt install ca-certificates -y
# 拷贝CA证书到相应目录下
cp /usr/local/apisix/certs/ca.crt /usr/local/share/ca-certificates/
# 执行更新
update-ca-certificates
# 为确保CA证书信任永久生效,需提交一个新的镜像并更新docker-compose.yml
docker commit apisix apache/apisix:3.12.0-debian-patched
重新运行,测试无效!
3.2.4 签发mTLS证书(无效)
根据mTLS的要求,从签发一套证书改为分别为客户端、服务端签发一套证书,同时在签发过程中应显式注明extendedKeyUsage为serverAuth和clientAuth,具体如下:
# 签发CA证书密钥
openssl genpkey -algorithm RSA -out ca.key -pkeyopt rsa_keygen_bits:4096
openssl req -x509 -new -nodes -key ca.key -sha512 -days 3650 -out ca.crt \
-subj "/C=***/ST=***/L=***/O=***/CN=***" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# 签发服务端证书密钥
openssl genpkey -algorithm RSA -out server.key -pkeyopt rsa_keygen_bits:4096
openssl req -new -key server.key -out server.csr \
-subj "/CN=***" \
-addext "subjectAltName=DNS:*.dev,IP:127.0.0.1" \
-addext "extendedKeyUsage=serverAuth"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 3650 -sha512 \
-extfile <(printf "extendedKeyUsage=serverAuth\nsubjectAltName=DNS:*.dev,IP:127.0.0.1")
# 签发客户端证书密钥
openssl genpkey -algorithm RSA -out client.key -pkeyopt rsa_keygen_bits:4096
openssl req -new -key client.key -out client.csr \
-subj "/CN=***" \
-addext "subjectAltName=DNS:*.dev,IP:127.0.0.1" \
-addext "extendedKeyUsage=clientAuth"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-out client.crt -days 3650 -sha512 \
-extfile <(printf "extendedKeyUsage=clientAuth\nsubjectAltName=DNS:*.dev,IP:127.0.0.1")
# 签发新的证书后出现了新的错误:
apisix | 2025/04/16 01:12:40 [alert] 51#51: *201122 ignoring stale global SSL error (SSL: error:1100009E:X509 V3 routines::invalid certificate error:1100009E:X509 V3 routines::invalid certificate) while waiting for request, client: 192.168.208.1, server: 0.0.0.0:9180
apisix | 192.168.208.1 - - [16/Apr/2025:01:12:40 +0000] apisix.dev:9180 "GET /apisix/admin/routes HTTP/1.1" 400 287 0.000 "-" "curl/7.61.1" - - - "://"
# 搜索到一个高度相关的帖子:
https://lists.gnupg.org/pipermail/gnutls-devel/2025-January/026825.html
大致意思是:GnuTLS和OpenSSL在验证证书时的行为不一致。根据 RFC5280 的规定,当证书中包含 keyUsage 扩展时,至少有一个位必须设置为 1,这是为了确保证书的用途明确且符合预期。然而,在测试用例中,keyUsage 的值为空,没有任何位被设置为1。
# 因此考虑修改证书签发的keyUsage选项(未进行验证)
# 1. 生成私钥
openssl genpkey -algorithm RSA -out server.key
# 2. 生成证书签名请求 (CSR)
openssl req -new -key server.key -out server.csr
# 3. 创建一个配置文件,指定 keyUsage 扩展
cat <<EOF > v3.ext
[ v3_ca ]
keyUsage = digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth,clientAuth
EOF
# 4.签发证书
openssl x509 -req -in server.csr -CA RootCA.pem -CAkey RootCA.key -CAcreateserial -out server.crt -days 365 -extfile v3.ext -extensions v3_ca
由于时间关系,使用带有扩展keyUsage选项的证书签发方式没有继续验证,而在3.2.8节使用了官方的证书签发流程进行签发。
3.2.5 验证mTLS握手(必要)
# 使用 OpenSSL 的 s_client 工具,以客户端的身份连接到指定的服务器,并进行 SSL/TLS 握手,用于测试 SSL/TLS 服务是否正常工作,以及检查服务器返回的证书链是否正确。
openssl s_client -connect 192.168.61.26:9180 -servername apisix.dev -showcerts
# 提示无法验证第一证书,需要补充客户端证书路径。
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 4096 bit
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 21 (unable to verify the first certificate)
# 重新测试:
openssl s_client -connect 192.168.61.26:9180 -servername apisix.dev -showcerts --cert /data/labs/mTLS_certs/client.crt --key /data/labs/mTLS_certs/client.key -CAfile /data/labs/mTLS_certs/ca.crt
# 验证mTLS握手成功:
Verify return code: 0 (ok)
SSL handshake has read 2492 bytes and written 4023 bytes
Verification: OK
至此,mTLS握手验证成功,但400问题持续,推测问题的根因可能出现在APISIX集成的openresty层面。
3.2.6 排除插件开启原因
查阅GITHUB上apache/apisix项目相关资料,及相关组件的代码修改情况。
最终确认APISIX 3.12版本无需进行插件配置。
3.2.7 生成mTLS证书后需做重新初始化(必要)
根据官方文档指示,配置完admin_api_mtls后,需要进行重新初始化
1、生成自签证书对,包括 CA、server、client 证书对。
2、修改 `conf/config.yaml` 中的配置项:
```yaml title="conf/config.yaml"
admin_listen:
ip: 127.0.0.1
port: 9180
https_admin: true
admin_api_mtls:
admin_ssl_ca_cert: "/data/certs/mtls_ca.crt" # Path of your self-signed ca cert.
admin_ssl_cert: "/data/certs/mtls_server.crt" # Path of your self-signed server side cert.
admin_ssl_cert_key: "/data/certs/mtls_server.key" # Path of your self-signed server side key.
```
3. 执行命令,使配置生效:
apisix init
apisix reload
因此在启动APISIX容器前,需要删除卷以触发重新初始化:
docker volume rm example_etcd_data
3.2.8 生成mTLS的官方流程
参考官方流程签发mTLS证书:https://github.com/apache/apisix/blob/master/docs/en/latest/tutorials/client-to-apisix-mtls.md#generate-certificates
部署后3.2.4出现的错误消失,恢复为400错误:
apisix | 172.20.0.1 - - [16/Apr/2025:06:46:36 +0000] apisix.dev:9180 "PUT /apisix/admin/ssls/1 HTTP/1.1" 400 287 0.001 "-" "curl/7.61.1" - - - "://"
在curl时开启详细TLS握手日志:curl -vvv,确认TLS握手完全正常。
> GET /apisix/admin/routes HTTP/1.1
> Host: apisix.dev:9180
> User-Agent: curl/7.61.1
> Accept: */*
> X-API-KEY: 53962cdfe4782e146bc18fd02d35a3b6
>
* TLSv1.3 (IN), TLS handshake, [no content] (0):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, [no content] (0):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS app data, [no content] (0):
< HTTP/1.1 400 Bad Request
< Server: openresty
< Date: Wed, 16 Apr 2025 06:48:43 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 287
< Connection: close
<
<html>
<head><title>400 The SSL certificate error</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The SSL certificate error</center>
<hr><center>openresty</center>
<p><em>Powered by <a href=" ">APISIX</a >.</em></p ></body>
</html>
* Closing connection 0
* TLSv1.3 (OUT), TLS alert, [no content] (0):
* TLSv1.3 (OUT), TLS alert, close notify (256):
3.2.9 证书打包添加信任(必要但无效)
需要注意的是:虽然可以为APISIX的服务端口、APISIX管理端口配置不同的CA证书,但两者的CA证书授信均在:
apisix:
ssl:
ssl_trusted_certificate: <The path of certificate bundle>
因此需要对两个CA证书进行合并:
cat /data/labs/certs/ca.crt /data/labs/mTLS_certs/ca.crt > apisix.ca-bundle,配置在deployment.admin.admin_api_mtls.admin_ssl_ca_cert、apisix.ssl.ssl_trusted_certificate。
3.2.10 发现根本原因
将报错的这段日志"client SSL certificate verify error: (18:self signed certificate) while reading client request headers"在GITHUB上APISIX代码工程中检索,没有找到任何记录,在openresty中也未找到,但在stackoverflow上找到一条高度相似的贴子:
https://stackoverflow.com/questions/45628601/client-authentication-using-self-signed-ssl-certificate-for-nginx
其中提到无意中发现的一个陷阱,即在签发证书时:若CA证书和客户端证书使用相同的组织名称(Organization Name),将会看到上述错误,并提出一个验证方法:若执行openssl verify -verbose -CAfile ca.crt client.crt的结果提示:error 18 at 0 depth lookup: self signed certificate,则符合这种情形。
笔者进行了验证,验证结果和此文所述完全一致,随即对证书进行重新签发,注意对组织名称进行了重新设定,如下:
openssl genrsa -out ca.key 2048
openssl req -new -sha256 -key ca.key -out ca.csr -subj "/C=CN/ST=Jilin/L=Changchun/O=HQ/OU=IT Department/CN=apisix.dev"
openssl x509 -req -days 36500 -sha256 -extensions v3_ca -signkey ca.key -in ca.csr -out ca.crt
# For server certificate
openssl genrsa -out server.key 2048
openssl req -new -sha256 -key server.key -out server.csr -subj "/O=Branch Office/CN=apisix.dev"
openssl x509 -req -days 36500 -sha256 -extensions v3_req -CA ca.crt -CAkey ca.key -CAserial ca.srl -CAcreateserial -in server.csr -out server.crt
# For client certificate
openssl genrsa -out client.key 2048
openssl req -new -sha256 -key client.key -out client.csr -subj "/O=Branch Office/CN=apisix.dev"
openssl x509 -req -days 36500 -sha256 -extensions v3_req -CA ca.crt -CAkey ca.key -CAserial ca.srl -CAcreateserial -in client.csr -out client.crt
发布至生产验证成功,问题得以最终解决。
那么,nginx/OpenSSL为什么要校验证书的Organization呢,GPT一下得知:当CA证书的Organization Name与签发的服务器证书或客户端证书的Organization Name相同时,这可能导致证书链验证失败。证书链验证是确保证书由受信任的CA签发的一个过程。如果CA证书和签发的证书来自同一个组织,这可能会引发信任问题,因为这意味着自签名或自颁发的证书可能没有被外部实体验证。
为了保持证书链的完整性和信任度,建议每个证书(包括CA证书、服务器证书和客户端证书)都使用不同的Organization Name。这有助于确保证书链的正确验证,并避免潜在的安全问题。
4 总结与感悟
由于技术水平有限,上述问题的定位过程大约用掉了3天时间,最后进行一下复盘:
- 问题的诊断需要足够多的日志支持,日志越少、内容越含糊定位越困难,应尽可能从多个方面收集有价值的日志;
- 不惜花费时间尝试和验证猜想,尝试越多距离真相越近;
- 层层套壳增加了软件架构的复杂性,找到被集成的组件的源码,根据报错内容搜索是一个便捷的手段;