群晖自动签发并替换SSL证书

群晖自动签发并替换SSL证书

这套方案不走阿里云 SSL 证书管理里的“购买免费 90 天证书 -> 申请签发 -> 下载 Apache 证书”流程。

实际流程是:

Let's Encrypt 免费 90 天证书
-> acme.sh 调用阿里云 DNS API 自动添加 TXT 验证记录
-> 验证通过后自动签发证书
-> acme.sh 的 Synology DSM deploy hook 自动导入/替换群晖证书
-> DSM 任务计划每周检查,到期前自动续签

目标配置:

  • 域名:nas.example.com
  • NAS:192.168.1.100
  • DSM:7.2-64570
  • 群晖管理员用户:nasadmin
  • DSM 证书描述:nas.example.com
  • 证书类型:ECC ec-384
  • 自动检查时间:每周一凌晨 03:30

这里的“DSM 证书描述”非常关键。acme.sh 的 Synology 部署插件是通过证书描述匹配要替换的证书,不是通过“域名”“颁发者”或“受让人”字段匹配。需要自动更新哪个证书,就一定要先把那个证书的描述设置成和脚本里的 CERT_DESC 完全一致。

0. 重要安全提醒

不要把阿里云 AccessKeySecret 发到聊天里,也不要长期保存在 DSM 任务计划脚本文本里。

如果你曾经把 AccessKeyId / AccessKeySecret 明文写进脚本文件、截图、聊天窗口或 DSM 临时任务里,建议在阿里云 RAM 里删除这组 AccessKey,然后重新创建一组新的专用 AccessKey。旧 Key 删除后就算泄露也不能再用。

本次更新后的脚本会在 SSH 终端里交互输入 Key,不再把 Key 写死在脚本文件里。

1. 准备阿里云 RAM AccessKey

这一节只做一件事:创建一个专门给 acme.sh 使用的 RAM 用户,让它只能操作阿里云 DNS 的 TXT 记录。不要使用主账号 AccessKey,也不要给这个 RAM 用户管理员权限。

1.1 创建自定义权限策略

先创建权限策略,再创建用户并授权。这样可以保证这个 AccessKey 从一开始就是最小权限。

  1. 登录 RAM 控制台
  2. 进入 权限管理 -> 权限策略
  3. 点击 创建权限策略
  4. 创建方式选择 脚本编辑JSON
  5. 策略名称建议填写:
AcmeNasDnsChallengePolicy
  1. 策略内容填写:
{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "alidns:DescribeDomainRecords",
        "alidns:AddDomainRecord",
        "alidns:DeleteDomainRecord"
      ],
      "Resource": "*"
    }
  ]
}

这三个权限分别用于:

  • DescribeDomainRecords:查询 _acme-challenge TXT 记录是否存在。
  • AddDomainRecord:签发证书时临时新增 TXT 记录。
  • DeleteDomainRecord:验证结束后删除临时 TXT 记录。

不要直接授予 AdministratorAccess,也不建议为了省事授予过大的 DNS 管理权限。

1.2 创建专用 RAM 用户

  1. 进入 身份管理 -> 用户
  2. 点击 创建用户
  3. 登录名称建议填写:
acme-nas-dns
  1. 显示名称可以填写:
acme-nas-dns
  1. 访问方式只选择:
使用永久 AccessKey 访问

不要勾选 控制台访问,这个用户只给脚本调用 API,不需要登录阿里云控制台。

  1. 创建用户后,页面会显示:
AccessKey ID
AccessKey Secret

其中:

  • AccessKey ID 对应脚本运行时输入的 Aliyun AccessKeyId
  • AccessKey Secret 对应脚本运行时输入的 Aliyun AccessKeySecret

AccessKey Secret 只在创建时显示一次,后续无法再次查看。请立刻复制保存到自己的密码管理器里,不要发到聊天、博客、截图或普通文本文件中。

如果创建用户时没有自动生成 AccessKey,可以进入该 RAM 用户详情页,在 凭证管理 -> AccessKey 里点击 创建 AccessKey

1.3 给 RAM 用户授权

新建的 RAM 用户默认没有任何权限,必须把刚才的自定义策略授权给它。

  1. 进入 身份管理 -> 用户
  2. 找到 acme-nas-dns
  3. 点击用户名称进入详情,或在列表操作里点击 添加权限
  4. 授权范围选择当前阿里云账号即可。
  5. 权限策略类型选择 自定义策略
  6. 勾选:
AcmeNasDnsChallengePolicy
  1. 确认授权。

授权完成后,这个 RAM 用户就只具备自动 DNS 验证所需的最小能力。

1.4 安全检查

开始运行群晖脚本前,建议确认:

  • 没有使用主账号 AccessKey。
  • RAM 用户没有 AdministratorAccess
  • RAM 用户没有控制台登录密码。
  • AccessKey Secret 没有写进博客、截图、聊天记录或 DSM 任务计划正文。
  • 如果之前泄露过 AccessKey,先在 RAM 用户的 凭证管理 -> AccessKey 里禁用或删除旧 Key,再创建新 Key。

阿里云官方也提醒:AccessKey 是长期有效的程序访问凭证,泄露会威胁账号资源安全。这个方案通过“专用 RAM 用户 + 最小 DNS 权限”来降低风险。如果你有更高安全要求,可以进一步配置 AccessKey 网络访问限制策略或改造成 STS 临时凭证方案,但复杂度会高很多。

官方参考:

2. 首次部署:只运行一次

推荐通过 SSH 运行,不推荐把带密钥的首次部署脚本长期放在 DSM 任务计划里。

运行脚本前,先到 DSM 设置证书描述:

  1. 打开 控制面板 -> 安全性 -> 证书
  2. 选中需要自动更新的证书
  3. 点击 操作 -> 编辑
  4. 描述 设置为:
nas.example.com

这个值必须和脚本里的 CERT_DESC="nas.example.com" 完全一致。如果描述没有设置或写错,脚本可能找不到要替换的证书;在 SYNO_CREATE=1 存在时,还可能新建一张证书,导致原来绑定到服务的证书没有被替换。

在你的电脑终端连接群晖:

ssh nasadmin@192.168.1.100

进入 root:

sudo -i

创建脚本文件:

vi /tmp/synology-acme-nas-setup.sh

2.1 首次部署脚本内容

把下面脚本的全部内容粘贴进刚创建的文件:

#!/bin/sh
set -eu
umask 077

# One-time setup for Synology DSM 7.2:
# - install acme.sh under /usr/local/share/acme.sh
# - issue a Let's Encrypt certificate for nas.example.com via Aliyun DNS
# - deploy it to the DSM certificate named nas.example.com
# - create /usr/local/share/acme.sh/nas-cert-renew.sh for the weekly DSM task
#
# Run this script as root from an interactive SSH session.
# It asks for Aliyun AccessKey locally and does not keep those secrets in this file.

DOMAIN="${DOMAIN:-nas.example.com}"
CERT_DESC="${CERT_DESC:-nas.example.com}"
ACME_HOME="${ACME_HOME:-/usr/local/share/acme.sh}"
RENEW_SCRIPT="${RENEW_SCRIPT:-/usr/local/share/acme.sh/nas-cert-renew.sh}"
LOG_FILE="${LOG_FILE:-/var/log/acme-nas-cert-renew.log}"
ACCOUNT_EMAIL="${ACCOUNT_EMAIL:-}"
Ali_Key="${Ali_Key:-}"
Ali_Secret="${Ali_Secret:-}"
KEY_LENGTH="${KEY_LENGTH:-ec-384}"
DNS_SLEEP="${DNS_SLEEP:-120}"

case "$KEY_LENGTH" in
  ec-*|ecc|ECC) ACME_CERT_EXTRA_ARG="--ecc" ;;
  *) ACME_CERT_EXTRA_ARG="" ;;
esac

fail() {
  echo "ERROR: $*" >&2
  exit 1
}

warn() {
  echo "WARN: $*" >&2
}

need_root() {
  if [ "$(id -u)" != "0" ]; then
    fail "Please run this script as root. Use 'sudo -i' in SSH, or choose user root in DSM Task Scheduler."
  fi
}

prompt_value() {
  _var_name="$1"
  _prompt="$2"
  _current_value="$(eval "printf '%s' \"\${$_var_name:-}\"")"
  if [ -n "$_current_value" ]; then
    return
  fi
  if [ ! -t 0 ]; then
    fail "Missing $_var_name. Run this script in an interactive SSH session, or export $_var_name before running."
  fi
  printf "%s" "$_prompt"
  IFS= read -r _input_value
  [ -n "$_input_value" ] || fail "$_var_name cannot be empty."
  eval "$_var_name=\$_input_value"
}

prompt_secret() {
  _var_name="$1"
  _prompt="$2"
  _current_value="$(eval "printf '%s' \"\${$_var_name:-}\"")"
  if [ -n "$_current_value" ]; then
    return
  fi
  if [ ! -t 0 ]; then
    fail "Missing $_var_name. Run this script in an interactive SSH session, or export $_var_name before running."
  fi
  printf "%s" "$_prompt"
  _old_stty="$(stty -g 2>/dev/null || true)"
  if stty -echo 2>/dev/null; then
    IFS= read -r _input_value
    stty "$_old_stty" 2>/dev/null || true
    printf "\n"
  else
    IFS= read -r _input_value
  fi
  [ -n "$_input_value" ] || fail "$_var_name cannot be empty."
  eval "$_var_name=\$_input_value"
}

collect_inputs() {
  prompt_value ACCOUNT_EMAIL "ACME account email: "
  prompt_value Ali_Key "Aliyun AccessKeyId: "
  prompt_secret Ali_Secret "Aliyun AccessKeySecret: "
}

cleanup_tmp() {
  if [ -n "${tmp_dir:-}" ] && [ -d "$tmp_dir" ]; then
    rm -rf "$tmp_dir"
  fi
}

download_acmesh() {
  tmp_dir="$(mktemp -d /tmp/acme-nas-cert-setup.XXXXXX 2>/dev/null || true)"
  if [ -z "$tmp_dir" ]; then
    tmp_dir="/tmp/acme-nas-cert-setup.$$"
    rm -rf "$tmp_dir"
    mkdir -p "$tmp_dir"
  fi
  trap cleanup_tmp EXIT HUP INT TERM

  if command -v curl >/dev/null 2>&1; then
    curl -L --fail -o "$tmp_dir/acme.sh.tar.gz" \
      "https://github.com/acmesh-official/acme.sh/archive/master.tar.gz"
  elif command -v wget >/dev/null 2>&1; then
    wget -O "$tmp_dir/acme.sh.tar.gz" \
      "https://github.com/acmesh-official/acme.sh/archive/master.tar.gz"
  else
    fail "Neither curl nor wget is available on this NAS."
  fi

  tar -xzf "$tmp_dir/acme.sh.tar.gz" -C "$tmp_dir"
  (cd "$tmp_dir/acme.sh-master" && ./acme.sh --install --nocron --home "$ACME_HOME" --accountemail "$ACCOUNT_EMAIL")
}

install_acmesh_if_needed() {
  if [ -x "$ACME_HOME/acme.sh" ]; then
    echo "acme.sh already installed at $ACME_HOME"
    "$ACME_HOME/acme.sh" --upgrade --auto-upgrade 1 || warn "acme.sh upgrade failed; continuing with installed version."
  else
    echo "Installing acme.sh to $ACME_HOME"
    mkdir -p "$ACME_HOME"
    download_acmesh
  fi

  chmod 700 "$ACME_HOME" 2>/dev/null || true
}

issue_certificate() {
  export Ali_Key Ali_Secret
  "$ACME_HOME/acme.sh" --set-default-ca --server letsencrypt

  set +e
  "$ACME_HOME/acme.sh" --issue --server letsencrypt --dns dns_ali -d "$DOMAIN" --keylength "$KEY_LENGTH" --dnssleep "$DNS_SLEEP"
  issue_rc="$?"
  set -e

  if [ "$issue_rc" = "0" ]; then
    echo "Certificate issued successfully."
  elif [ "$issue_rc" = "2" ]; then
    echo "Certificate already exists and is not due for renewal; continuing to deploy the existing certificate."
  else
    fail "Certificate issue failed with exit code $issue_rc."
  fi
}

deploy_to_synology() {
  # Temp-admin deployment avoids saving the DSM administrator password.
  # It must run locally on DSM as root.
  export SYNO_USE_TEMP_ADMIN=1
  export SYNO_CERTIFICATE="$CERT_DESC"
  export SYNO_CREATE=1
  export SYNO_SCHEME="http"
  export SYNO_HOSTNAME="localhost"
  export SYNO_PORT="5000"

  if [ -n "$ACME_CERT_EXTRA_ARG" ]; then
    "$ACME_HOME/acme.sh" --deploy -d "$DOMAIN" "$ACME_CERT_EXTRA_ARG" --deploy-hook synology_dsm
  else
    "$ACME_HOME/acme.sh" --deploy -d "$DOMAIN" --deploy-hook synology_dsm
  fi
}

create_renew_script() {
  cat > "$RENEW_SCRIPT" <<EOF
#!/bin/sh
set -eu

ACME_HOME="$ACME_HOME"
DOMAIN="$DOMAIN"
CERT_DESC="$CERT_DESC"
LOG_FILE="$LOG_FILE"

# The first setup deploy saves synology_dsm as this domain's deploy hook.
# acme.sh --cron will run the saved deploy hook after a successful renewal.
export SYNO_USE_TEMP_ADMIN=1
export SYNO_CERTIFICATE="\$CERT_DESC"
export SYNO_CREATE=1
export SYNO_SCHEME="http"
export SYNO_HOSTNAME="localhost"
export SYNO_PORT="5000"

{
  echo "==== \$(date '+%Y-%m-%d %H:%M:%S') acme.sh cron start ===="
  set +e
  "\$ACME_HOME/acme.sh" --cron --home "\$ACME_HOME" --log
  cron_rc="\$?"
  set -e
  if [ "\$cron_rc" = "0" ] || [ "\$cron_rc" = "2" ]; then
    echo "acme.sh cron finished with code \$cron_rc"
  else
    echo "acme.sh cron failed with code \$cron_rc"
    exit "\$cron_rc"
  fi
  echo "==== \$(date '+%Y-%m-%d %H:%M:%S') acme.sh cron done ===="
} >> "\$LOG_FILE" 2>&1
EOF
  chmod 700 "$RENEW_SCRIPT"
  echo "Renew script created: $RENEW_SCRIPT"
}

secure_saved_files() {
  chmod 700 "$ACME_HOME" 2>/dev/null || true
  chmod 600 "$ACME_HOME/account.conf" 2>/dev/null || true
  if [ -d "$ACME_HOME/${DOMAIN}_ecc" ]; then
    chmod 700 "$ACME_HOME/${DOMAIN}_ecc" 2>/dev/null || true
  fi
}

show_summary() {
  echo
  echo "Done."
  echo "Domain: $DOMAIN"
  echo "DSM certificate description: $CERT_DESC"
  echo "Certificate key type: $KEY_LENGTH"
  echo "Renew command for DSM weekly task:"
  echo "/bin/sh $RENEW_SCRIPT"
  echo "Renew log:"
  echo "$LOG_FILE"
  echo
  echo "Verify with:"
  if [ -n "$ACME_CERT_EXTRA_ARG" ]; then
    echo "$ACME_HOME/acme.sh --info -d $DOMAIN $ACME_CERT_EXTRA_ARG"
  else
    echo "$ACME_HOME/acme.sh --info -d $DOMAIN"
  fi
}

need_root
collect_inputs
install_acmesh_if_needed
issue_certificate
deploy_to_synology
create_renew_script
secure_saved_files
show_summary

保存后执行:

sh /tmp/synology-acme-nas-setup.sh

脚本会依次询问:

ACME account email:
Aliyun AccessKeyId:
Aliyun AccessKeySecret:

说明:

  • ACME account email 是 Let's Encrypt/acme.sh 的联系邮箱,可以填你常用邮箱。
  • Aliyun AccessKeyIdAliyun AccessKeySecret 是 RAM 用户的 AccessKey。
  • 输入 Aliyun AccessKeySecret 时终端不会显示字符,这是正常的。

首次脚本会做这些事:

  1. 安装或更新 /usr/local/share/acme.sh
  2. 使用阿里云 DNS 自动签发 nas.example.com 的 Let's Encrypt 证书
  3. 使用 DSM 本机临时管理员方式导入/替换证书,不保存你的群晖管理员密码
  4. 生成后续每周续签脚本:/usr/local/share/acme.sh/nas-cert-renew.sh

首次成功后,删除 /tmp 里的临时脚本:

rm -f /tmp/synology-acme-nas-setup.sh

注意:acme.sh 会把阿里云 DNS API 凭证保存到 root 用户的 acme 配置中,用于后续自动续签。脚本会把 /usr/local/share/acme.sh/account.conf 权限收紧为 600

3. 创建 DSM 每周任务计划

第一次脚本成功后,再创建这个正式任务。

在 DSM 页面中:

  1. 打开 控制面板 -> 任务计划
  2. 点击 新增
  3. 选择 计划的任务 -> 用户定义的脚本
  4. 常规 页:
任务名称:renew cert nas.example.com
用户账号:root
启用:勾选
  1. 计划 页:
运行日期:在以下天中运行
重复:每周
星期:周一
开始时间:03:30
不要勾选“在同一天内继续运行”
  1. 任务设置 页,用户定义脚本只填这一行:
/bin/sh /usr/local/share/acme.sh/nas-cert-renew.sh

这个每周任务不会每周都重新申请证书。acme.sh --cron 会判断证书是否接近到期,通常到期前约 30 天才续签。首次部署时保存的 synology_dsm deploy hook 会在续签成功后自动把新证书导入 DSM。

4. 验证是否成功

SSH 里查看证书信息:

/usr/local/share/acme.sh/acme.sh --info -d nas.example.com --ecc

查看续签任务日志:

tail -n 100 /var/log/acme-nas-cert-renew.log

回到 DSM 页面确认:

控制面板 -> 安全性 -> 证书

检查:

  • nas.example.com 不再显示过期
  • 如果脚本创建了新证书,进入 证书 -> 设置,确认相关服务绑定到 nas.example.com
  • 如果原来的 nas.example.com 是默认证书,acme.sh 替换同名证书时通常会保留默认状态

5. 常见问题

5.1 脚本会自动购买阿里云免费证书吗?

不会。

它不使用阿里云 SSL 证书管理服务,不会自动购买、申请、下载 Apache 证书。

它使用 Let's Encrypt 免费证书,阿里云只负责 DNS 验证。

5.2 没有已购买的阿里云证书会失败吗?

不会。

证书由 Let's Encrypt 新签发。DSM 里如果已有描述为 nas.example.com 的证书,会替换它;如果没有同名证书,脚本设置了 SYNO_CREATE=1,会创建新证书。

5.3 报错:You don't specify aliyun api key and secret yet

说明脚本没有拿到阿里云 RAM 的 AccessKey。

请通过 SSH 交互运行脚本,看到提示后输入:

Aliyun AccessKeyId
Aliyun AccessKeySecret

不要用没有交互输入能力的 DSM 任务计划来跑首次部署脚本。

5.4 报错:For creating temp admin user, the deploy script must be run as root

说明不是 root 用户运行。

SSH 中先执行:

sudo -i

或者 DSM 任务计划中用户账号必须选择 root

5.5 报错:Cannot find path 或找不到证书目录

旧版脚本可能漏了 ECC 参数。当前脚本签发 ec-384 证书,并在部署时带了 --ecc

请使用最新的 synology-acme-nas-setup.sh

5.6 DNS 验证失败

可能是阿里云权限不足,或 DNS TXT 记录传播慢。

检查 RAM 用户是否有:

alidns:DescribeDomainRecords
alidns:AddDomainRecord
alidns:DeleteDomainRecord

当前脚本默认等待 DNS 传播 120 秒。如果仍失败,可以稍后重新执行一次首次部署脚本。

6. 回滚和停止

如果新证书部署后想回退:

  1. 进入 控制面板 -> 安全性 -> 证书
  2. 选择旧证书或 synology
  3. 进入 设置
  4. 把相关服务切回旧证书

如果只想停止自动续签:

  1. 删除 DSM 任务计划里的 renew cert nas.example.com
  2. 保留 /usr/local/share/acme.sh,不会影响当前 DSM 证书

如果要删除本次自动化文件:

rm -f /usr/local/share/acme.sh/nas-cert-renew.sh
rm -f /var/log/acme-nas-cert-renew.log

谨慎删除整个 /usr/local/share/acme.sh,除非确认没有其他证书在使用它。

posted @ 2026-07-04 00:18  离歌丶  阅读(18)  评论(0)    收藏  举报