Skip to content

Instantly share code, notes, and snippets.

@rakuyoMo
Last active January 23, 2026 07:15
Show Gist options
  • Select an option

  • Save rakuyoMo/68c3be53edbc9e5285fc2c33fb67fa49 to your computer and use it in GitHub Desktop.

Select an option

Save rakuyoMo/68c3be53edbc9e5285fc2c33fb67fa49 to your computer and use it in GitHub Desktop.
#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment