diff --git a/docker/Dockerfile b/docker/Dockerfile index 202ffca8..f7935543 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -30,6 +30,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git \ busybox \ tini \ + cron \ jq \ ripgrep \ less \ diff --git a/docker/cert.sh b/docker/cert.sh index 39a7c4ab..253174da 100644 --- a/docker/cert.sh +++ b/docker/cert.sh @@ -1,5 +1,14 @@ #!/bin/bash +CERT_ERREXIT_WAS_SET=false +CERT_PIPEFAIL_WAS_SET=false +if [[ "$-" == *e* ]]; then + CERT_ERREXIT_WAS_SET=true +fi +if shopt -qo pipefail; then + CERT_PIPEFAIL_WAS_SET=true +fi set -e +set -o pipefail Green="\033[32m" Red="\033[31m" @@ -18,110 +27,206 @@ function WARN() { echo -e "${WARN} ${1}" } +CERT_DIR="${CONFIG_DIR}/certs" +ACME_HOME="${CONFIG_DIR}/acme.sh" +ACME_DATA_DIR="${ACME_HOME}/data" +ACME_CERT_DIR="${CERT_DIR}/${SSL_DOMAIN}" +ACME_LATEST_CERT_DIR="${CERT_DIR}/latest" +NGINX_RELOAD_CMD="nginx -s reload 2>/dev/null || true" + +# 恢复调用方原有 shell 选项,避免 source 本脚本后影响 entrypoint 后续流程。 +function restore_shell_options() { + if ! ${CERT_PIPEFAIL_WAS_SET}; then + set +o pipefail + fi + if ! ${CERT_ERREXIT_WAS_SET}; then + set +e + fi +} + +# 输出错误并恢复调用方 shell 选项,确保 source 失败时不会污染后续流程。 +function exit_with_error() { + ERROR "$1" + restore_shell_options + exit 1 +} + +# 使用固定的 acme.sh 工作目录执行命令,避免签发、安装和续期读取不同配置目录。 +function run_acme() { + LE_WORKING_DIR="${ACME_HOME}" \ + LE_CONFIG_HOME="${ACME_DATA_DIR}" \ + LE_CERT_HOME="${CERT_DIR}" \ + "${ACME_HOME}/acme.sh" --home "${ACME_HOME}" "$@" +} + +# 维护 nginx 使用的稳定证书目录链接,兼容手动证书和自动签发证书路径。 +function link_latest_cert_dir() { + if [ -e "${ACME_LATEST_CERT_DIR}" ] && [ ! -L "${ACME_LATEST_CERT_DIR}" ]; then + rm -rf "${ACME_LATEST_CERT_DIR}" + fi + ln -sfn "${ACME_CERT_DIR}" "${ACME_LATEST_CERT_DIR}" +} + +# 配置证书自动续期;续期任务失败不应阻断已有证书启动。 +function configure_cert_renewal() { + if ! command -v cron >/dev/null 2>&1; then + WARN "未安装 cron,跳过证书自动续期任务配置" + return 0 + fi + + if ! mkdir -p /etc/cron.d 2>/dev/null; then + WARN "无法创建 /etc/cron.d,跳过证书自动续期任务配置" + return 0 + fi + + if ! printf "0 3 * * * root LE_WORKING_DIR=%q LE_CONFIG_HOME=%q LE_CERT_HOME=%q %q --cron --home %q\n" \ + "${ACME_HOME}" \ + "${ACME_DATA_DIR}" \ + "${CERT_DIR}" \ + "${ACME_HOME}/acme.sh" \ + "${ACME_HOME}" > /etc/cron.d/acme 2>/dev/null; then + WARN "无法写入 /etc/cron.d/acme,跳过证书自动续期任务配置" + return 0 + fi + + if ! chmod 644 /etc/cron.d/acme 2>/dev/null; then + WARN "无法设置 /etc/cron.d/acme 权限,证书自动续期任务可能不会生效" + return 0 + fi + + if ! pgrep -x cron >/dev/null 2>&1 && ! cron 2>/dev/null; then + WARN "cron 启动失败,证书自动续期任务可能不会生效" + fi +} + # 核心条件验证 if [ "${ENABLE_SSL}" = "true" ] && \ [ "${AUTO_ISSUE_CERT}" = "true" ] && \ [ -n "${SSL_DOMAIN}" ]; then # 创建证书目录 - mkdir -p /config/certs/"${SSL_DOMAIN}" - chown moviepilot:moviepilot /config/certs -R + mkdir -p "${ACME_CERT_DIR}" + if id moviepilot >/dev/null 2>&1; then + chown moviepilot:moviepilot "${CERT_DIR}" -R + fi # 安装acme.sh(使用官方安装脚本) - if [ ! -d "/config/acme.sh" ]; then + if [ ! -f "${ACME_HOME}/acme.sh" ]; then INFO "→ 安装acme.sh..." - # 设置安装环境变量 - export LE_WORKING_DIR="/config/acme.sh" - export LE_CONFIG_HOME="/config/acme.sh/data" - export LE_CERT_HOME="/config/certs" - # 执行官方安装命令(添加错误处理) INFO "正在下载并安装 acme.sh..." - # 构建安装命令 - INSTALL_CMD="curl -sSL https://get.acme.sh | sh -s -- --install-online" + install_args=("--install-online") if [ -n "${SSL_EMAIL}" ]; then - INSTALL_CMD="${INSTALL_CMD} --accountemail ${SSL_EMAIL}" + install_args+=("--accountemail" "${SSL_EMAIL}") else WARN "未设置SSL_EMAIL,建议配置邮箱用于证书过期提醒" fi - if ! eval "${INSTALL_CMD}"; then - ERROR "acme.sh 安装失败" - exit 1 + if ! curl -sSL https://get.acme.sh | \ + LE_WORKING_DIR="${ACME_HOME}" \ + LE_CONFIG_HOME="${ACME_DATA_DIR}" \ + LE_CERT_HOME="${CERT_DIR}" \ + sh -s -- "${install_args[@]}"; then + exit_with_error "acme.sh 安装失败" fi # 验证安装是否成功 - if [ ! -f "/config/acme.sh/acme.sh" ]; then - ERROR "acme.sh 安装后文件不存在,安装可能失败" - exit 1 + if [ ! -f "${ACME_HOME}/acme.sh" ]; then + exit_with_error "acme.sh 安装后文件不存在,安装可能失败" fi INFO "acme.sh 安装成功" fi # 签发证书(仅当证书不存在时) - if [ ! -f "/config/certs/${SSL_DOMAIN}/fullchain.pem" ]; then + if [ ! -f "${ACME_CERT_DIR}/fullchain.pem" ] || [ ! -f "${ACME_CERT_DIR}/privkey.pem" ]; then # 必要参数检查 REQUIRED_VARS=("DNS_PROVIDER") for var in "${REQUIRED_VARS[@]}"; do eval "value=\${${var}}" - [ -z "$value" ] && { ERROR "必须设置环境变量: ${var}"; exit 1; } + [ -z "$value" ] && exit_with_error "必须设置环境变量: ${var}" done INFO "→ 签发证书: ${SSL_DOMAIN} (DNS验证方式: ${DNS_PROVIDER})" # 加载ACME环境变量(带安全过滤) - INFO "正在加载ACME环境变量..." - env | grep '^ACME_ENV_' | while read -r line; do - key="${line#ACME_ENV_}" - key="${key%%=*}" - value="${line#ACME_ENV_${key}=}" + acme_exported_vars=() + acme_original_keys=() + acme_original_values=() + acme_had_original_values=() + while IFS= read -r var_name; do + [ -z "${var_name}" ] && continue + key="${var_name#ACME_ENV_}" + value="${!var_name}" # 过滤非法变量名 if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + acme_original_keys+=("${key}") + if eval "[ -n \"\${${key}+x}\" ]"; then + acme_had_original_values+=("true") + acme_original_values+=("$(eval "printf '%s' \"\${${key}}\"")") + else + acme_had_original_values+=("false") + acme_original_values+=("") + fi export "$key"="$value" - INFO "已加载环境变量: ${key}=******" + acme_exported_vars+=("${key}") else WARN "跳过无效变量名: ${key}" fi - done + done < <(compgen -A variable ACME_ENV_ || true) # 签发证书(添加错误处理) INFO "正在签发证书..." - if ! /config/acme.sh/acme.sh --issue \ + if ! run_acme --issue \ --dns "${DNS_PROVIDER}" \ --domain "${SSL_DOMAIN}" \ - --key-file /config/certs/"${SSL_DOMAIN}"/privkey.pem \ - --fullchain-file /config/certs/"${SSL_DOMAIN}"/fullchain.pem \ - --reloadcmd "nginx -s reload" \ --force; then - ERROR "证书签发失败" - exit 1 + exit_with_error "证书签发失败" fi + INFO "正在安装证书文件..." + if ! run_acme --install-cert \ + --domain "${SSL_DOMAIN}" \ + --key-file "${ACME_CERT_DIR}/privkey.pem" \ + --fullchain-file "${ACME_CERT_DIR}/fullchain.pem" \ + --reloadcmd "${NGINX_RELOAD_CMD}"; then + exit_with_error "证书安装失败" + fi + + for index in "${!acme_original_keys[@]}"; do + var_name="${acme_original_keys[$index]}" + if [ "${acme_had_original_values[$index]}" = "true" ]; then + export "${var_name}=${acme_original_values[$index]}" + else + unset "${var_name}" + fi + done + # 创建稳定符号链接 - ln -sf /config/certs/"${SSL_DOMAIN}" /config/certs/latest + link_latest_cert_dir INFO "证书签发成功" else + link_latest_cert_dir INFO "证书已存在,跳过签发步骤" fi # 配置自动更新任务 INFO "→ 配置cron自动更新..." - echo "0 3 * * * /config/acme.sh/acme.sh --cron --home /config/acme.sh && nginx -s reload" > /etc/cron.d/acme - chmod 644 /etc/cron.d/acme - service cron start + configure_cert_renewal elif [ "${ENABLE_SSL}" = "true" ] && [ "${AUTO_ISSUE_CERT}" = "true" ] && [ -z "${SSL_DOMAIN}" ]; then - WARN "已启用自动签发证书但未设置SSL_DOMAIN,跳过证书管理" + exit_with_error "已启用自动签发证书但未设置 SSL_DOMAIN,无法生成 HTTPS 证书" elif [ "${ENABLE_SSL}" = "true" ] && [ "${AUTO_ISSUE_CERT}" = "false" ]; then INFO "SSL已启用但自动签发证书已禁用,将使用手动配置的证书" # 检查证书文件是否存在 - if [ -f "/config/certs/latest/fullchain.pem" ] && [ -f "/config/certs/latest/privkey.pem" ]; then + if [ -f "${ACME_LATEST_CERT_DIR}/fullchain.pem" ] && [ -f "${ACME_LATEST_CERT_DIR}/privkey.pem" ]; then INFO "检测到证书文件,SSL配置正常" else - WARN "未检测到证书文件,请确保手动配置了正确的证书路径" + exit_with_error "未检测到证书文件,请将 fullchain.pem 和 privkey.pem 放入 ${ACME_LATEST_CERT_DIR}" fi -fi \ No newline at end of file +fi + +restore_shell_options diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0822a5a5..7ee406dd 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -27,9 +27,6 @@ export PATH="${VENV_PATH}/bin:$PATH" # 校正设置目录 CONFIG_DIR="${CONFIG_DIR:-/config}" -# 记录非系统环境(docker容器表)提供的变量 -declare -ga VARS_SET_BY_SCRIPT=() - # 环境变量补全 # 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值 # 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义) @@ -38,7 +35,7 @@ function load_config_from_app_env() { local env_file="${CONFIG_DIR}/app.env" # 定义 ["变量名"]="预设默认值" - # 禁止填入 CONFIG_DIR 变量,ACME_ENV_ 开头的变量暂时不处理,还是交由 cert.sh 处理 + # 禁止填入 CONFIG_DIR 变量,ACME_ENV_ 开头的变量不设默认值,仅透传 app.env 中已有配置。 declare -A vars_and_default_values=( # update.sh ["PIP_PROXY"]="" @@ -48,19 +45,13 @@ function load_config_from_app_env() { ["MOVIEPILOT_AUTO_UPDATE"]="release" ["BROWSER_EMULATION"]="cloakbrowser" - # database - ["DB_TYPE"]="sqlite" - ["DB_POSTGRESQL_HOST"]="localhost" - ["DB_POSTGRESQL_PORT"]="5432" - ["DB_POSTGRESQL_DATABASE"]="moviepilot" - ["DB_POSTGRESQL_USERNAME"]="moviepilot" - ["DB_POSTGRESQL_PASSWORD"]="moviepilot" - ["DB_POSTGRESQL_POOL_SIZE"]="20" - ["DB_POSTGRESQL_MAX_OVERFLOW"]="30" - # cert ["ENABLE_SSL"]="false" + ["AUTO_ISSUE_CERT"]="false" ["SSL_DOMAIN"]="" + ["SSL_EMAIL"]="" + ["DNS_PROVIDER"]="" + ["SSL_NGINX_PORT"]="443" ["NGINX_PORT"]="3000" ["PORT"]="3001" ["NGINX_CLIENT_MAX_BODY_SIZE"]="50m" @@ -83,7 +74,7 @@ function load_config_from_app_env() { key_in_file="${BASH_REMATCH[1]}" value_raw_in_file="${BASH_REMATCH[2]}" - if [[ -n "${vars_and_default_values[$key_in_file]+_}" ]]; then + if [[ -n "${vars_and_default_values[$key_in_file]+_}" || "${key_in_file}" == ACME_ENV_* ]]; then local temp_val_after_initial_trim temp_val_after_initial_trim="${value_raw_in_file#"${value_raw_in_file%%[![:space:]]*}"}" temp_val_after_initial_trim="${temp_val_after_initial_trim%"${temp_val_after_initial_trim##*[![:space:]]}"}" @@ -120,20 +111,16 @@ function load_config_from_app_env() { INFO "${env_file} 文件不存在,跳过文件加载。" fi - INFO "正在根据优先级确定并导出配置值..." for var_name in "${!vars_and_default_values[@]}"; do local fallback_value="${vars_and_default_values[$var_name]}" local final_value local value_source="未设置" - # 标志变量是否来自初始环境 - local set_by_initial_env=false # 检查变量是否在环境中已设置(可能为空) if eval "[ -n \"\${${var_name}+x}\" ]"; then # 获取其值 final_value="$(eval echo \"\$"${var_name}"\")" value_source="系统环境变量" - set_by_initial_env=true elif [[ -n "${values_from_env_file["${var_name}"]+_}" ]]; then final_value="${values_from_env_file["${var_name}"]}" value_source=".env 文件" @@ -142,31 +129,20 @@ function load_config_from_app_env() { value_source="内置默认值" fi - # 不论来源如何,都导出变量,以便脚本的其余部分和子进程使用 - # (例如 envsubst, mp_update.sh, cert.sh) - if declare -gx "${var_name}=${final_value}"; then - if [ -z "${final_value}" ]; then - INFO "变量 ${var_name}, 值为空 (来源: ${value_source})。" - else - INFO "变量 ${var_name}, 值: ${final_value} (来源: ${value_source})。" - fi + if ! declare -g "${var_name}=${final_value}"; then + ERROR "设置变量 ${var_name}, 值: '${final_value}'失败 (来源: ${value_source}) " + fi + done - # 如果变量不是来自初始环境变量,则记录下来以便稍后 unset - if ! ${set_by_initial_env}; then - # 检查是否已在数组中,避免重复添加 - local found_in_script_vars=false - for item in "${VARS_SET_BY_SCRIPT[@]}"; do - if [[ "$item" == "$var_name" ]]; then - found_in_script_vars=true - break - fi - done - if ! ${found_in_script_vars}; then - VARS_SET_BY_SCRIPT+=("${var_name}") - fi - fi - else - ERROR "导出变量 ${var_name}, 值: '${final_value}'失败 (来源: ${value_source}) " + for var_name in "${!values_from_env_file[@]}"; do + if [[ "${var_name}" != ACME_ENV_* ]]; then + continue + fi + if eval "[ -n \"\${${var_name}+x}\" ]"; then + continue + fi + if ! declare -g "${var_name}=${values_from_env_file["${var_name}"]}"; then + ERROR "设置变量 ${var_name} 失败 (来源: .env 文件) " fi done @@ -174,6 +150,46 @@ function load_config_from_app_env() { INFO "配置加载流程执行完毕。" } +# 生成 nginx 配置,仅为 envsubst 单次调用传入模板变量。 +function render_nginx_config() { + local https_server_conf + if [ "${ENABLE_SSL}" = "true" ]; then + https_server_conf=$(cat < /etc/nginx/nginx.conf +} + # 优雅退出 function graceful_exit() { local exit_code=${1:-0} @@ -265,52 +281,21 @@ if [ -f "${ONE_SHOT_UPDATE_FLAG}" ]; then fi if [ "${ONE_SHOT_UPDATE_MODE}" = "release" ] || [ "${ONE_SHOT_UPDATE_MODE}" = "dev" ]; then INFO "检测到一次性升级标记,本次启动将执行 ${ONE_SHOT_UPDATE_MODE} 升级..." - export MOVIEPILOT_AUTO_UPDATE="${ONE_SHOT_UPDATE_MODE}" + MOVIEPILOT_AUTO_UPDATE="${ONE_SHOT_UPDATE_MODE}" ONE_SHOT_UPDATE_APPLIED="true" elif [ -n "${ONE_SHOT_UPDATE_MODE}" ]; then WARN "检测到无效的一次性升级模式:${ONE_SHOT_UPDATE_MODE},已忽略" fi fi -# 生成HTTPS配置块 -if [ "${ENABLE_SSL}" = "true" ]; then - export HTTPS_SERVER_CONF=$(cat < /etc/nginx/nginx.conf +# 使用env配置渲染 nginx 配置 +render_nginx_config # 自动更新 cd / source /usr/local/bin/mp_update.sh if [ "${ONE_SHOT_UPDATE_APPLIED}" = "true" ]; then - export MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}" + MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}" fi cd /app || exit @@ -375,22 +360,6 @@ fi # 设置后端服务权限掩码 umask "${UMASK}" -# 清除非系统环境导入的变量,保证转移到 dumb-init 的时候,不会带入不必要的环境变量 -INFO "准备为 Python 应用清理的非系统环境导入的变量..." -if [ ${#VARS_SET_BY_SCRIPT[@]} -gt 0 ]; then - for var_to_unset in "${VARS_SET_BY_SCRIPT[@]}"; do - # 再次确认变量确实存在于当前环境中(虽然理论上应该存在) - if eval "[ -n \"\${${var_to_unset}+x}\" ]"; then - INFO "取消设置环境变量: ${var_to_unset}" - unset "${var_to_unset}" - else - WARN "变量 ${var_to_unset} 已不存在,无需取消设置。" - fi - done -else - INFO "没有由非系统环境导入的变量需要清理。" -fi - # 启动后端服务 INFO "→ 启动后端服务..." if [ "${START_NOGOSU:-false}" = "true" ]; then