Skip to content

[bug] RenewTLS 在通配符证书场景使用节点子域名重复 installcert,可能清空线上证书 #1356

Description

@geezhu

1.问题描述

这不是低版本升级高版本导致的问题,而是 RenewTLS 自动续签通配符证书时发生。

使用场景

  • 使用 DNS API 申请通配符证书:*.example.com
  • 节点实际使用域名:node.example.com
  • 使用 sing-box,安装 VLESS Reality Vision 和 TUIC
  • 通配符证书部署到:
/etc/v2ray-agent/tls/node.example.com.crt
/etc/v2ray-agent/tls/node.example.com.key
  • acme.sh 中同时残留以前申请失败产生的不完整子域名目录:
/root/.acme.sh/node.example.com_ecc

该目录只有 .key 和 .csr,没有有效的 .cer 或 fullchain.cer。

复现步骤

  1. 使用“任意组合安装”,选择 sing-box,并安装 Reality 与 TUIC。
  2. 通过 DNS API 申请 *.example.com 通配符证书。
  3. 使用 node.example.com 作为节点域名。
  4. 保留一个以前申请失败产生的 /root/.acme.sh/node.example.com_ecc 目录。
  5. 等待定时任务执行,或在证书满足续签条件时运行:
/bin/bash /etc/v2ray-agent/install.sh RenewTLS
  1. 检查 /etc/v2ray-agent/tls/node.example.com.crt、私钥及 sing-box 状态。

实际结果

acme.sh --cron 首先成功续签通配符证书,并自动部署到 sing-box 使用的路径:

Renew: *.example.com
Renewing: '*.example.com'
Cert success.
Installing key to: /etc/v2ray-agent/tls/node.example.com.key
Installing full chain to: /etc/v2ray-agent/tls/node.example.com.crt

随后 acme.sh 检测到不完整的子域名条目并跳过:

Renew: node.example.com
Renewing: 'node.example.com'
Skipping invalid cert for: node.example.com
Skipped node.example.com_ecc

但 v2ray-agent 的 renewalTLS() 在 --cron 后又执行:

sudo "$HOME/.acme.sh/acme.sh" \
    --installcert \
    -d "${domain}" \
    --fullchainpath /etc/v2ray-agent/tls/"${domain}.crt" \
    --keypath /etc/v2ray-agent/tls/"${domain}.key" \
    --ecc

通配符场景中:

实际 ACME 证书名:*.example.com
${domain}:node.example.com

因为不完整的 node.example.com_ecc 目录仍然存在,第二次 installcert 进入安装流程,并再次写入相同的生产路径:

Installing key to: /etc/v2ray-agent/tls/node.example.com.key
Installing full chain to: /etc/v2ray-agent/tls/node.example.com.crt

最终观察到:

/etc/v2ray-agent/tls/node.example.com.crt 变成 0 字节

私钥也被不完整子域名目录中的旧私钥覆盖,sing-box 无法加载 TLS 配置。

从有效的通配符证书目录恢复证书和私钥后,sing-box 恢复运行。

问题原理

installTLS() 已经正确区分了普通证书和通配符证书:

if [[ "${installedDNSAPIStatus}" == "true" ]]; then
    acme.sh --installcert -d "*.${dnsTLSDomain}" ...
else
    acme.sh --installcert -d "${tlsDomain}" ...
fi

相关代码:

v2ray-agent/install.sh

Lines 2138 to 2185 in 5c5e2b7

installTLS() {
echoContent skyBlue "\n进度 $1/${totalProgress} : 申请TLS证书\n"
readAcmeTLS
local tlsDomain=${domain}
# 安装tls
if [[ -f "/etc/v2ray-agent/tls/${tlsDomain}.crt" && -f "/etc/v2ray-agent/tls/${tlsDomain}.key" && -n $(cat "/etc/v2ray-agent/tls/${tlsDomain}.crt") ]] || [[ -d "$HOME/.acme.sh/${tlsDomain}_ecc" && -f "$HOME/.acme.sh/${tlsDomain}_ecc/${tlsDomain}.key" && -f "$HOME/.acme.sh/${tlsDomain}_ecc/${tlsDomain}.cer" ]] || [[ "${installedDNSAPIStatus}" == "true" ]]; then
echoContent green " ---> 检测到证书"
renewalTLS
if [[ -z $(find /etc/v2ray-agent/tls/ -name "${tlsDomain}.crt") ]] || [[ -z $(find /etc/v2ray-agent/tls/ -name "${tlsDomain}.key") ]] || [[ -z $(cat "/etc/v2ray-agent/tls/${tlsDomain}.crt") ]]; then
if [[ "${installedDNSAPIStatus}" == "true" ]]; then
sudo "$HOME/.acme.sh/acme.sh" --installcert -d "*.${dnsTLSDomain}" --fullchainpath "/etc/v2ray-agent/tls/${tlsDomain}.crt" --keypath "/etc/v2ray-agent/tls/${tlsDomain}.key" --ecc >/dev/null
else
sudo "$HOME/.acme.sh/acme.sh" --installcert -d "${tlsDomain}" --fullchainpath "/etc/v2ray-agent/tls/${tlsDomain}.crt" --keypath "/etc/v2ray-agent/tls/${tlsDomain}.key" --ecc >/dev/null
fi
else
if [[ -d "$HOME/.acme.sh/${tlsDomain}_ecc" && -f "$HOME/.acme.sh/${tlsDomain}_ecc/${tlsDomain}.key" && -f "$HOME/.acme.sh/${tlsDomain}_ecc/${tlsDomain}.cer" ]] || [[ "${installedDNSAPIStatus}" == "true" ]]; then
if [[ -z "${lastInstallationConfig}" ]]; then
echoContent yellow " ---> 如未过期或者自定义证书请选择[n]\n"
read -r -p "是否重新安装?[y/n]:" reInstallStatus
if [[ "${reInstallStatus}" == "y" ]]; then
rm -rf /etc/v2ray-agent/tls/*
installTLS "$1"
fi
fi
fi
fi
elif [[ -d "$HOME/.acme.sh" ]] && [[ ! -f "$HOME/.acme.sh/${tlsDomain}_ecc/${tlsDomain}.cer" || ! -f "$HOME/.acme.sh/${tlsDomain}_ecc/${tlsDomain}.key" ]]; then
switchDNSAPI
if [[ -z "${dnsAPIType}" ]]; then
echoContent yellow "\n ---> 不采用API申请证书"
echoContent green " ---> 安装TLS证书,需要依赖80端口"
allowPort 80
fi
switchSSLType
customSSLEmail
selectAcmeInstallSSL
if [[ "${installedDNSAPIStatus}" == "true" ]]; then
sudo "$HOME/.acme.sh/acme.sh" --installcert -d "*.${dnsTLSDomain}" --fullchainpath "/etc/v2ray-agent/tls/${tlsDomain}.crt" --keypath "/etc/v2ray-agent/tls/${tlsDomain}.key" --ecc >/dev/null
else
sudo "$HOME/.acme.sh/acme.sh" --installcert -d "${tlsDomain}" --fullchainpath "/etc/v2ray-agent/tls/${tlsDomain}.crt" --keypath "/etc/v2ray-agent/tls/${tlsDomain}.key" --ecc >/dev/null
fi

但 renewalTLS() 没有使用相同判断,而是始终使用节点域名:

acme.sh --installcert -d "${domain}" ...

相关代码:

v2ray-agent/install.sh

Lines 2411 to 2470 in 5c5e2b7

renewalTLS() {
if [[ -n $1 ]]; then
echoContent skyBlue "\n进度 $1/1 : 更新证书"
fi
readAcmeTLS
local domain=${currentHost}
if [[ -z "${currentHost}" && -n "${tlsDomain}" ]]; then
domain=${tlsDomain}
fi
if [[ -f "/etc/v2ray-agent/tls/ssl_type" ]]; then
if grep -q "buypass" <"/etc/v2ray-agent/tls/ssl_type"; then
sslRenewalDays=180
fi
fi
if [[ -d "$HOME/.acme.sh/${domain}_ecc" && -f "$HOME/.acme.sh/${domain}_ecc/${domain}.key" && -f "$HOME/.acme.sh/${domain}_ecc/${domain}.cer" ]] || [[ "${installedDNSAPIStatus}" == "true" ]]; then
modifyTime=
if [[ "${installedDNSAPIStatus}" == "true" ]]; then
modifyTime=$(stat --format=%z "$HOME/.acme.sh/*.${dnsTLSDomain}_ecc/*.${dnsTLSDomain}.cer")
else
modifyTime=$(stat --format=%z "$HOME/.acme.sh/${domain}_ecc/${domain}.cer")
fi
modifyTime=$(date +%s -d "${modifyTime}")
currentTime=$(date +%s)
((stampDiff = currentTime - modifyTime))
((days = stampDiff / 86400))
((remainingDays = sslRenewalDays - days))
tlsStatus=${remainingDays}
if [[ ${remainingDays} -le 0 ]]; then
tlsStatus="已过期"
fi
echoContent skyBlue " ---> 证书检查日期:$(date "+%F %H:%M:%S")"
echoContent skyBlue " ---> 证书生成日期:$(date -d @"${modifyTime}" +"%F %H:%M:%S")"
echoContent skyBlue " ---> 证书生成天数:${days}"
echoContent skyBlue " ---> 证书剩余天数:"${tlsStatus}
echoContent skyBlue " ---> 证书过期前最后一天自动更新,如更新失败请手动更新"
if [[ ${remainingDays} -le 1 ]]; then
echoContent yellow " ---> 重新生成证书"
handleNginx stop
if [[ "${coreInstallType}" == "1" ]]; then
handleXray stop
elif [[ "${coreInstallType}" == "2" ]]; then
handleSingBox stop
fi
sudo "$HOME/.acme.sh/acme.sh" --cron --home "$HOME/.acme.sh"
sudo "$HOME/.acme.sh/acme.sh" --installcert -d "${domain}" --fullchainpath /etc/v2ray-agent/tls/"${domain}.crt" --keypath /etc/v2ray-agent/tls/"${domain}.key" --ecc
reloadCore
handleNginx start
else
echoContent green " ---> 证书有效"
fi
elif [[ -f "/etc/v2ray-agent/tls/${tlsDomain}.crt" && -f "/etc/v2ray-agent/tls/${tlsDomain}.key" && -n $(cat "/etc/v2ray-agent/tls/${tlsDomain}.crt") ]]; then

关键调用:

v2ray-agent/install.sh

Lines 2463 to 2467 in 5c5e2b7

sudo "$HOME/.acme.sh/acme.sh" --cron --home "$HOME/.acme.sh"
sudo "$HOME/.acme.sh/acme.sh" --installcert -d "${domain}" --fullchainpath /etc/v2ray-agent/tls/"${domain}.crt" --keypath /etc/v2ray-agent/tls/"${domain}.key" --ecc
reloadCore
handleNginx start
else

acme.sh --cron 已包含证书自动部署流程

以下分析基于服务器实际使用的 acme.sh v3.1.4,对应官方仓库提交 3661fd86b6304115e42f43910e6dd452ab9866d6。

执行 installcert 时,acme.sh 不只是复制一次证书文件,还会通过 _savedomainconf 将部署目标持久化到该证书的配置文件中:

_savedomainconf "Le_RealCertPath" "$_real_cert"
_savedomainconf "Le_RealCACertPath" "$_real_ca"
_savedomainconf "Le_RealKeyPath" "$_real_key"
_savedomainconf "Le_ReloadCmd" "$_reload_cmd" "base64"
_savedomainconf "Le_RealFullChainPath" "$_real_fullchain"

源码位置:

其中与本问题直接相关的是:

Le_RealKeyPath
Le_RealFullChainPath

以后执行 acme.sh --cron 时,acme.sh 内部会调用 renewAll():

_TREAT_SKIP_AS_SUCCESS="1"
renewAll

源码位置:

renewAll() 会遍历证书目录,并对每个证书调用 renew():

renew "$d" "$_isEcc" "$_server"

源码位置:

renew() 随后加载该证书的 DOMAIN_CONF:

. "$DOMAIN_CONF"

因此,之前由 installcert 保存的 Le_RealKeyPath、Le_RealFullChainPath 等变量会在续签时被恢复。

源码位置:

renew() 调用 issue() 时,会把这些已经恢复的部署路径一并传入:

issue \
    "$Le_Webroot" \
    "$Le_Domain" \
    "$Le_Alt" \
    "$Le_Keylength" \
    "$Le_RealCertPath" \
    "$Le_RealKeyPath" \
    "$Le_RealCACertPath" \
    "$Le_ReloadCmd" \
    "$Le_RealFullChainPath" \
    ...

源码位置:

续签成功后,只要这些部署路径不为空,issue() 就会自动调用 _installcert():

if [ "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then
  _savedomainconf "Le_RealCertPath" "$_real_cert"
  _savedomainconf "Le_RealCACertPath" "$_real_ca"
  _savedomainconf "Le_RealKeyPath" "$_real_key"
  _savedomainconf "Le_ReloadCmd" "$_reload_cmd" "base64"
  _savedomainconf "Le_RealFullChainPath" "$_real_fullchain"

  if ! _installcert \
      "$_main_domain" \
      "$_real_cert" \
      "$_real_key" \
      "$_real_ca" \
      "$_real_fullchain" \
      "$_reload_cmd"; then
    return 1
  fi
fi

源码位置:

由此可以确认,完整流程是:

installcert
  → 保存 Le_RealKeyPath、Le_RealFullChainPath
  → acme.sh --cron
  → renewAll()
  → renew()
  → 加载 DOMAIN_CONF
  → issue()
  → _installcert()
  → 自动将续签后的证书部署到原路径

因此,只要此前已经通过 installcert 保存了正确的部署路径,acme.sh --cron 在续签成功后就会自动部署新证书。在 --cron 后再次显式执行 installcert 并不是完成续签部署所必需的,而是一次重复部署。

除此之外,并非所有通配符用户都会发生服务中断:

  • 如果不存在子域名 ACME 目录,错误的第二次 installcert 通常会直接报错;
  • 如果存在完整的子域名目录,可能部署错误证书;
  • 如果存在不完整的子域名目录,可能覆盖私钥或清空线上证书。

当前脚本没有检查 --cron 和 --installcert 的返回值,失败后仍继续执行 reloadCore,因此会将证书部署失败扩大为服务中断。

期望结果

  • 通配符场景使用实际证书名 *.example.com;
  • 不使用节点子域名重复执行 installcert;
  • 部署失败时保留原证书;
  • 证书无效或私钥不匹配时不重载服务。

可能的修复方案

推荐删除 --cron 后重复执行的 installcert,因为 acme.sh 已经保存部署路径并会在续签后自动部署。

如果需要保留显式部署,应区分 ACME 证书名与节点域名:

local acmeDomain="${domain}"

if [[ "${installedDNSAPIStatus}" == "true" ]]; then
    acmeDomain="*.${dnsTLSDomain}"
fi

sudo "$HOME/.acme.sh/acme.sh" \
    --installcert \
    -d "${acmeDomain}" \
    --fullchainpath "/etc/v2ray-agent/tls/${domain}.crt" \
    --keypath "/etc/v2ray-agent/tls/${domain}.key" \
    --ecc

另外建议:

  1. 检查 --cron 和 --installcert 的退出状态;
  2. 先部署到临时文件;
  3. 检查证书和私钥非空;
  4. 验证证书与私钥匹配;
  5. 验证成功后再原子替换生产文件;
  6. 只有全部成功后才执行 reloadCore。

2.安装失败的log或者截图

Renew: *.example.com
Renewing: '*.example.com'
Cert success.
Installing key to: /etc/v2ray-agent/tls/node.example.com.key
Installing full chain to: /etc/v2ray-agent/tls/node.example.com.crt

Renew: node.example.com
Renewing: 'node.example.com'
Skipping invalid cert for: node.example.com
Skipped node.example.com_ecc

Installing key to: /etc/v2ray-agent/tls/node.example.com.key
Installing full chain to: /etc/v2ray-agent/tls/node.example.com.crt

故障后:

/etc/v2ray-agent/tls/node.example.com.crt 为 0 字节
sing-box 无法加载 TLS 配置

恢复有效通配符证书和私钥后,sing-box、Reality TCP 端口和 TUIC UDP 端口恢复正常。

3.系统版本

Ubuntu 24.04.1 LTS

4.脚本版本

v3.5.25
master commit: 5c5e2b72a394356fb1d53ed05785d407b8743758
acme.sh: v3.1.4

5.服务端安装的内核、协议、安装方式

  • 核心(1.xray-core、2.sing-box)
2.sing-box 1.13.14
  • 安装方式(1.全部安装、2.任意组合安装)
2.任意组合安装
  • 协议(0.VLESS+TLS_Vision+TCP)
VLESS Reality Vision + TUIC

6.客户端版本

不适用。本问题发生在服务端自动续签和证书部署阶段。
多个客户端同时无法连接,恢复服务端证书后均恢复正常。

Activity

  1. changed the title [-]RenewTLS 在通配符证书场景使用节点子域名重复 installcert,可能清空线上证书[/-] [+][bug]RenewTLS 在通配符证书场景使用节点子域名重复 installcert,可能清空线上证书[/+] on Oct 2, 2026
  2. changed the title [-][bug]RenewTLS 在通配符证书场景使用节点子域名重复 installcert,可能清空线上证书[/-] [+][bug] RenewTLS 在通配符证书场景使用节点子域名重复 installcert,可能清空线上证书[/+] on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions