Debian12下的一键布署脚本 lnmpw ssl

2026年03月30日

原创内容,转载请注明出处:https://www.myzhenai.com.cn/post/4932.html

这里的lnmp不是军哥的那个lnmp.org的一键脚本,而是linux下自动安装nginx、mysql、php的一键脚本,w则是自动安装wordpress

install_lnmp_wp.sh

作用:在 Debian/Ubuntu 上以 root 交互安装一套 LNMP + WordPress 一键环境。

流程概要

步骤 内容
交互收集 网站目录、运行用户/组(默认 www)、MySQL root 与 WP 库用户/库名、PHP 版本(如 8.3)、WP 下载 URL、域名、是否在已有 MySQL 数据时自动备份清理
0 apt 装基础包:nginx、编译依赖等
1 建用户/组、站点目录与权限
2 加 Sury PHP 源,装 php-fpm 及常用扩展,并把 FPM pool 改成站点用户
3 下载 MySQL 8.0.45 官方 deb 包、debconf 预置 root 密码、安装并起服务;建库、建 WP 用户(localhost + 127.0.0.1),mysql_native_password
4 Nginx user 改为站点用户,为域名写 仅 HTTP 80 的 server,PHP 走对应 php-fpm.sock
5 下载 WP 中文版、解压到站点、wp-config.php 填库信息
6–9 检查服务、打印信息、可选写 info.php(提醒上线前删除)

特点set -euo pipefail、步骤失败时 trap 报当前步骤;适合新机器从零搭 WP;不自动 HTTPS(脚本末尾提示自行配 SSL)。

脚本中容易出错的是Mysql的安装,因为Debian12的官方源中已经删除了Mysql8.0的一些版本,还有一些说明是Debian12还没有完全支持Mysql8.0,所以我们只能是通过从Mysql官网下载指定文件包来进行操作。

注:此脚本只能安装amd64架构的,arm我没试过,但好像Mysql8.0的安装包不适用。如果脚本在线不能进行安装,请下载离线包装包放到与脚本同目录下进行安装。

通过网盘分享的文件:lnmpw
链接: https://pan.baidu.com/s/1Fx2wOAHv6i1m_oM373BJvw?pwd=uqrs 提取码: uqrs
–来自百度网盘超级会员v10的分享

安装前,请在脚本collect_inputs()里填入相关信息,否则会安装出错。

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

# ===================== 交互输入参数(运行时赋值) =====================
WEB_DIR=""
WEB_USER=""
WEB_GROUP=""
DOMAIN=""
MYSQL_ROOT_PASS=""
MYSQL_DB=""
MYSQL_USER=""
MYSQL_PASS=""
PHP_VERSION=""
WP_MAIN_URL=""
WP_BACKUP_URL=""
AUTO_BACKUP_OLD_MYSQL_DATA=""
# ==================================================================

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

STEP=""
trap 'echo -e "\n${RED}❌ 步骤 ${STEP} 执行失败${NC}"; exit 1' ERR

if [[ $EUID -ne 0 ]]; then
  echo -e "${RED}❌ 请用 root 执行${NC}"
  exit 1
fi

export DEBIAN_FRONTEND=noninteractive

step() {
  STEP="$1"
  echo -e "\n${GREEN}===== ${STEP} =====${NC}"
}

prompt_required() {
  local var_name="$1"
  local prompt_text="$2"
  local input
  while true; do
    read -r -p "${prompt_text}: " input
    if [ -n "${input}" ]; then
      eval "${var_name}=\"\${input}\""
      return
    fi
    echo -e "${YELLOW}⚠️  不能为空,请重新输入。${NC}"
  done
}

prompt_default() {
  local var_name="$1"
  local prompt_text="$2"
  local default_value="$3"
  local input
  read -r -p "${prompt_text} [默认: ${default_value}]: " input
  input="${input:-$default_value}"
  eval "${var_name}=\"\${input}\""
}

prompt_yesno_default() {
  local var_name="$1"
  local prompt_text="$2"
  local default_value="$3"
  local input
  while true; do
    read -r -p "${prompt_text} [y/n, 默认: ${default_value}]: " input
    input="${input:-$default_value}"
    case "${input}" in
      y|Y) eval "${var_name}=1"; return ;;
      n|N) eval "${var_name}=0"; return ;;
      *) echo -e "${YELLOW}⚠️  请输入 y 或 n。${NC}" ;;
    esac
  done
}

collect_inputs() {
  step "参数输入"
  echo -e "${GREEN}请按提示输入安装参数(回车可使用默认值)${NC}"

  prompt_required WEB_DIR "请输入网站路径(例如 /home/wwwroot/example.com)"
  prompt_default WEB_USER "请输入网站用户" "www"
  prompt_default WEB_GROUP "请输入网站用户组" "www"
  prompt_required MYSQL_ROOT_PASS "请输入 MySQL root 密码"
  prompt_default MYSQL_USER "请输入 WordPress 数据库用户" "wordpress"
  prompt_required MYSQL_PASS "请输入 WordPress 数据库密码"
  prompt_default MYSQL_DB "请输入 WordPress 数据库名" "wordpress"
  prompt_default PHP_VERSION "请输入 PHP 版本(8.3 或 8.0)" "8.3"
  prompt_default WP_MAIN_URL "请输入 WordPress 主下载地址" "https://cn.wordpress.org/latest-zh_CN.tar.gz"
  prompt_default WP_BACKUP_URL "请输入 WordPress 备用下载地址" "https://wordpress.org/latest-zh_CN.tar.gz"
  prompt_yesno_default AUTO_BACKUP_OLD_MYSQL_DATA "检测到旧 MySQL 数据目录时自动备份并清理" "y"
  prompt_default DOMAIN "请输入站点域名(server_name)" "localhost"

  if [ "${WEB_DIR#/}" = "${WEB_DIR}" ]; then
    echo -e "${RED}❌ 网站路径必须是绝对路径(以 / 开头)${NC}"
    exit 1
  fi

  echo -e "\n${GREEN}参数确认:${NC}"
  echo "WEB_DIR=${WEB_DIR}"
  echo "WEB_USER=${WEB_USER}"
  echo "WEB_GROUP=${WEB_GROUP}"
  echo "MYSQL_ROOT_PASS=******"
  echo "MYSQL_USER=${MYSQL_USER}"
  echo "MYSQL_PASS=******"
  echo "MYSQL_DB=${MYSQL_DB}"
  echo "PHP_VERSION=${PHP_VERSION}"
  echo "WP_MAIN_URL=${WP_MAIN_URL}"
  echo "WP_BACKUP_URL=${WP_BACKUP_URL}"
  echo "AUTO_BACKUP_OLD_MYSQL_DATA=${AUTO_BACKUP_OLD_MYSQL_DATA}"
  echo "DOMAIN=${DOMAIN}"
  read -r -p "确认开始安装吗?[y/N]: " confirm
  if [[ ! "${confirm}" =~ ^[Yy]$ ]]; then
    echo "已取消。"
    exit 0
  fi
}

# 0) 基础工具
collect_inputs
step "0/9 安装基础依赖"
apt update -y
apt install -y --no-install-recommends \
  wget curl ca-certificates lsb-release gnupg2 apt-transport-https \
  tar gzip nginx libaio1 libncurses6 libtinfo6 \
  gcc g++ make cmake libssl-dev debconf-utils
dpkg --configure -a || true

# 1) 创建 www:www 和站点目录
step "1/9 创建网站用户和目录"
getent group "${WEB_GROUP}" >/dev/null || groupadd -r "${WEB_GROUP}"
id -u "${WEB_USER}" >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin -M -g "${WEB_GROUP}" "${WEB_USER}"
mkdir -p "${WEB_DIR}"
chown -R "${WEB_USER}:${WEB_GROUP}" "${WEB_DIR}"
chmod 755 "${WEB_DIR}"

# 2) 安装 PHP 8.3(sury)
step "2/9 安装 PHP ${PHP_VERSION}"
curl -fsSL -o /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb
dpkg -i /tmp/debsuryorg-archive-keyring.deb

cat >/etc/apt/sources.list.d/php.sources <<EOF
Types: deb
URIs: https://packages.sury.org/php/
Suites: $(lsb_release -sc)
Components: main
Signed-By: /usr/share/keyrings/debsuryorg-archive-keyring.gpg
EOF

apt update -y
apt install -y --no-install-recommends \
  php${PHP_VERSION} php${PHP_VERSION}-fpm php${PHP_VERSION}-mysql \
  php${PHP_VERSION}-curl php${PHP_VERSION}-gd php${PHP_VERSION}-mbstring \
  php${PHP_VERSION}-xml php${PHP_VERSION}-zip php${PHP_VERSION}-opcache

# PHP-FPM 用 www:www(LNMP 风格)
PHP_POOL="/etc/php/${PHP_VERSION}/fpm/pool.d/www.conf"
sed -i "s/^user = .*/user = ${WEB_USER}/" "${PHP_POOL}"
sed -i "s/^group = .*/group = ${WEB_GROUP}/" "${PHP_POOL}"
sed -i "s/^listen.owner = .*/listen.owner = ${WEB_USER}/" "${PHP_POOL}"
sed -i "s/^listen.group = .*/listen.group = ${WEB_GROUP}/" "${PHP_POOL}"
grep -q "^listen.mode" "${PHP_POOL}" \
  && sed -i "s/^listen.mode = .*/listen.mode = 0660/" "${PHP_POOL}" \
  || echo "listen.mode = 0660" >> "${PHP_POOL}"

systemctl restart "php${PHP_VERSION}-fpm"
systemctl enable "php${PHP_VERSION}-fpm"

# 3) 安装 MySQL 8.0.45(官方 deb bundle)
step "3/9 安装 MySQL 8.0.45"

# 旧数据目录处理(避免交互弹窗)
if [[ -d /var/lib/mysql && -n "$(ls -A /var/lib/mysql 2>/dev/null || true)" ]]; then
  if [[ "${AUTO_BACKUP_OLD_MYSQL_DATA}" == "1" ]]; then
    BAK="/root/mysql-data-backup-$(date +%Y%m%d-%H%M%S).tar.gz"
    echo -e "${YELLOW}⚠️ 检测到旧 /var/lib/mysql,备份到 ${BAK}${NC}"
    tar -zcf "${BAK}" -C / var/lib/mysql
  fi
  systemctl stop mysql >/dev/null 2>&1 || true
  rm -rf /var/lib/mysql
fi

cd /tmp
MYSQL_BUNDLE_URL="https://dev.mysql.com/get/Downloads/MySQL-8.0/mysql-server_8.0.45-1debian12_amd64.deb-bundle.tar"
wget -O mysql-bundle.tar "${MYSQL_BUNDLE_URL}" --no-check-certificate
tar -xf mysql-bundle.tar

apt install -y --no-install-recommends libmecab2

# 非交互预置
echo "mysql-community-server mysql-community-server/root-pass password ${MYSQL_ROOT_PASS}" | debconf-set-selections
echo "mysql-community-server mysql-community-server/re-root-pass password ${MYSQL_ROOT_PASS}" | debconf-set-selections
echo "mysql-community-server mysql-community-server/default-auth-override select Use Legacy Authentication Method (Retain MySQL 5.x Compatibility)" | debconf-set-selections

install_deb_if_exists() {
  local p="$1"
  local d
  d=$(ls -1 ${p} 2>/dev/null | head -n1 || true)
  [[ -n "${d}" ]] && dpkg -i "${d}" || true
}

# 兼容 mysql-* / mysql-community-* 两种命名
install_deb_if_exists "mysql-common_*.deb"
install_deb_if_exists "mysql-community-client-plugins_*.deb"
install_deb_if_exists "mysql-community-client-core_*.deb"
install_deb_if_exists "mysql-community-client_*.deb"
install_deb_if_exists "mysql-client-core_*.deb"
install_deb_if_exists "mysql-client_*.deb"
install_deb_if_exists "mysql-community-server-core_*.deb"
install_deb_if_exists "mysql-community-server_*.deb"
install_deb_if_exists "mysql-server-core_*.deb"
install_deb_if_exists "mysql-server_*.deb"

apt -f install -y --no-install-recommends

getent group mysql >/dev/null || groupadd -r mysql
id -u mysql >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin -M -g mysql mysql
mkdir -p /var/lib/mysql /run/mysqld /var/log/mysql
chown -R mysql:mysql /var/lib/mysql /run/mysqld /var/log/mysql
chmod 750 /var/lib/mysql
chmod 755 /run/mysqld

# systemd 兜底,避免“ready了却被超时杀掉”
mkdir -p /etc/systemd/system/mysql.service.d
cat >/etc/systemd/system/mysql.service.d/override.conf <<EOF
[Service]
Type=simple
TimeoutStartSec=0
PIDFile=
EOF

systemctl daemon-reload
systemctl stop mysql >/dev/null 2>&1 || true
pkill -9 mysqld_safe >/dev/null 2>&1 || true
pkill -9 mysqld >/dev/null 2>&1 || true
systemctl start mysql
systemctl enable mysql

# root/业务库/业务用户
mysql -uroot -p"${MYSQL_ROOT_PASS}" <<EOF
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '${MYSQL_ROOT_PASS}';
CREATE DATABASE IF NOT EXISTS ${MYSQL_DB} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${MYSQL_USER}'@'localhost' IDENTIFIED WITH mysql_native_password BY '${MYSQL_PASS}';
ALTER USER '${MYSQL_USER}'@'localhost' IDENTIFIED WITH mysql_native_password BY '${MYSQL_PASS}';
CREATE USER IF NOT EXISTS '${MYSQL_USER}'@'127.0.0.1' IDENTIFIED WITH mysql_native_password BY '${MYSQL_PASS}';
ALTER USER '${MYSQL_USER}'@'127.0.0.1' IDENTIFIED WITH mysql_native_password BY '${MYSQL_PASS}';
GRANT ALL PRIVILEGES ON ${MYSQL_DB}.* TO '${MYSQL_USER}'@'localhost';
GRANT ALL PRIVILEGES ON ${MYSQL_DB}.* TO '${MYSQL_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
EOF

# 4) 配置 Nginx(worker 也改为 www)
step "4/9 配置 Nginx"
if grep -qE '^user\s+' /etc/nginx/nginx.conf; then
  sed -i "s/^user .*/user ${WEB_USER};/" /etc/nginx/nginx.conf
else
  sed -i "1i user ${WEB_USER};" /etc/nginx/nginx.conf
fi
rm -f /etc/nginx/sites-enabled/default

SITE_NAME="${DOMAIN}"
cat >"/etc/nginx/sites-available/${SITE_NAME}" <<EOF
server {
    listen 80;
    listen [::]:80;
    server_name ${DOMAIN};
    root ${WEB_DIR};
    index index.php index.html index.htm;

    access_log /var/log/nginx/${SITE_NAME}.access.log;
    error_log /var/log/nginx/${SITE_NAME}.error.log;

    location / {
        try_files \$uri \$uri/ /index.php?\$args;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php${PHP_VERSION}-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}
EOF

ln -sf "/etc/nginx/sites-available/${SITE_NAME}" "/etc/nginx/sites-enabled/${SITE_NAME}"
nginx -t
systemctl restart nginx
systemctl enable nginx

# 5) 安装 WordPress
step "5/9 安装 WordPress"
cd /tmp
if ! wget -O wp.tar.gz "${WP_MAIN_URL}" --no-check-certificate; then
  wget -O wp.tar.gz "${WP_BACKUP_URL}" --no-check-certificate
fi

rm -rf /tmp/wordpress
tar -zxf wp.tar.gz -C /tmp
cp -a /tmp/wordpress/. "${WEB_DIR}/"

# 用官方 sample 生成配置
cp -f "${WEB_DIR}/wp-config-sample.php" "${WEB_DIR}/wp-config.php"
sed -i "s/database_name_here/${MYSQL_DB}/" "${WEB_DIR}/wp-config.php"
sed -i "s/username_here/${MYSQL_USER}/" "${WEB_DIR}/wp-config.php"
sed -i "s/password_here/${MYSQL_PASS}/" "${WEB_DIR}/wp-config.php"
sed -i "s/localhost/127.0.0.1/" "${WEB_DIR}/wp-config.php"

chown -R "${WEB_USER}:${WEB_GROUP}" "${WEB_DIR}"
find "${WEB_DIR}" -type d -exec chmod 755 {} \;
find "${WEB_DIR}" -type f -exec chmod 644 {} \;
chmod 600 "${WEB_DIR}/wp-config.php"

# 6) 验证
step "6/9 服务验证"
systemctl is-active --quiet nginx || (echo "nginx 未运行" && exit 1)
systemctl is-active --quiet "php${PHP_VERSION}-fpm" || (echo "php-fpm 未运行" && exit 1)
systemctl is-active --quiet mysql || (echo "mysql 未运行" && exit 1)
mysql -u"${MYSQL_USER}" -p"${MYSQL_PASS}" -h127.0.0.1 -e "SELECT 1;" >/dev/null

# 7) 完成信息
step "7/9 安装完成"
echo -e "${GREEN}✅ LNMP + WordPress 安装完成${NC}"
echo "网站目录: ${WEB_DIR}"
echo "运行用户: ${WEB_USER}:${WEB_GROUP}"
echo "MySQL root 密码: ${MYSQL_ROOT_PASS}"
echo "WordPress DB: ${MYSQL_DB}"
echo "WordPress User/Pass: ${MYSQL_USER} / ${MYSQL_PASS}"
echo "访问地址: http://${DOMAIN}"

# 8) 可选测试页
step "8/9 生成 info.php(可选)"
echo "<?php phpinfo();" > "${WEB_DIR}/info.php"
chown "${WEB_USER}:${WEB_GROUP}" "${WEB_DIR}/info.php"
chmod 644 "${WEB_DIR}/info.php"
echo "测试页: http://${DOMAIN}/info.php (确认后建议删除)"

# 9) 结束
step "9/9 结束"
echo -e "${YELLOW}提示:上线前请删除 info.php,配置 HTTPS。${NC}"

setup_ssl_lnmp.sh

这个脚本是使用acme.sh安装ssl证书的一键脚本,在安装脚本前,需要输入域名、网站目录等信息,可以重复输入,如果不需要重复输入,请在输入完一个域名和网站目录后,输入n跳出这一步

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

# 交互式 SSL 安装脚本(Debian + Nginx + acme.sh)
# 规则:
# 1) 不新建任何站点配置文件
# 2) 80/443 必须在同一个 server 段
# 3) 只在现有配置中补 listen 443 与 SSL 项

if [[ $EUID -ne 0 ]]; then
  echo "请使用 root 执行。"
  exit 1
fi
if ! command -v nginx >/dev/null 2>&1; then
  echo "未检测到 nginx 命令,请先安装 Nginx。"
  exit 1
fi

LE_EMAIL_DEFAULT="admin@example.com"
read -r -p "请输入用于申请证书的邮箱 [${LE_EMAIL_DEFAULT}]: " LE_EMAIL
LE_EMAIL="${LE_EMAIL:-$LE_EMAIL_DEFAULT}"

declare -a DOMAINS=()
declare -A WEBROOTS=()
declare -A EXTRA_NAMES=()

echo
echo "========== 域名录入 =========="
while true; do
  read -r -p "请输入主域名(如 www.example.com): " domain
  domain="$(echo "$domain" | xargs)"
  [[ -z "$domain" ]] && { echo "域名不能为空。"; continue; }

  read -r -p "请输入 ${domain} 的站点路径(如 /home/wwwroot/www.example.com): " webroot
  webroot="$(echo "$webroot" | xargs)"
  [[ -z "$webroot" ]] && { echo "路径不能为空。"; continue; }

  read -r -p "可选:请输入该主域名的附加域名(逗号分隔,如 www.example.com;留空跳过): " extra
  extra="$(echo "$extra" | xargs)"

  DOMAINS+=("$domain")
  WEBROOTS["$domain"]="$webroot"
  EXTRA_NAMES["$domain"]="$extra"

  echo "已添加:$domain -> $webroot ; 附加域名: ${extra:-无}"
  read -r -p "继续输入?(y 继续 / n 结束并开始安装): " more
  case "${more,,}" in
    y|yes) ;;
    n|no) break ;;
    *) echo "输入不识别,默认继续。" ;;
  esac
done

[[ ${#DOMAINS[@]} -eq 0 ]] && { echo "未录入任何域名,脚本结束。"; exit 1; }

echo
echo "========== 录入确认 =========="
for d in "${DOMAINS[@]}"; do
  echo "- ${d} -> ${WEBROOTS[$d]} ; 附加: ${EXTRA_NAMES[$d]:-无}"
done
read -r -p "确认开始安装?(y/n): " go
if [[ "${go,,}" != "y" && "${go,,}" != "yes" ]]; then
  echo "已取消。"
  exit 0
fi

echo
echo ">>> 自动检测 Nginx 配置目录"
if [[ -d /usr/local/nginx/conf/vhost ]]; then
  VHOST_DIR="/usr/local/nginx/conf/vhost"
elif [[ -d /etc/nginx/sites-enabled ]]; then
  VHOST_DIR="/etc/nginx/sites-enabled"
elif [[ -d /etc/nginx/conf.d ]]; then
  VHOST_DIR="/etc/nginx/conf.d"
else
  echo "未找到常见 Nginx 站点目录。"
  exit 1
fi
echo "检测结果:${VHOST_DIR}"

echo
echo ">>> 安装依赖"
apt-get update -y
apt-get install -y curl socat cron ca-certificates openssl

echo
echo ">>> 安装 acme.sh(gitee raw)"
mkdir -p /root/.acme.sh
if [[ ! -x /root/.acme.sh/acme.sh ]]; then
  curl --connect-timeout 10 --max-time 120 -fsSL https://gitee.com/neilpang/acme.sh/raw/master/acme.sh -o /root/.acme.sh/acme.sh
  chmod +x /root/.acme.sh/acme.sh
fi
ACME="/root/.acme.sh/acme.sh"
"${ACME}" --home /root/.acme.sh --config-home /root/.acme.sh --register-account -m "${LE_EMAIL}" || true
"${ACME}" --home /root/.acme.sh --config-home /root/.acme.sh --set-default-ca --server letsencrypt

mkdir -p /etc/nginx/ssl

for d in "${DOMAINS[@]}"; do
  webroot="${WEBROOTS[$d]}"
  extra="${EXTRA_NAMES[$d]}"
  if [[ ! -d "${webroot}" ]]; then
    echo "跳过 ${d}:站点路径不存在 -> ${webroot}"
    continue
  fi

  conf_file="$(grep -Ril -E "server_name[[:space:]].*\\b${d}\\b" "${VHOST_DIR}" 2>/dev/null | head -n1 || true)"
  if [[ -z "${conf_file}" ]]; then
    echo "未找到 ${d} 的现有配置文件,按规则不新建,已跳过。"
    continue
  fi

  echo
  echo ">>> 处理域名:${d}"
  echo "配置文件:${conf_file}"

  # 1) 签发/续签(证书未到期时会提示 skipping,继续流程)
  issue_cmd=( "${ACME}" --home /root/.acme.sh --config-home /root/.acme.sh --issue -d "${d}" -w "${webroot}" --keylength ec-256 )
  if [[ -n "${extra}" ]]; then
    IFS=',' read -ra arr <<< "${extra}"
    for n in "${arr[@]}"; do
      n="$(echo "$n" | xargs)"
      [[ -n "$n" ]] && issue_cmd+=( -d "$n" )
    done
  fi

  set +e
  issue_out="$("${issue_cmd[@]}" 2>&1)"
  issue_rc=$?
  set -e
  echo "${issue_out}"
  if [[ ${issue_rc} -ne 0 ]] && ! echo "${issue_out}" | grep -q "Skipping. Next renewal time is"; then
    echo "签发失败:${d}"
    exit 1
  fi

  # 2) 安装证书到固定路径
  mkdir -p "/etc/nginx/ssl/${d}"
  "${ACME}" --home /root/.acme.sh --config-home /root/.acme.sh --install-cert -d "${d}" --ecc \
    --fullchain-file "/etc/nginx/ssl/${d}/fullchain.cer" \
    --key-file "/etc/nginx/ssl/${d}/${d}.key"

  # 3) 防跨站文件
  user_ini="${webroot}/.user.ini"
  if [[ ! -f "${user_ini}" ]]; then
    cat > "${user_ini}" <<EOF
open_basedir=${webroot}/:/tmp/:/proc/
EOF
    chown www:www "${user_ini}" || true
    chmod 644 "${user_ini}" || true
    echo "已创建 ${user_ini}"
  fi

  # 4) 同一个 server 段补 443 与 SSL 项(不新建 server 块)
  cp -a "${conf_file}" "${conf_file}.bak.$(date +%F-%H%M%S)"

  if grep -Eq "listen[[:space:]]+443([[:space:]]|;)" "${conf_file}"; then
    echo "已存在 listen 443,跳过监听项。"
  else
    perl -0777 -i -pe 's/(listen\s+80[^\n;]*;)/$1 . "\n    listen 443 ssl http2;"/se' "${conf_file}"
    echo "已补 listen 443(同一 server 段)。"
  fi

  if grep -q "ssl_certificate /etc/nginx/ssl/${d}/fullchain.cer;" "${conf_file}"; then
    echo "已存在该域名证书配置,跳过。"
  else
    NB_DOMAIN="${d}" perl -0777 -i -pe '
      my $domain = $ENV{"NB_DOMAIN"};
      my $q = quotemeta($domain);
      s/(server_name\s+[^;]*\b$q\b[^;]*;)/
        $1
        . "\n    ssl_certificate /etc/nginx/ssl/$domain/fullchain.cer;"
        . "\n    ssl_certificate_key /etc/nginx/ssl/$domain/$domain.key;"
        . "\n    ssl_protocols TLSv1.2 TLSv1.3;"
        . "\n    ssl_ciphers HIGH:!aNULL:!MD5;"
      /se;
    ' "${conf_file}"
    echo "已补 SSL 证书配置(同一 server 段)。"
  fi

  # 5) 证书域名匹配检查(主域 + 附加域)
  cert_file="/etc/nginx/ssl/${d}/fullchain.cer"
  if [[ -f "${cert_file}" ]]; then
    san="$(openssl x509 -in "${cert_file}" -noout -ext subjectAltName 2>/dev/null || true)"
    if ! echo "${san}" | grep -q "DNS:${d}"; then
      echo "警告:证书 SAN 不含主域 ${d},请检查签发参数。"
    fi
    if [[ -n "${extra}" ]]; then
      IFS=',' read -ra arr2 <<< "${extra}"
      for n in "${arr2[@]}"; do
        n="$(echo "$n" | xargs)"
        [[ -z "$n" ]] && continue
        if ! echo "${san}" | grep -q "DNS:${n}"; then
          echo "警告:证书 SAN 不含附加域 ${n}。"
        fi
      done
    fi
  fi

  # 6) PHP 规则检查(仅提示,不擅改业务)
  if ! grep -Eq "fastcgi_pass|enable-php|location[[:space:]]+~[[:space:]]+\\\.php" "${conf_file}"; then
    echo "警告:${conf_file} 未检测到 PHP 处理规则,HTTPS 可能出现下载 PHP。"
  fi
done

echo
echo ">>> 生成续期子脚本:/root/renew_ssl_lnmp.sh"
{
  echo '#!/usr/bin/env bash'
  echo 'set -euo pipefail'
  echo
  echo 'ACME="/root/.acme.sh/acme.sh"'
  echo 'HOME_DIR="/root/.acme.sh"'
  echo 'NOW_MONTH="$(date +%Y-%m)"'
  echo 'NEED_RELOAD=0'
  echo
  echo 'renew_if_expire_this_month() {'
  echo '  local domain="$1"'
  echo '  local cert_file="$2"'
  echo '  local fullchain="$3"'
  echo '  local keyfile="$4"'
  echo
  echo '  if [[ ! -f "$cert_file" ]]; then'
  echo '    echo "[$domain] 未找到本地证书文件,尝试续签并安装。"'
  echo '  else'
  echo '    local end_raw end_month'
  echo '    end_raw="$(openssl x509 -enddate -noout -in "$cert_file" | cut -d= -f2)"'
  echo '    end_month="$(date -d "$end_raw" +%Y-%m)"'
  echo '    echo "[$domain] 当前到期时间: $end_raw"'
  echo '    if [[ "$end_month" != "$NOW_MONTH" ]]; then'
  echo '      echo "[$domain] 本月不到期,跳过续签。"'
  echo '      return 0'
  echo '    fi'
  echo '    echo "[$domain] 本月到期,开始续签。"'
  echo '  fi'
  echo
  echo '  "$ACME" --home "$HOME_DIR" --config-home "$HOME_DIR" --renew -d "$domain" --ecc || true'
  echo '  "$ACME" --home "$HOME_DIR" --config-home "$HOME_DIR" --install-cert -d "$domain" --ecc \'
  echo '    --fullchain-file "$fullchain" \'
  echo '    --key-file "$keyfile"'
  echo '  NEED_RELOAD=1'
  echo '}'
  echo
  for d in "${DOMAINS[@]}"; do
    echo "renew_if_expire_this_month \"${d}\" \"/etc/nginx/ssl/${d}/fullchain.cer\" \"/etc/nginx/ssl/${d}/fullchain.cer\" \"/etc/nginx/ssl/${d}/${d}.key\""
  done
  echo
  echo 'if [[ "$NEED_RELOAD" -eq 1 ]]; then'
  echo '  nginx -t'
  echo '  systemctl reload nginx'
  echo '  echo "检测到本月到期证书,已续签并重载 Nginx。"'
  echo 'else'
  echo '  echo "本月无到期证书,无需续签。"'
  echo 'fi'
} > /root/renew_ssl_lnmp.sh
chmod +x /root/renew_ssl_lnmp.sh
echo "已生成 /root/renew_ssl_lnmp.sh"
echo "可选添加定时任务:"
echo '(crontab -l 2>/dev/null; echo "0 3 1 * * /root/renew_ssl_lnmp.sh >> /var/log/renew_ssl_lnmp.log 2>&1") | crontab -'

echo
echo ">>> 校验 Nginx 配置"
nginx_test_out="$(nginx -t 2>&1 || true)"
echo "${nginx_test_out}"
if echo "${nginx_test_out}" | grep -q "conflicting server name"; then
  echo "检测到 server_name 冲突,已停止,不执行 reload。请先清理冲突。"
  exit 1
fi
if ! echo "${nginx_test_out}" | grep -q "test is successful"; then
  echo "nginx -t 未通过,已停止。"
  exit 1
fi

systemctl reload nginx
echo
echo "全部完成。"

renew_ssl_lnmp.sh

这是自动续签ssl证书的脚本,会自动匹配证书安装目录、acme.sh安装目录,自动检测证书到期时间。

sudo crontab -e

在cron里新增一项计划任务,假设你的脚本安装目录在 /usr/local/bin/renew_ssl_lnmp.sh

0 0 1 * * /usr/local/bin/renew_ssl_lnmp.sh >> /var/log/renew_ssl_lnmp.log 2>&1

每个月的1日的零点零分开始执行

#!/usr/bin/env bash
# =============================================================================
# renew_ssl_lnmp.sh — 通用 acme.sh 证书续期并写回 Nginx 使用的路径
#
# 自动适配常见环境:
#   • acme.sh 安装位置:/root/.acme.sh、当前用户 ~/.acme.sh、PATH 中的 acme.sh 等
#   • 证书部署目录(可多选合并扫描):
#       - setup_ssl_lnmp.sh / 常见 Debian:/etc/nginx/ssl/<域名>/
#       - LNMP 一键包常见:/usr/local/nginx/conf/ssl/<域名>/
#     单域名下识别 fullchain.cer | fullchain.pem | fullchain.crt,
#     私钥 <域名>.key | privkey.pem | key.pem
#
# 需 root 执行(默认 reload nginx)。
#
# 用法:
#   sudo ./renew_ssl_lnmp.sh
#   sudo SSL_ROOTS="/etc/nginx/ssl /usr/local/nginx/conf/ssl" ./renew_ssl_lnmp.sh
#
# 环境变量(可选):
#   SSL_ROOT       仅使用此证书根(与旧版兼容;设置后不再自动追加其它默认路径)
#   SSL_ROOTS      空格分隔多个证书根,覆盖自动列表
#   ACME_HOME      acme.sh 数据目录(默认自动探测)
#   ACME_BIN       acme.sh 可执行文件(默认自动探测)
#   RENEW_ECC      auto(默认)| ecc | rsa — 续签时是否带 --ecc(auto 按目录判断)
#   RENEW_POLICY   acme(默认)| monthly
#   NGINX_RELOAD   1(默认)| 0
#   STRICT         1| 0(默认)
#
# Cron 示例:
#   0 3 * * * /usr/local/bin/renew_ssl_lnmp.sh >> /var/log/renew_ssl_lnmp.log 2>&1
# =============================================================================
set -euo pipefail

RENEW_POLICY="${RENEW_POLICY:-acme}"
RENEW_ECC="${RENEW_ECC:-auto}"
NGINX_RELOAD="${NGINX_RELOAD:-1}"
STRICT="${STRICT:-0}"
DRY_RUN=0

# 未设置 SSL_ROOT / SSL_ROOTS 时扫描这些目录(存在的才会参与)
DEFAULT_SSL_ROOTS="/etc/nginx/ssl /usr/local/nginx/conf/ssl"

usage() {
  cat <<'EOF'
renew_ssl_lnmp.sh — acme.sh 续期并写回 Nginx 证书路径(多证书根、多 acme 路径自动探测)

用法:
  sudo ./renew_ssl_lnmp.sh [选项]

选项:
  -h, --help        显示本说明
  -n, --dry-run     列出域名与解析到的证书路径,不执行续签
      --ssl-root    只使用这一证书根(等同 SSL_ROOT)
      --ssl-roots   空格分隔多个根目录(等同 SSL_ROOTS)
      --acme-home   指定 acme.sh 数据目录
      --acme-bin    指定 acme.sh 可执行文件
      --policy      acme | monthly

环境变量: SSL_ROOT, SSL_ROOTS, ACME_HOME, ACME_BIN, RENEW_ECC, RENEW_POLICY,
          NGINX_RELOAD, STRICT(详见脚本头部注释)。
EOF
}

log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
warn() { printf '[%s] WARN: %s\n' "$(date '+%F %T')" "$*" >&2; }
die() { printf '[%s] ERROR: %s\n' "$(date '+%F %T')" "$*" >&2; exit 1; }

if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
  die "请使用 root 执行(或 sudo)。"
fi

while [[ $# -gt 0 ]]; do
  case "$1" in
    -h|--help) usage; exit 0 ;;
    -n|--dry-run) DRY_RUN=1; shift ;;
    --ssl-root) SSL_ROOT="${2:?}"; shift 2 ;;
    --ssl-roots) SSL_ROOTS="${2:?}"; shift 2 ;;
    --acme-home) ACME_HOME="${2:?}"; shift 2 ;;
    --acme-bin) ACME_BIN="${2:?}"; shift 2 ;;
    --policy)
      RENEW_POLICY="${2:?}"
      [[ "${RENEW_POLICY}" == acme || "${RENEW_POLICY}" == monthly ]] || die "--policy 只能是 acme 或 monthly"
      shift 2
      ;;
    *) die "未知参数: $1 (使用 --help)" ;;
  esac
done

command -v openssl >/dev/null 2>&1 || die "未找到 openssl 命令。"

# ---------- acme.sh 探测 ----------
# 官方布局:数据目录与可执行脚本在同一目录,如 /root/.acme.sh/acme.sh
auto_detect_acme() {
  if [[ -n "${ACME_HOME:-}" && -z "${ACME_BIN:-}" && -x "${ACME_HOME}/acme.sh" ]]; then
    ACME_BIN="${ACME_HOME}/acme.sh"
    return 0
  fi
  if [[ -n "${ACME_BIN:-}" && -x "${ACME_BIN}" ]]; then
    if [[ -z "${ACME_HOME:-}" ]]; then
      ACME_HOME="$(cd "$(dirname "${ACME_BIN}")" && pwd)"
    fi
    return 0
  fi
  local c
  for c in "/root/.acme.sh/acme.sh" "${HOME}/.acme.sh/acme.sh"; do
    if [[ -x "$c" ]]; then
      ACME_BIN="$c"
      ACME_HOME="$(cd "$(dirname "$c")" && pwd)"
      return 0
    fi
  done
  if c="$(command -v acme.sh 2>/dev/null || true)"; [[ -n "$c" && -x "$c" ]]; then
    ACME_BIN="$c"
    if [[ -z "${ACME_HOME:-}" ]]; then
      if [[ -d "/root/.acme.sh" ]]; then
        ACME_HOME="/root/.acme.sh"
      else
        ACME_HOME="$(cd "$(dirname "$c")" && pwd)"
      fi
    fi
    return 0
  fi
  return 1
}

ACME_BIN="${ACME_BIN:-}"
ACME_HOME="${ACME_HOME:-}"
if ! auto_detect_acme; then
  die "未找到 acme.sh。请安装或设置 ACME_BIN / ACME_HOME。"
fi
[[ -d "${ACME_HOME}" ]] || die "ACME_HOME 不是目录: ${ACME_HOME}"

# ---------- 证书根列表 ----------
collect_ssl_roots() {
  local r roots=()
  if [[ -n "${SSL_ROOT:-}" ]]; then
    printf '%s\n' "${SSL_ROOT}"
    return
  fi
  if [[ -n "${SSL_ROOTS:-}" ]]; then
    for r in ${SSL_ROOTS}; do
      [[ -n "$r" && -d "$r" ]] && printf '%s\n' "$r"
    done
    return
  fi
  for r in ${DEFAULT_SSL_ROOTS}; do
    [[ -d "$r" ]] && printf '%s\n' "$r"
  done
}

mapfile -t SSL_ROOT_LIST < <(collect_ssl_roots | sort -u)

if [[ ${#SSL_ROOT_LIST[@]} -eq 0 ]]; then
  die "未发现证书根目录。请创建 /etc/nginx/ssl 或 /usr/local/nginx/conf/ssl,或设置 SSL_ROOT / SSL_ROOTS。"
fi

# 在某一 ssl_root/<domain>/ 下解析 fullchain + key
resolve_cert_paths_in_root() {
  local domain="$1"
  local base="$2"
  local fullchain="" keyfile=""

  if [[ -f "${base}/fullchain.cer" ]]; then fullchain="${base}/fullchain.cer"
  elif [[ -f "${base}/fullchain.pem" ]]; then fullchain="${base}/fullchain.pem"
  elif [[ -f "${base}/fullchain.crt" ]]; then fullchain="${base}/fullchain.crt"
  fi

  if [[ -f "${base}/${domain}.key" ]]; then keyfile="${base}/${domain}.key"
  elif [[ -f "${base}/privkey.pem" ]]; then keyfile="${base}/privkey.pem"
  elif [[ -f "${base}/key.pem" ]]; then keyfile="${base}/key.pem"
  fi

  printf '%s\t%s\n' "$fullchain" "$keyfile"
}

# 返回:fullchain<TAB>keyfile<TAB>ssl_root(选用第一个同时存在链与钥的根)
resolve_cert_paths() {
  local domain="$1"
  local root base p fc k
  for root in "${SSL_ROOT_LIST[@]}"; do
    base="${root}/${domain}"
    [[ -d "$base" ]] || continue
    p="$(resolve_cert_paths_in_root "$domain" "$base")"
    fc="$(printf '%s' "$p" | cut -f1)"
    k="$(printf '%s' "$p" | cut -f2)"
    if [[ -n "$fc" && -n "$k" ]]; then
      printf '%s\t%s\t%s\n' "$fc" "$k" "$root"
      return 0
    fi
  done
  printf '\t\t\n'
  return 1
}

domain_has_partial_ssl_dir() {
  local domain="$1"
  local root base p fc k
  for root in "${SSL_ROOT_LIST[@]}"; do
    base="${root}/${domain}"
    [[ -d "$base" ]] || continue
    p="$(resolve_cert_paths_in_root "$domain" "$base")"
    fc="$(printf '%s' "$p" | cut -f1)"
    k="$(printf '%s' "$p" | cut -f2)"
    if [[ -n "$fc" || -n "$k" ]]; then
      return 0
    fi
  done
  return 1
}

discover_domains_from_ssl_roots() {
  local root d domain
  for root in "${SSL_ROOT_LIST[@]}"; do
    for d in "${root}"/*; do
      [[ -e "$d" ]] || continue
      [[ -d "$d" ]] || continue
      domain="$(basename "$d")"
      [[ -n "$domain" && "$domain" != "*" ]] || continue
      domain_has_partial_ssl_dir "$domain" && printf '%s\n' "$domain"
    done
  done
}

discover_domains_from_acme_list() {
  "${ACME_BIN}" --home "${ACME_HOME}" --config-home "${ACME_HOME}" --list 2>/dev/null \
    | awk 'NR>1 && $1!="" && $1!="No" { print $1 }' | sort -u
}

merge_domain_lists() {
  local tmp d
  tmp="$(mktemp)"
  discover_domains_from_ssl_roots >>"$tmp"
  while IFS= read -r d; do
    [[ -z "$d" ]] && continue
    domain_has_partial_ssl_dir "$d" && printf '%s\n' "$d"
  done < <(discover_domains_from_acme_list) >>"$tmp" 2>/dev/null || true
  sort -u "$tmp"
  rm -f "$tmp"
}

# --ecc:与签发时一致;LNMP / setup_ssl 多为 ECC
ecc_args_for_domain() {
  local domain="$1"
  case "${RENEW_ECC}" in
    ecc) printf '%s\n' --ecc; return ;;
    rsa) return ;;
    auto)
      if [[ -d "${ACME_HOME}/${domain}_ecc" ]]; then
        printf '%s\n' --ecc
        return
      fi
      if [[ -d "${ACME_HOME}/${domain}" ]]; then
        return
      fi
      printf '%s\n' --ecc
      ;;
    *) die "RENEW_ECC 只能是 auto/ecc/rsa" ;;
  esac
}

should_try_renew_monthly() {
  local cert_file="$1"
  local domain="$2"
  local now_month
  now_month="$(date +%Y-%m)"
  if [[ ! -f "$cert_file" ]]; then
    log "[$domain] 未找到本地证书文件,将尝试续签。"
    return 0
  fi
  local end_raw end_month
  end_raw="$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)"
  if [[ -z "$end_raw" ]]; then
    warn "[$domain] 无法读取证书到期时间,将尝试续签。"
    return 0
  fi
  end_month="$(date -d "$end_raw" +%Y-%m 2>/dev/null || true)"
  log "[$domain] 当前到期时间: $end_raw"
  if [[ "$end_month" != "$now_month" ]]; then
    log "[$domain](monthly 策略)非本月到期,跳过。"
    return 1
  fi
  log "[$domain](monthly 策略)本月到期,将续签。"
  return 0
}

reload_nginx_if_needed() {
  if [[ "${NGINX_RELOAD}" != "1" ]]; then
    log "已跳过 Nginx reload(NGINX_RELOAD=${NGINX_RELOAD})。"
    return 0
  fi
  if ! command -v nginx >/dev/null 2>&1; then
    warn "未找到 nginx 命令,跳过 reload。"
    return 0
  fi
  nginx -t
  systemctl reload nginx 2>/dev/null || service nginx reload 2>/dev/null || nginx -s reload
  log "Nginx 已重载。"
}

NEED_RELOAD=0
FAIL=0

mapfile -t DOMAINS < <(merge_domain_lists)

if [[ ${#DOMAINS[@]} -eq 0 ]]; then
  warn "未发现可处理域名。"
  warn "请在证书根下创建 <域名> 目录,并放置 fullchain(.cer/.pem)与私钥(域名.key 或 privkey.pem)。"
  warn "当前扫描的证书根: ${SSL_ROOT_LIST[*]}"
  exit 1
fi

log "SSL_ROOTS=${SSL_ROOT_LIST[*]} ACME_HOME=${ACME_HOME} ACME_BIN=${ACME_BIN} RENEW_POLICY=${RENEW_POLICY} RENEW_ECC=${RENEW_ECC}"
log "自动识别 ${#DOMAINS[@]} 个域名: ${DOMAINS[*]}"

for domain in "${DOMAINS[@]}"; do
  line="$(resolve_cert_paths "$domain" || true)"
  fullchain="$(printf '%s' "$line" | cut -f1)"
  keyfile="$(printf '%s' "$line" | cut -f2)"
  ssl_root_used="$(printf '%s' "$line" | cut -f3)"

  if [[ -z "$fullchain" || -z "$keyfile" ]]; then
    warn "[$domain] 在证书根下未同时找到 fullchain 与私钥,跳过。(已扫描: ${SSL_ROOT_LIST[*]})"
    FAIL=1
    continue
  fi

  mapfile -t ECC_ARG < <(ecc_args_for_domain "$domain")

  if [[ "${RENEW_POLICY}" == monthly ]]; then
    should_try_renew_monthly "$fullchain" "$domain" || continue
  fi

  if [[ "$DRY_RUN" -eq 1 ]]; then
    log "[$domain] dry-run: ssl_root=${ssl_root_used} ecc_arg=[${ECC_ARG[*]}] fullchain=$fullchain key=$keyfile"
    continue
  fi

  set +e
  renew_out="$("${ACME_BIN}" --home "${ACME_HOME}" --config-home "${ACME_HOME}" --renew -d "${domain}" "${ECC_ARG[@]:-}" 2>&1)"
  renew_rc=$?
  set -e

  printf '%s\n' "$renew_out"

  if echo "$renew_out" | grep -qiE 'Skipping\.|Cert not yet due|not due for renewal'; then
    log "[$domain] acme.sh:尚未到续期窗口,跳过部署。"
    continue
  fi

  if [[ $renew_rc -ne 0 ]] && ! echo "$renew_out" | grep -qiE 'success|renewed|Renew'; then
    warn "[$domain] acme.sh --renew 失败(退出码 $renew_rc)。若证书为 RSA,可尝试 RENEW_ECC=rsa。"
    FAIL=1
    [[ "${STRICT}" == "1" ]] && die "STRICT=1,终止。"
    continue
  fi

  if ! "${ACME_BIN}" --home "${ACME_HOME}" --config-home "${ACME_HOME}" --install-cert -d "${domain}" "${ECC_ARG[@]:-}" \
    --fullchain-file "$fullchain" \
    --key-file "$keyfile"; then
    warn "[$domain] install-cert 失败。"
    FAIL=1
    [[ "${STRICT}" == "1" ]] && die "STRICT=1,终止。"
    continue
  fi

  NEED_RELOAD=1
  log "[$domain] 证书已更新: $fullchain"
done

if [[ "$DRY_RUN" -eq 1 ]]; then
  exit 0
fi

if [[ "$NEED_RELOAD" -eq 1 ]]; then
  reload_nginx_if_needed
else
  log "无需重载 Nginx(无证书更新或均在续期窗口外)。"
fi

if [[ "$FAIL" -eq 1 ]]; then
  exit 1
fi
exit 0

 


sicnature ---------------------------------------------------------------------
I P 地 址: 216.73.216.41
区 域 位 置: 美国加利福尼亚洛杉矶
系 统 信 息: 美国
Original content, please indicate the source:
同福客栈论坛 | 蟒蛇科普海南乡情论坛 | JiaYu Blog
sicnature ---------------------------------------------------------------------
Welcome to reprint. Please indicate the source https://www.myzhenai.com.cn/post/4932.html

没有评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注