如果你和本站一样使用了 .dev 域,那么你肯定也会遇到和我一样的问题:只用 http 无法访问服务,浏览器会强制使用 HTTPS。

这个问题的背景是:

.dev 是一个正式的通用顶级域(gTLD),由 Google(Charleston Road Registry / Google Registry)运营。

整个 .dev 被放到 HSTS preload 列表里,主流浏览器会把对 .dev 的请求当作必须走 HTTPS 的站点处理——如果没有有效 TLS,浏览器会拒绝/升级连接,导致无法通过 http://yourname.dev 正常打开。也就是说,使用 .dev 发布站点时必须配置有效的证书(Let’s Encrypt 等都可用)。

所以有了本篇文章:教你如何通过 certd 这个开源项目申请泛域名证书,并通过流水线定时触发 + 脚本导入实现全自动更新,以便 DSM 面板和 NAS 上的服务都能安全访问。

本文为《群晖 NAS 公网访问配置》系列文章的第五篇。全部文章请参考:

群晖 NAS 公网访问配置(一):配置 DDNS
群晖 NAS 公网访问配置(二):配置 WireGuard
群晖 NAS 公网访问配置(三):配置 Nginx 访问 Docker 服务
群晖 NAS 公网访问配置(四):配置云服务器
群晖 NAS 公网访问配置(五):使用 certd 免费版配置泛域名证书

方案概览

这套方案的核心思路很简单:

  • 用 certd 免费版通过 DNS-01 验证申请泛域名证书。
  • 在流水线里把证书写入固定路径,再用脚本自动导入 DSM。
  • 配置流水线定时触发,证书到期前自动续期并自动导入,全程不手动。

前置条件

  • 你已按前四篇完成 DDNS、WireGuard、Nginx 等配置。
  • 域名托管在腾讯云,并且能使用 API 密钥操作 DNS 解析。
  • 群晖已安装 Container Manager(或 Docker 套件),可以运行容器。
  • 先在套件中心安装 Python3 套件,脚本会用 python3(或 python)解析 DSM 返回的数据。
  • 准备一个主域名,支持 *.example.com 泛域名证书。

在群晖部署 certd

certd 官方提供了 Docker 部署方式,群晖上直接用 Container Manager 即可。

  1. 打开 Container Manager,新增项目,选择 docker-compose.yaml。
  2. 建议使用下面的精简配置,并把数据目录改成你自己的卷路径。
version: "3.3"
services:
  certd:
    image: registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest
    container_name: certd
    restart: unless-stopped
    volumes:
      - /volume1/docker/certd:/app/data:delegated
    ports:
      - "7001:7001"
      - "7002:7002"
    environment:
      - TZ=Asia/Shanghai
      - certd_koa_hostname=0.0.0.0
  1. 部署完成后访问 http://群晖 IP:7001,使用默认账号 admin/123456 登录,并第一时间修改密码。

如果你不希望暴露端口,可仅在内网访问 certd,或通过群晖反向代理映射到一个内网域名。

在 certd 中申请泛域名证书


1. 添加腾讯云授权

进入 certd 的授权管理,新增腾讯云授权,填写 SecretId 和 SecretKey。
这两个值可以复用你在 DDNS 配置时创建的密钥。

2. 创建证书申请流水线

进入证书流水线,新增 “证书申请(JS 版)”,按下面的方式填写:

  • 域名验证方式:DNS 直接验证。
  • 证书颁发机构:选择 “Let’s Encrypt”。
  • DNS 解析服务商:腾讯云。
  • 证书域名:填写主域名和泛域名,例如 example.com 与 *.example.com。
  • 更新天数:按需设置(默认值以界面为准),表示证书到期前多少天触发更新。

保存后不需要手动运行,后面设置定时触发后会自动执行。
如果你希望尽快拿到证书,可以把定时触发时间设置为当前时间后几分钟,让它自动跑首轮。

如果失败,多半是 DNS 生效时间不够。可以在流水线里把“等待解析生效时长”适当调大,再试一次。

将证书输出到固定路径

为了让脚本能拿到证书文件,需要把证书保存到 NAS 上一个固定目录。certd 的流水线里加一个“主机 - 复制到本机”任务即可。

  1. 打开对应流水线,新增任务 “主机 - 复制到本机”。
  2. 证书类型选择 pem。
  3. 路径建议这样填(相对路径会写到 /app/data 下):
    • 全链证书保存路径:tmp/fullchain.pem
    • 私钥保存路径:tmp/private.key
    • 中间证书保存路径:tmp/intermediate.pem

因为我在 docker-compose.yaml 里把 /volume1/docker/certd 映射到容器的 /app/data,所以上面三个文件最终会落在:

  • /volume1/docker/certd/tmp/fullchain.pem
  • /volume1/docker/certd/tmp/private.key
  • /volume1/docker/certd/tmp/intermediate.pem

你可以按自己的映射关系调整路径,但脚本里要用同一套路径。

用脚本导入证书到 DSM


(可选)为启用 2FA 的账户获取 device-id

如果你的 DSM 账户启用了两步验证(2FA),需要先获取一次 device-id,后续就可以用它来免 OTP 登录。

在你的本地电脑(或任何能访问 NAS 内网的设备)上执行以下命令,只需要执行一次(记得替换其中的参数):

curl -sS -k -G "https://192.168.1.10:5001/webapi/entry.cgi" \
  --data-urlencode "api=SYNO.API.Auth" \
  --data-urlencode "version=6" \
  --data-urlencode "method=login" \
  --data-urlencode "format=sid" \
  --data-urlencode "account=admin" \
  --data-urlencode "passwd=your-password" \
  --data-urlencode "otp_code=123456" \
  --data-urlencode "device_name=certd" \
  --data-urlencode "enable_device_token=yes" \
  --data-urlencode "enable_syno_token=yes" \
  | grep -oP '"device_id"\s*:\s*"\K[^"]+'

参数说明:

  • https://192.168.1.10:5001:你的 NAS 内网地址和端口
  • account:DSM 管理员账号
  • passwd:DSM 管理员密码
  • otp_code:从你的两步验证应用(如 Google Authenticator)获取的 6 位数字
  • device_name:自定义设备名称,用于在 DSM 中标识该设备,建议填 certd

命令会输出类似 aBcD1234eFgH5678 这样的字符串,这就是你的 device-id,记录下来供后续脚本使用。

如果命令执行失败或没有输出,检查:

  • OTP 码是否正确且未过期(有效期 30 秒)
  • 网络是否能访问到 NAS
  • 账号密码是否正确

准备导入脚本

首次使用前不需要手动准备证书记录。执行脚本时传入 --cert-desc,并加上 --create,脚本会在 DSM 里找不到同名描述时自动创建证书记录并导入证书。
如果你需要手动排查或验证,也可以在 DSM 里走“新增 → 新增新证书 → 导入证书”,导入一次后再交给脚本更新。

DSM 自带的 Let's Encrypt 需要公网 80 端口可达,且通常不支持 DNS-01 与泛域名,不适合本方案。

把下面脚本保存到 NAS 上,例如 /volume1/docker/certd/scripts/synology-import-cert.sh,无需修改脚本内容,所有配置都通过参数传入。

#!/usr/bin/env bash
set -euo pipefail

# 基于 DSM API 导入证书,并可选设置默认证书与服务绑定。
# 依赖 curl 和 awk 以及 sed,使用 --assign-services 时需要安装 Python3 套件。

usage() {
  cat <<'EOF'
用法:
  synology-import-cert.sh --base-url URL --username USER --password PASS --cert-desc DESC --fullchain PATH --key PATH [选项]

必填参数:
  --base-url       DSM 地址,例如 https://192.168.1.10:5001
  --username       DSM 管理员账号
  --password       DSM 管理员密码
  --cert-desc      证书描述,用于定位或创建记录
  --fullchain      全链证书路径
  --key            私钥路径

可选参数:
  --create         证书描述不存在时自动创建
  --insecure       跳过 HTTPS 证书校验(等同 curl -k)
  --set-default    设置为系统默认证书
  --assign-services 绑定服务名称,逗号分隔,支持 all 表示全部
  --device-id      2FA 已信任设备 ID
  --device-name    设备名称(自定义),默认为 certd
  -h, --help       显示帮助

提示:
  使用 --assign-services 需要先安装 Python3 套件。
EOF
}

# 记录参数默认值,后续会被命令行参数覆盖。
SYNO_BASE_URL=""
SYNO_USERNAME=""
SYNO_PASSWORD=""
SYNO_CERT_DESC=""
CERT_FULLCHAIN=""
CERT_KEY=""
SYNO_CREATE="0"
SYNO_SET_DEFAULT="0"
SYNO_ASSIGN_SERVICES=""
SYNO_DEVICE_NAME="certd"
SYNO_DEVICE_ID=""
INSECURE="0"

# 统一错误输出,方便在流水线日志中定位问题。
fail() {
  echo "错误:$1" >&2
  exit 1
}

# 检查必填参数是否存在。
require_arg() {
  [ -n "$2" ] || fail "缺少参数:$1"
}

# 检查参数是否带值。
require_value() {
  local flag="$1"
  local value="${2:-}"
  [ -n "$value" ] || fail "参数 $flag 缺少值"
}

# 解析命令行参数。
while [ "$#" -gt 0 ]; do
  case "$1" in
    --base-url)
      require_value "$1" "${2:-}"
      SYNO_BASE_URL="$2"
      shift 2
      ;;
    --username)
      require_value "$1" "${2:-}"
      SYNO_USERNAME="$2"
      shift 2
      ;;
    --password)
      require_value "$1" "${2:-}"
      SYNO_PASSWORD="$2"
      shift 2
      ;;
    --cert-desc)
      require_value "$1" "${2:-}"
      SYNO_CERT_DESC="$2"
      shift 2
      ;;
    --fullchain)
      require_value "$1" "${2:-}"
      CERT_FULLCHAIN="$2"
      shift 2
      ;;
    --key)
      require_value "$1" "${2:-}"
      CERT_KEY="$2"
      shift 2
      ;;
    --device-name)
      require_value "$1" "${2:-}"
      SYNO_DEVICE_NAME="$2"
      shift 2
      ;;
    --device-id)
      require_value "$1" "${2:-}"
      SYNO_DEVICE_ID="$2"
      shift 2
      ;;
    --create)
      SYNO_CREATE="1"
      shift
      ;;
    --set-default)
      SYNO_SET_DEFAULT="1"
      shift
      ;;
    --assign-services)
      require_value "$1" "${2:-}"
      SYNO_ASSIGN_SERVICES="$2"
      shift 2
      ;;
    --insecure)
      INSECURE="1"
      shift
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      fail "未知参数:$1"
      ;;
  esac
done

# 校验证书文件是否存在。
require_file() {
  [ -f "$1" ] || fail "未找到文件:$1"
}

# 查找可用的 Python,用于解析 DSM 返回的 JSON。
find_python() {
  if command -v python3 >/dev/null 2>&1; then
    echo "python3"
    return 0
  fi
  if command -v python >/dev/null 2>&1; then
    echo "python"
    return 0
  fi
  return 1
}

# 生成服务绑定所需的 settings JSON。
# DSM 需要完整的 service 对象,因此要从证书列表里提取结构。
build_service_settings() {
  local services_csv="$1"
  local new_cert_id="$2"
  local python_bin

  python_bin=$(find_python) || fail "未找到 python3 或 python,请先在套件中心安装 Python3 套件后再使用 --assign-services"

  local tmp_err
  tmp_err=$(mktemp)
  local settings

  settings=$(
    printf "%s" "$cert_list" | "$python_bin" - "$services_csv" "$new_cert_id" 2>"$tmp_err" <<'PY'
import json
import sys

# 读取参数与证书列表。
services_arg = sys.argv[1].strip()
new_id = sys.argv[2]
data = json.load(sys.stdin)

# 建立服务名称到证书与服务信息的映射。
service_map = {}
for cert in data.get("data", {}).get("certificates", []):
    for service in cert.get("services", []) or []:
        name = service.get("display_name") or ""
        if not name:
            continue
        service_map[name] = {
            "service": service,
            "old_id": cert.get("id", "")
        }

if not service_map:
    sys.stderr.write("未获取到服务列表,请确认 DSM 返回的证书列表包含 services 字段。\n")
    sys.exit(2)

# 解析服务名称,支持 all 或 * 表示全部。
if services_arg.lower() in ("all", "*"):
    services = sorted(service_map.keys())
else:
    services = [s.strip() for s in services_arg.split(",") if s.strip()]

if not services:
    sys.stderr.write("服务名称不能为空。\n")
    sys.exit(2)

missing = [name for name in services if name not in service_map]
if missing:
    available = sorted(service_map.keys())
    sys.stderr.write("未找到服务名称:" + ", ".join(missing) + "\n")
    if available:
        sys.stderr.write("可选服务名称:" + ", ".join(available) + "\n")
    sys.exit(2)

# 生成 DSM 需要的 settings 数组结构。
settings = []
for name in services:
    info = service_map[name]
    settings.append({"service": info["service"], "old_id": info["old_id"], "id": new_id})

sys.stdout.write(json.dumps(settings, separators=(",", ":")))
PY
  ) || {
    local err
    err=$(cat "$tmp_err")
    rm -f "$tmp_err"
    [ -n "$err" ] || err="生成服务绑定参数失败"
    fail "$err"
  }

  rm -f "$tmp_err"
  printf "%s" "$settings"
}

# 检查必填参数。
require_arg "--base-url" "$SYNO_BASE_URL"
require_arg "--username" "$SYNO_USERNAME"
require_arg "--password" "$SYNO_PASSWORD"
require_arg "--cert-desc" "$SYNO_CERT_DESC"
require_arg "--fullchain" "$CERT_FULLCHAIN"
require_arg "--key" "$CERT_KEY"

# 校验证书文件路径。
require_file "$CERT_FULLCHAIN"
require_file "$CERT_KEY"

# 如果要绑定服务,提前确认 Python 可用。
if [ -n "$SYNO_ASSIGN_SERVICES" ]; then
  if ! find_python >/dev/null 2>&1; then
    fail "未找到 python3 或 python,请先在套件中心安装 Python3 套件后再使用 --assign-services"
  fi
fi

# 组装 curl 参数,可选跳过 HTTPS 证书校验。
CURL_ARGS=(-sS)
# 如需忽略证书校验,则追加 -k。
if [ "$INSECURE" = "1" ]; then
  CURL_ARGS+=(-k)
fi

# 获取 DSM API 入口路径与版本,兼容不同 DSM 版本。
auth_info=$(curl "${CURL_ARGS[@]}" "$SYNO_BASE_URL/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query&query=SYNO.API.Auth")
api_path=$(echo "$auth_info" | sed -n 's/.*"path" *: *"\([^"]*\)".*/\1/p')
api_version=$(echo "$auth_info" | sed -n 's/.*"maxVersion" *: *\([0-9]\+\).*/\1/p')

# 如果返回为空,使用常见默认值兜底。
[ -n "$api_path" ] || api_path="entry.cgi"
[ -n "$api_version" ] || api_version="6"

# 登录获取 sid 与 SynoToken,后续接口都依赖它们。
login_args=(
  "${CURL_ARGS[@]}" -G "$SYNO_BASE_URL/webapi/$api_path"
  --data-urlencode "api=SYNO.API.Auth"
  --data-urlencode "version=$api_version"
  --data-urlencode "method=login"
  --data-urlencode "format=sid"
  --data-urlencode "account=$SYNO_USERNAME"
  --data-urlencode "passwd=$SYNO_PASSWORD"
  --data-urlencode "enable_syno_token=yes"
)

# 已信任设备登录,适合自动化场景。
if [ -n "$SYNO_DEVICE_ID" ]; then
  login_args+=(--data-urlencode "device_id=$SYNO_DEVICE_ID" --data-urlencode "device_name=$SYNO_DEVICE_NAME")
fi

login_response=$(curl "${login_args[@]}")

# 简单判断是否有错误码返回。
if echo "$login_response" | grep -q '"error"'; then
  error_code=$(echo "$login_response" | sed -n 's/.*"code":\([0-9]\+\).*/\1/p')
  fail "登录失败,错误码:$error_code"
fi

# 解析会话标识与 Token。
sid=$(echo "$login_response" | sed -n 's/.*"sid" *: *"\([^"]*\)".*/\1/p')
token=$(echo "$login_response" | sed -n 's/.*"synotoken" *: *"\([^"]*\)".*/\1/p')

[ -n "$sid" ] || fail "未获取到 sid"
[ -n "$token" ] || fail "未获取到 SynoToken"

# 获取证书列表,用于定位证书 ID 与服务列表。
cert_list=$(curl "${CURL_ARGS[@]}" -X POST "$SYNO_BASE_URL/webapi/entry.cgi" \
  -H "X-SYNO-TOKEN: $token" \
  --data "api=SYNO.Core.Certificate.CRT" \
  --data "method=list" \
  --data "version=1" \
  --data "_sid=$sid")

if echo "$cert_list" | grep -q '"error"'; then
  fail "获取证书列表失败,请检查账号权限。"
fi

# 基于证书描述匹配证书 ID,避免额外 JSON 解析依赖。
escaped_desc=$(printf "%s" "$SYNO_CERT_DESC" | sed 's/\([].*^$[]\)/\\\1/g;s/"/\\\\"/g')
cert_id=$(echo "$cert_list" | sed -n "s/.*\\\"desc\\\":\\\"$escaped_desc\\\",\\\"id\\\":\\\"\\([^\"]*\\)\\\".*/\\1/p")

if [ -z "$cert_id" ]; then
  if [ "${SYNO_CREATE:-}" = "1" ]; then
    echo "提示:未找到证书描述,将自动创建记录:$SYNO_CERT_DESC"
  else
    fail "未找到证书描述:$SYNO_CERT_DESC,请先在 DSM 中创建同名证书记录。"
  fi
fi

# 记录当前证书是否是默认值,便于复用。
is_default=$(echo "$cert_list" | sed -n "s/.*\\\"desc\\\":\\\"$escaped_desc\\\"[^}]*\\\"is_default\\\":\\(true\\|false\\).*/\\1/p")

# DSM 导入接口要求叶子证书与中间证书分开。
tmp_leaf=$(mktemp)
tmp_intermediate=$(mktemp)
trap 'rm -f "$tmp_leaf" "$tmp_intermediate"' EXIT

: > "$tmp_leaf"
: > "$tmp_intermediate"

awk '/-----BEGIN CERTIFICATE-----/ {n++} { if (n == 1) print > leaf; else print > inter }' \
  leaf="$tmp_leaf" inter="$tmp_intermediate" "$CERT_FULLCHAIN"

# 上传证书并可选设置为默认。
upload_url="$SYNO_BASE_URL/webapi/entry.cgi?api=SYNO.Core.Certificate&method=import&version=1&SynoToken=$token&_sid=$sid"
upload_args=(
  "${CURL_ARGS[@]}" -X POST "$upload_url"
  -H "X-SYNO-TOKEN: $token"
  -F "key=@$CERT_KEY"
  -F "cert=@$tmp_leaf"
  -F "inter_cert=@$tmp_intermediate"
  -F "id=$cert_id"
  -F "desc=$SYNO_CERT_DESC"
)

# 如果显式设置默认,或原记录已是默认,则保持默认状态。
if [ "$SYNO_SET_DEFAULT" = "1" ] || [ "$is_default" = "true" ]; then
  upload_args+=(-F "as_default=true")
fi

upload_response=$(curl "${upload_args[@]}")

if echo "$upload_response" | grep -q '"success":true'; then
  echo "证书导入完成。"
  new_cert_id=$(echo "$upload_response" | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p')
  [ -n "$new_cert_id" ] && cert_id="$new_cert_id"
else
  fail "证书导入失败:$upload_response"
fi

# 可选:再次调用接口设置系统默认证书。
if [ "$SYNO_SET_DEFAULT" = "1" ]; then
  [ -n "$cert_id" ] || fail "未获取到证书 ID,无法设置默认证书"
  default_id="\"$cert_id\""
  default_desc="\"\""
  set_default_response=$(curl "${CURL_ARGS[@]}" -X POST "$SYNO_BASE_URL/webapi/entry.cgi" \
    -H "X-SYNO-TOKEN: $token" \
    --data-urlencode "api=SYNO.Core.Certificate.CRT" \
    --data-urlencode "method=set" \
    --data-urlencode "version=1" \
    --data-urlencode "_sid=$sid" \
    --data-urlencode "id=$default_id" \
    --data-urlencode "desc=$default_desc" \
    --data-urlencode "as_default=true")

  if echo "$set_default_response" | grep -q '"success":true'; then
    echo "默认证书已更新。"
  else
    fail "设置默认证书失败:$set_default_response"
  fi
fi

# 可选:绑定服务证书。
if [ -n "$SYNO_ASSIGN_SERVICES" ]; then
  [ -n "$cert_id" ] || fail "未获取到证书 ID,无法绑定服务"
  settings_json=$(build_service_settings "$SYNO_ASSIGN_SERVICES" "$cert_id")
  assign_response=$(curl "${CURL_ARGS[@]}" -X POST "$SYNO_BASE_URL/webapi/entry.cgi" \
    -H "X-SYNO-TOKEN: $token" \
    --data-urlencode "api=SYNO.Core.Certificate.Service" \
    --data-urlencode "method=set" \
    --data-urlencode "version=1" \
    --data-urlencode "_sid=$sid" \
    --data-urlencode "settings=$settings_json")

  if echo "$assign_response" | grep -q '"success":true'; then
    echo "服务绑定完成。"
  else
    fail "服务绑定失败:$assign_response"
  fi
fi

# 登出释放会话。
curl "${CURL_ARGS[@]}" -G "$SYNO_BASE_URL/webapi/$api_path" \
  --data-urlencode "api=SYNO.API.Auth" \
  --data-urlencode "version=$api_version" \
  --data-urlencode "method=logout" \
  --data-urlencode "_sid=$sid" >/dev/null 2>&1 || true

赋予脚本可执行权限:

chmod +x /volume1/docker/certd/scripts/synology-import-cert.sh

补充说明:

  • 默认不跳过 HTTPS 证书校验。需要忽略校验时添加 --insecure,避免首次接入时因自签证书或域名不匹配而登录失败。
  • --create 表示当证书描述不存在时自动创建记录,这样就不需要手动导入占位证书。该参数名称与行为来自 acme.sh 的 Synology 部署脚本。
  • --set-default 会把证书设置为系统默认证书,适合用来消除首次手动设置的步骤。
  • --assign-services 用于绑定服务名称,名称需要与 API 返回的 display_name 一致;如果不匹配,脚本会提示可选列表。支持 all 表示全部服务。该功能依赖 python3 或 python。
  • 如果账号启用了 2FA,需要使用前面获取的 --device-id 参数。--device-name 有默认值 certd,如需自定义可以传递该参数。未开启 2FA 时,这两个参数都不需要。你也可以为证书更新新建一个不启用 2FA 的管理员账号。

让流水线自动执行脚本

要做到全自动,需要让 certd 在证书更新后自动执行脚本。做法是给流水线增加一个 “主机 - 执行远程主机脚本命令” 任务。

  1. 在 certd 的 “授权管理” 里新增 SSH 授权,主机填 NAS 地址,端口填你的 SSH 端口(可在 DSM 的 “控制面板 → 终端机和 SNMP → 终端机” 中查看,默认为 22),账号使用管理员账户。
    如果你之前没开 SSH,可以在 DSM 的 “控制面板 → 终端机和 SNMP → 终端机” 里开启。
  2. 回到证书流水线,在 “主机 - 复制到本机” 之后新增 “主机 - 执行远程主机脚本命令” 任务。
  3. 选择刚才的 SSH 授权,脚本命令填写:

如果启用了 2FA(使用前面获取的 device-id):

bash /volume1/docker/certd/scripts/synology-import-cert.sh \
  --base-url "https://192.168.1.10:5001" \
  --username "admin" \
  --password "your-password" \
  --cert-desc "nas-wildcard" \
  --fullchain "/volume1/docker/certd/tmp/fullchain.pem" \
  --key "/volume1/docker/certd/tmp/private.key" \
  --device-id "your-device-id" \
  --set-default \
  --assign-services "all" \
  --insecure \
  --create

参数说明:

  • 如果 DSM 证书尚未可信或域名不匹配,保留 --insecure。
  • 如果你希望一次性覆盖全部服务,保留 --assign-services all;也可以指定具体服务名称,例如 "DSM Desktop Service,Reverse Proxy"。

配置完成后,可以手动运行一次流水线测试。如果一切正常,证书会自动导入到 DSM。这样流水线每次拿到新证书后都会自动更新 DSM。

首次运行成功后,建议优化脚本配置:将 --base-url 改为你的域名,并去掉 --insecure 参数,这样可以正常校验 HTTPS 证书,更加安全:

bash /volume1/docker/certd/scripts/synology-import-cert.sh \
  --base-url "https://nas.example.com:5001" \
  --username "admin" \
  --password "your-password" \
  --cert-desc "nas-wildcard" \
  --fullchain "/volume1/docker/certd/tmp/fullchain.pem" \
  --key "/volume1/docker/certd/tmp/private.key" \
  --device-id "your-device-id" \
  --set-default \
  --assign-services "all" \
  --create

配置 Nginx 使用 SSL 证书

现在证书已经通过 certd 自动管理了,接下来需要让 Nginx 使用这些证书来支持 HTTPS 访问。我们使用 DSM 自带的 Nginx(参考第三篇文章)。

修改 Nginx 配置

DSM 自带的 Nginx 配置文件在 /etc/nginx/sites-enabled 目录下。

假设你在第三篇文章中为 calibre 配置了 calibre.example.com.conf,现在需要修改它以支持 HTTPS。

使用 SSH 连接到 NAS,编辑对应的配置文件:

sudo vim /etc/nginx/sites-enabled/calibre.example.com.conf

将原有的配置改为:

# HTTP 自动跳转到 HTTPS
server {
    listen 80;
    server_name calibre.example.com;
    return 301 https://$server_name$request_uri;
}

# HTTPS 配置
server {
    listen 443 ssl;
    server_name calibre.example.com;

    # SSL 证书配置
    ssl_certificate /volume1/docker/certd/tmp/fullchain.pem;
    ssl_certificate_key /volume1/docker/certd/tmp/private.key;

    # SSL 协议与加密套件
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Scheme $scheme;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
        proxy_pass http://172.17.0.1:8083;

        client_max_body_size 2000m;
    }
}

关键修改点:

  • 新增 80 端口的 server 块,自动跳转到 HTTPS
  • 原有的 server 块改为监听 443 端口,并启用 SSL
  • 使用 certd 导出的证书路径:/volume1/docker/certd/tmp/fullchain.pem 和 /volume1/docker/certd/tmp/private.key
  • 新增 X-Forwarded-Proto header,让后端应用知道使用的是 HTTPS

重启 Nginx

保存配置文件后,测试配置并重启 Nginx 使配置生效:

sudo /usr/bin/nginx -c /etc/nginx/nginx.conf.run -t
sudo /usr/bin/nginx -c /etc/nginx/nginx.conf.run -s reload

验证 HTTPS 是否生效

用浏览器访问 https://calibre.example.com,检查:

  • 浏览器地址栏显示小锁图标。
  • 证书信息显示正确的域名和颁发机构。
  • 页面能正常加载,没有证书警告。

如果有问题,可以查看 Nginx 日志:

sudo tail -f /var/log/nginx/error.log

常见问题:

  • 证书路径不对:确认 /volume1/docker/certd/tmp/ 下有 fullchain.pem 和 private.key 文件。
  • 权限问题:确保 Nginx 能读取证书文件,可以执行 sudo chmod 644 /volume1/docker/certd/tmp/*.pem /volume1/docker/certd/tmp/*.key。
  • 证书未更新:certd 更新证书后,Nginx 需要重启才能重新加载证书。你可以在流水线的 “主机 - 执行远程主机脚本命令” 任务中,在导入脚本后面加一个重启命令:
bash /volume1/docker/certd/scripts/synology-import-cert.sh \
  --base-url "https://nas.example.com:5001" \
  --username "admin" \
  --password "your-password" \
  --cert-desc "nas-wildcard" \
  --fullchain "/volume1/docker/certd/tmp/fullchain.pem" \
  --key "/volume1/docker/certd/tmp/private.key" \
  --device-id "your-device-id" \
  --set-default \
  --assign-services "all" \
  --create

# 重启 Nginx 以重新加载证书
sudo /usr/bin/nginx -c /etc/nginx/nginx.conf.run -s reload

续期与维护

Let’s Encrypt 证书有效期是 90 天。
要实现全自动,只需要在流水线里配置定时触发即可。

建议设置为每天执行一次,例如 0 0 3 * * *(每天凌晨 3 点)。
certd 会在证书到期前按照“更新天数”设定的天数自动申请新证书并继续执行后续部署任务,没到期前不会重复申请。默认值以界面为准。