From 6301182cfc7a6d00153451971e839bb1f3994a4e Mon Sep 17 00:00:00 2001
From: zxyszx <299979470+zxyszx@users.noreply.github.com>
Date: Tue, 4 Aug 2026 02:23:46 +0800
Subject: [PATCH] feat: harden installer management menu
---
.github/workflows/ci.yml | 2 +-
.github/workflows/docker.yml | 2 +-
README.md | 14 +-
apps/web/src/pages/mail.tsx | 3 +-
deploy/README.md | 5 +-
docs/GUIDE.md | 9 +-
install.sh | 639 ++++++++++++++++++++++++++++-------
tests/install_test.sh | 325 +++++++++++++++++-
8 files changed, 861 insertions(+), 138 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7bdcb92..bc74e12 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,7 +43,7 @@ jobs:
- name: Check installer
run: |
sudo apt-get update
- sudo apt-get install -y shellcheck
+ sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index b27a38c..691eb6e 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -30,7 +30,7 @@ jobs:
- name: Check installer
run: |
sudo apt-get update
- sudo apt-get install -y shellcheck
+ sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
diff --git a/README.md b/README.md
index b12d221..af8f0d7 100644
--- a/README.md
+++ b/README.md
@@ -37,13 +37,13 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
-`/opt/newszxcn-email` 完整改名备份,再进入首次安装流程。更新会先备份数据库,并在
-启动失败时自动回滚。
+`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
+校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
-- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
+- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
@@ -70,15 +70,19 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
sudo newszxcn-email update
```
-命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
+命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
```bash
sudo newszxcn-email rollback
```
+手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
+
常用运维命令:
```bash
+sudo ns
+sudo newszxcn-email guide
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
@@ -86,7 +90,7 @@ sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
-`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
+`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
## DNS 与端口
diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx
index c3e8f16..2a05108 100644
--- a/apps/web/src/pages/mail.tsx
+++ b/apps/web/src/pages/mail.tsx
@@ -174,7 +174,6 @@ export function MailPage() {
const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
const canViewUnknownMail = user?.role === "admin"
- const canManageMailboxes = hasPermission(user, "admin.mailboxes.view")
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
@@ -1469,7 +1468,7 @@ export function MailPage() {
) : !canReadMail ? (
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
- navigate(canManageMailboxes ? "/admin?section=mailboxes" : "/profile?tab=mailboxes")} />
+ navigate("/profile?tab=mailboxes")} />
) : mailView === "scheduled" && canScheduleMail ? (
/dev/null && pwd
}
-refresh_assets() {
+stage_assets() {
local source_dir local_source="false"
+ local compose_new env_example_new installer_new
source_dir="$(script_dir || true)"
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then
local_source="true"
fi
install -d -m 0755 "${INSTALL_DIR}"
+ compose_new="${INSTALL_DIR}/.docker-compose.yml.new"
+ env_example_new="${INSTALL_DIR}/.env.example.new"
+ installer_new="${INSTALL_DIR}/.install.sh.new"
+ rm -f "${compose_new}" "${env_example_new}" "${installer_new}"
if [[ "${local_source}" == "true" && -f "${source_dir}/deploy/docker-compose.yml" && -f "${source_dir}/deploy/.env.example" ]]; then
- install -m 0644 "${source_dir}/deploy/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
- install -m 0644 "${source_dir}/deploy/.env.example" "${INSTALL_DIR}/.env.example"
- install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email
+ install -m 0644 "${source_dir}/deploy/docker-compose.yml" "${compose_new}"
+ install -m 0644 "${source_dir}/deploy/.env.example" "${env_example_new}"
+ install -m 0755 "${source_dir}/install.sh" "${installer_new}"
else
- curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${INSTALL_DIR}/docker-compose.yml"
- curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${INSTALL_DIR}/.env.example"
- curl -fsSL "${RAW_BASE}/install.sh" -o /usr/local/bin/newszxcn-email.new
- chmod 0755 /usr/local/bin/newszxcn-email.new
- mv /usr/local/bin/newszxcn-email.new /usr/local/bin/newszxcn-email
+ curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${compose_new}"
+ curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${env_example_new}"
+ curl -fsSL "${RAW_BASE}/install.sh" -o "${installer_new}"
fi
+ chmod 0755 "${installer_new}"
+ bash -n "${installer_new}" || fail "新版安装脚本语法检查失败,现有文件未修改。"
+ if command -v shellcheck >/dev/null 2>&1; then
+ shellcheck -x "${installer_new}" || fail "新版安装脚本 ShellCheck 未通过,现有文件未修改。"
+ fi
+ if [[ -f "${INSTALL_DIR}/.env" ]] && command -v docker >/dev/null 2>&1; then
+ docker compose --project-directory "${INSTALL_DIR}" --env-file "${INSTALL_DIR}/.env" -f "${compose_new}" config >/dev/null \
+ || fail "新版 Docker Compose 配置检查失败,现有文件未修改。"
+ fi
+}
+
+apply_staged_assets() {
+ install -m 0644 "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/docker-compose.yml" || return 1
+ install -m 0644 "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.env.example" || return 1
+ install -m 0755 "${INSTALL_DIR}/.install.sh.new" "${CLI_PATH}.new" || return 1
+ mv "${CLI_PATH}.new" "${CLI_PATH}" || return 1
+ rm -f "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.install.sh.new"
+}
+
+ensure_cli_alias() {
+ local target="${CLI_PATH}" alias_path="${CLI_ALIAS_PATH}" existing
+ [[ -x "${target}" ]] || return 0
+ if [[ -L "${alias_path}" ]]; then
+ existing="$(readlink "${alias_path}" 2>/dev/null || true)"
+ if [[ "${existing}" == "${target}" ]]; then
+ return 0
+ fi
+ warn "未修改 ${alias_path}:现有符号链接指向 ${existing:-未知目标}。"
+ return 0
+ fi
+ if [[ -e "${alias_path}" ]]; then
+ warn "未创建快捷命令 ns:${alias_path} 已被其他文件占用。"
+ return 0
+ fi
+ existing="$(type -P ns 2>/dev/null || true)"
+ if [[ -n "${existing}" && "${existing}" != "${alias_path}" ]]; then
+ warn "未创建快捷命令 ns:系统中已存在 ${existing}。"
+ return 0
+ fi
+ ln -s "${target}" "${alias_path}"
+ success "快捷命令已创建:输入 ns 可打开管理菜单。"
+}
+
+refresh_assets() {
+ stage_assets
+ apply_staged_assets
+ ensure_cli_alias
}
random_secret() {
@@ -165,7 +233,7 @@ prompt_choice() {
}
prompt_menu_choice() {
- local default_value="$1" value="${LANQIN_MENU_ACTION:-}"
+ local default_value="$1" max_value="${2:-10}" value="${LANQIN_MENU_ACTION:-}"
if [[ -z "${value}" ]] && ! has_tty; then
fail "非交互环境请直接使用 install、update、status 等子命令。"
fi
@@ -174,13 +242,13 @@ prompt_menu_choice() {
read -r -p "请选择 [${default_value}]: " value = 0 && value <= max_value )); then
printf '%s' "${value}"
return
fi
- prompt_text "[提示] 请输入 0 至 9。\n"
+ prompt_text "[提示] 请输入 0 至 ${max_value}。\n"
value=""
- has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 9。"
+ has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 ${max_value}。"
done
}
@@ -258,8 +326,8 @@ configure_first_install() {
fi
local firewall_mode hostname admin_username admin_password web_mode public_url update_token
- prompt_text '\n防火墙配置 [1]:\n1. 仅开放邮局必要端口(推荐)\n2. 保留现有防火墙,由用户自行配置\n3. 开放全部端口(不推荐)\n'
- firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1")"
+ prompt_text '\n防火墙配置 [1]:\n1. 自动添加邮局必要端口规则(推荐)\n2. 保留现有防火墙,由用户自行配置\n'
+ firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1" "2")"
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。"
@@ -343,7 +411,7 @@ configure_restricted_firewall() {
firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null
done
firewall-cmd --reload >/dev/null
- success "firewalld 已仅开放 SSH 和邮局必要端口。"
+ success "firewalld 已添加 SSH 和邮局必要端口规则。"
return
fi
@@ -355,36 +423,17 @@ configure_restricted_firewall() {
ufw allow "${ssh_port}/tcp" >/dev/null
done
ufw --force enable >/dev/null
- success "UFW 已开放 SSH 和邮局必要端口。"
+ success "UFW 已添加 SSH 和邮局必要端口规则。"
return
fi
fail "没有找到可管理的 UFW 或 firewalld。"
}
-configure_open_firewall() {
- warn "正在按选择开放全部端口,请同时检查云厂商安全组。"
- if command -v ufw >/dev/null 2>&1; then
- ufw --force disable >/dev/null 2>&1 || true
- fi
- if command -v systemctl >/dev/null 2>&1; then
- systemctl disable --now firewalld >/dev/null 2>&1 || true
- fi
- if command -v iptables >/dev/null 2>&1; then
- iptables -P INPUT ACCEPT
- iptables -F INPUT
- fi
- if command -v ip6tables >/dev/null 2>&1; then
- ip6tables -P INPUT ACCEPT
- ip6tables -F INPUT
- fi
- success "主机防火墙已调整为开放入站;云厂商安全组仍需单独配置。"
-}
-
configure_firewall() {
case "$(env_value LANQIN_INSTALL_FIREWALL_MODE || true)" in
1) configure_restricted_firewall ;;
2) warn "已保留现有防火墙,请自行开放 SSH、25、80、443、465、587、993、995/TCP。" ;;
- 3) configure_open_firewall ;;
+ 3) warn "检测到旧版开放全部端口配置。为避免破坏现有安全规则,本次不再修改防火墙。" ;;
"") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;;
*) fail "防火墙模式配置无效。" ;;
esac
@@ -415,6 +464,16 @@ ensure_nginx() {
fi
}
+reload_nginx() {
+ command -v nginx >/dev/null 2>&1 || return 0
+ nginx -t >/dev/null 2>&1 || return 1
+ if command -v systemctl >/dev/null 2>&1; then
+ systemctl reload nginx
+ else
+ nginx -s reload
+ fi
+}
+
write_nginx_http_config() {
local hostname tmp
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
@@ -505,6 +564,7 @@ EOF
}
ensure_acme() {
+ ensure_cron_scheduler
if [[ ! -x /root/.acme.sh/acme.sh ]]; then
local hostname
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
@@ -512,6 +572,33 @@ ensure_acme() {
curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}"
fi
[[ -x /root/.acme.sh/acme.sh ]] || fail "acme.sh 安装失败。"
+ if ! acme_cron_enabled; then
+ /root/.acme.sh/acme.sh --install-cronjob >/dev/null || fail "acme.sh 自动续期任务安装失败。"
+ fi
+}
+
+acme_cron_enabled() {
+ command -v crontab >/dev/null 2>&1 \
+ && crontab -l 2>/dev/null | grep -Eq 'acme\.sh"?/acme\.sh[[:space:]]+--cron'
+}
+
+ensure_cron_scheduler() {
+ if ! command -v crontab >/dev/null 2>&1; then
+ if command -v apt-get >/dev/null 2>&1; then
+ install_packages cron
+ else
+ install_packages cronie
+ fi
+ fi
+ command -v crontab >/dev/null 2>&1 || fail "系统缺少 Cron,无法配置证书自动续期。"
+ if command -v systemctl >/dev/null 2>&1; then
+ systemctl enable --now cron >/dev/null 2>&1 \
+ || systemctl enable --now crond >/dev/null 2>&1 \
+ || fail "Cron 服务启动失败,无法保证证书自动续期。"
+ elif command -v service >/dev/null 2>&1; then
+ service cron start >/dev/null 2>&1 || service crond start >/dev/null 2>&1 \
+ || fail "Cron 服务启动失败,无法保证证书自动续期。"
+ fi
}
install_certificate() {
@@ -531,7 +618,7 @@ install_certificate() {
--domain "${hostname}" \
--fullchain-file "${CERT_DIR}/fullchain.pem" \
--key-file "${CERT_DIR}/privkey.pem" \
- --reloadcmd "/usr/local/bin/newszxcn-email reload" || fail "证书安装失败。请确认域名已解析到本机、80 端口可从公网访问,然后执行 newszxcn-email certificate 重试。"
+ --reloadcmd "${CLI_PATH} reload" || fail "证书安装失败。请确认域名已解析到本机、80 端口可从公网访问,然后执行 newszxcn-email certificate 重试。"
chmod 0644 "${CERT_DIR}/fullchain.pem"
chmod 0600 "${CERT_DIR}/privkey.pem"
set_env LANQIN_TLS_CERT_FILE "/certs/fullchain.pem"
@@ -564,50 +651,204 @@ configure_web_mode() {
esac
}
-backup_database() {
- local timestamp
- timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
- if [[ -n "$(compose ps -q lanqin-email 2>/dev/null || true)" ]]; then
- compose exec -T lanqin-email sh -c "mkdir -p /data/backups && sqlite3 /data/lanqin.db \".backup '/data/backups/cli-update-${timestamp}.db'\"" >/dev/null
- log "数据库已备份到 data/backups/cli-update-${timestamp}.db"
+current_image_id() {
+ local container_id image_ref
+ container_id="$(compose ps -aq lanqin-email 2>/dev/null | head -n 1 || true)"
+ if [[ -n "${container_id}" ]]; then
+ docker inspect --format '{{.Image}}' "${container_id}"
+ return
fi
+ image_ref="$(env_value LANQIN_IMAGE || true)"
+ image_ref="${image_ref:-ghcr.io/zxyszx/newszxcn-email:latest}"
+ docker image inspect --format '{{.Id}}' "${image_ref}" 2>/dev/null
}
-remember_current_image() {
- local container_id image_id rollback_tag
- container_id="$(compose ps -q lanqin-email 2>/dev/null || true)"
- [[ -n "${container_id}" ]] || return 0
- image_id="$(docker inspect --format '{{.Image}}' "${container_id}")"
- rollback_tag="newszxcn-email:rollback-$(date -u +%Y%m%d%H%M%S)"
- docker image tag "${image_id}" "${rollback_tag}"
- printf '%s\n' "${rollback_tag}" > "${ROLLBACK_FILE}"
+sqlite_integrity_check() {
+ local database="$1" image="$2" relative result
+ if command -v sqlite3 >/dev/null 2>&1; then
+ result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
+ else
+ relative="${database#"${INSTALL_DIR}"/data/}"
+ [[ "${relative}" != "${database}" ]] || return 1
+ result="$(docker run --rm --entrypoint sqlite3 -v "${INSTALL_DIR}/data:/data" "${image}" "/data/${relative}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
+ fi
+ [[ "${result}" == "ok" ]]
+}
+
+backup_database() {
+ local destination="$1" image="$2" container_id running="false" relative container_destination
+ [[ -s "${INSTALL_DIR}/data/lanqin.db" ]] || { warn "未找到可备份的数据库:${INSTALL_DIR}/data/lanqin.db"; return 1; }
+ install -d -m 0700 "$(dirname "${destination}")"
+ rm -f "${destination}"
+ relative="${destination#"${INSTALL_DIR}"/data/}"
+ [[ "${relative}" != "${destination}" ]] || { warn "数据库备份必须保存在 ${INSTALL_DIR}/data 内。"; return 1; }
+ container_destination="/data/${relative}"
+ container_id="$(compose ps -aq lanqin-email 2>/dev/null | head -n 1 || true)"
+ if [[ -n "${container_id}" ]] && [[ "$(docker inspect --format '{{.State.Running}}' "${container_id}" 2>/dev/null || true)" == "true" ]]; then
+ running="true"
+ fi
+
+ if [[ "${running}" == "true" ]]; then
+ docker exec "${container_id}" sqlite3 /data/lanqin.db ".backup '${container_destination}'" >/dev/null \
+ || { warn "运行中数据库备份失败。"; return 1; }
+ elif command -v sqlite3 >/dev/null 2>&1; then
+ sqlite3 "${INSTALL_DIR}/data/lanqin.db" ".backup '${destination}'" >/dev/null \
+ || { warn "离线数据库备份失败。"; return 1; }
+ else
+ docker image inspect "${image}" >/dev/null 2>&1 || { warn "无法找到用于离线备份的旧镜像。"; return 1; }
+ docker run --rm --entrypoint sqlite3 -v "${INSTALL_DIR}/data:/data" "${image}" /data/lanqin.db ".backup '${container_destination}'" >/dev/null \
+ || { warn "离线数据库备份失败。"; return 1; }
+ fi
+
+ [[ -s "${destination}" ]] || { warn "数据库备份文件为空。"; return 1; }
+ sqlite_integrity_check "${destination}" "${image}" || { warn "数据库备份完整性检查未通过。"; return 1; }
+ log "数据库已备份并校验:${destination}"
+}
+
+create_update_snapshot() {
+ local timestamp snapshot image version pointer_tmp
+ timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
+ snapshot="${INSTALL_DIR}/data/backups/cli-rollback-${timestamp}"
+ image="$(current_image_id || true)"
+ [[ -n "${image}" ]] || { warn "无法确定当前运行镜像。"; return 1; }
+ docker image inspect "${image}" >/dev/null 2>&1 || { warn "当前镜像不存在:${image}"; return 1; }
+ install -d -m 0700 "${snapshot}"
+
+ cp -p "${INSTALL_DIR}/docker-compose.yml" "${snapshot}/docker-compose.yml" || return 1
+ cp -p "${INSTALL_DIR}/.env" "${snapshot}/.env" || return 1
+ if [[ -f "${INSTALL_DIR}/.env.example" ]]; then
+ cp -p "${INSTALL_DIR}/.env.example" "${snapshot}/.env.example" || return 1
+ else
+ : > "${snapshot}/env-example.absent"
+ fi
+ if [[ -f "${CLI_PATH}" ]]; then
+ cp -p "${CLI_PATH}" "${snapshot}/newszxcn-email" || return 1
+ else
+ : > "${snapshot}/installer.absent"
+ fi
+ if [[ -f "${NGINX_CONFIG}" ]]; then
+ cp -p "${NGINX_CONFIG}" "${snapshot}/nginx.conf" || return 1
+ else
+ : > "${snapshot}/nginx.absent"
+ fi
+ if [[ -d "${CERT_DIR}" ]]; then
+ cp -a "${CERT_DIR}" "${snapshot}/certs" || return 1
+ else
+ : > "${snapshot}/certs.absent"
+ fi
+ backup_database "${snapshot}/database.db" "${image}" || return 1
+
+ version="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${image}" 2>/dev/null || true)"
+ printf '%s\n' "${image}" > "${snapshot}/image"
+ printf '%s\n' "${version:-unknown}" > "${snapshot}/version"
+ cat > "${snapshot}/rollback-manifest.json" < "${pointer_tmp}"
+ mv "${pointer_tmp}" "${ROLLBACK_POINTER}"
+ printf '%s\n' "${image}" > "${ROLLBACK_FILE}"
+ log "更新回滚快照已创建:${snapshot}"
+}
+
+restore_update_snapshot() {
+ local snapshot="${1:-}" restore_database="${2:-true}" image
+ if [[ -z "${snapshot}" && -f "${ROLLBACK_POINTER}" ]]; then
+ snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
+ fi
+ [[ -d "${snapshot}" && -s "${snapshot}/database.db" && -s "${snapshot}/image" && -s "${snapshot}/docker-compose.yml" && -s "${snapshot}/.env" ]] \
+ || { warn "回滚快照不完整。"; return 1; }
+ [[ -f "${snapshot}/.env.example" || -f "${snapshot}/env-example.absent" ]] \
+ && [[ -f "${snapshot}/newszxcn-email" || -f "${snapshot}/installer.absent" ]] \
+ && [[ -f "${snapshot}/nginx.conf" || -f "${snapshot}/nginx.absent" ]] \
+ && [[ -d "${snapshot}/certs" || -f "${snapshot}/certs.absent" ]] \
+ || { warn "回滚快照缺少文件状态标记。"; return 1; }
+ image="$(tr -d '\r\n' < "${snapshot}/image")"
+ docker image inspect "${image}" >/dev/null 2>&1 || { warn "回滚镜像已不存在:${image}"; return 1; }
+ sqlite_integrity_check "${snapshot}/database.db" "${image}" || { warn "回滚数据库完整性检查未通过。"; return 1; }
+
+ log "正在恢复更新前快照:${snapshot}"
+ compose down --remove-orphans >/dev/null 2>&1 || true
+ install -m 0644 "${snapshot}/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" || return 1
+ install -m 0600 "${snapshot}/.env" "${INSTALL_DIR}/.env" || return 1
+ if [[ -f "${snapshot}/.env.example" ]]; then
+ install -m 0644 "${snapshot}/.env.example" "${INSTALL_DIR}/.env.example" || return 1
+ elif [[ -f "${snapshot}/env-example.absent" ]]; then
+ rm -f "${INSTALL_DIR}/.env.example"
+ fi
+ if [[ -f "${snapshot}/newszxcn-email" ]]; then
+ install -m 0755 "${snapshot}/newszxcn-email" "${CLI_PATH}" || return 1
+ elif [[ -f "${snapshot}/installer.absent" ]]; then
+ rm -f "${CLI_PATH}"
+ fi
+ if [[ "${restore_database}" == "true" ]]; then
+ rm -f "${INSTALL_DIR}/data/lanqin.db-wal" "${INSTALL_DIR}/data/lanqin.db-shm"
+ install -m 0600 "${snapshot}/database.db" "${INSTALL_DIR}/data/lanqin.db" || return 1
+ fi
+ if [[ -d "${snapshot}/certs" ]]; then
+ rm -rf "${CERT_DIR}"
+ cp -a "${snapshot}/certs" "${CERT_DIR}" || return 1
+ elif [[ -f "${snapshot}/certs.absent" ]]; then
+ rm -rf "${CERT_DIR}"
+ fi
+ if [[ -f "${snapshot}/nginx.conf" ]]; then
+ install -m 0644 "${snapshot}/nginx.conf" "${NGINX_CONFIG}" || return 1
+ elif [[ -f "${snapshot}/nginx.absent" ]]; then
+ rm -f "${NGINX_CONFIG}"
+ fi
+ printf '%s\n' "${image}" > "${RUNTIME_IMAGE_PIN}"
+ chmod 0600 "${RUNTIME_IMAGE_PIN}"
+ compose up -d --remove-orphans --force-recreate || return 1
+ reload_nginx || return 1
+ wait_for_health 90 || return 1
+ ensure_cli_alias
}
do_repair_install() {
- refresh_assets
- ensure_update_token
- configure_runtime_bindings
ensure_docker
- backup_database
- remember_current_image
- configure_firewall
- prepare_directories
+ create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
+ stage_assets
+ clear_runtime_image_pin
+ if ! apply_staged_assets || ! ensure_update_token || ! configure_runtime_bindings; then
+ restore_update_snapshot "" false || true
+ fail "修复准备失败,已恢复原安装。"
+ fi
+ if ! (configure_firewall && prepare_directories); then
+ restore_update_snapshot "" false || true
+ fail "修复环境准备失败,已恢复原安装。"
+ fi
log "正在拉取并修复 NewSzxcn Email 服务..."
- compose pull
+ if ! compose pull; then
+ restore_update_snapshot "" false || true
+ fail "修复镜像拉取失败,已恢复原安装。"
+ fi
log "正在启动服务..."
if ! compose up -d --remove-orphans; then
warn "修复后容器启动失败,正在自动回滚。"
- do_rollback
- fail "修复失败,已回滚到原镜像。"
+ restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
+ fail "修复失败,已恢复到修复前版本。"
fi
if ! wait_for_health 90; then
warn "修复后健康检查失败,正在自动回滚。"
- do_rollback
- fail "修复失败,已回滚到原镜像。"
+ restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
+ fail "修复失败,已恢复到修复前版本。"
fi
- configure_web_mode
+ if ! (configure_web_mode); then
+ restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
+ fail "Web 配置失败,已恢复到修复前版本。"
+ fi
+ generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
+ warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
}
do_install() {
@@ -629,54 +870,65 @@ do_install() {
compose up -d --remove-orphans
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
configure_web_mode
+ generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
+ warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
}
do_update() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。"
ensure_docker
- refresh_assets
- ensure_update_token
- backup_database
- remember_current_image
+ create_update_snapshot || fail "更新前备份失败,未修改现有安装。"
+ stage_assets
+ clear_runtime_image_pin
+ if ! apply_staged_assets || ! ensure_update_token; then
+ restore_update_snapshot "" false || true
+ fail "更新文件替换失败,已恢复原安装。"
+ fi
log "正在拉取最新版..."
- compose pull
+ if ! compose pull; then
+ restore_update_snapshot "" false || true
+ fail "镜像拉取失败,已恢复到更新前版本。"
+ fi
if ! compose up -d --remove-orphans; then
warn "新版本容器启动失败,正在自动回滚。"
- do_rollback
- fail "更新失败,已回滚到原镜像。"
+ restore_update_snapshot || fail "更新失败,且自动恢复未完成,请使用回滚快照手动恢复。"
+ fail "更新失败,已恢复到更新前版本。"
fi
if ! wait_for_health 90; then
warn "新版本健康检查失败,正在自动回滚。"
- do_rollback
- fail "更新失败,已回滚到原镜像。"
+ restore_update_snapshot || fail "更新失败,且自动恢复未完成,请使用回滚快照手动恢复。"
+ fail "更新失败,已恢复到更新前版本。"
fi
+ ensure_cli_alias
+ generate_guide >/dev/null || warn "更新成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "系统已更新,配置、邮件、证书和数据库均已保留。"
}
do_rollback() {
- [[ -f "${ROLLBACK_FILE}" ]] || fail "没有可用的回滚镜像。"
- local image
- image="$(tr -d '\r\n' < "${ROLLBACK_FILE}")"
- docker image inspect "${image}" >/dev/null 2>&1 || fail "回滚镜像已不存在:${image}"
- log "正在回滚到 ${image}..."
- LANQIN_IMAGE="${image}" compose up -d --no-deps --force-recreate lanqin-email
- wait_for_health 90 || fail "回滚后服务仍未通过健康检查,请查看日志。"
- success "已回滚到 ${image}。"
+ [[ -f "${ROLLBACK_POINTER}" ]] || fail "没有可用的完整回滚快照。"
+ local confirm="${LANQIN_ROLLBACK_CONFIRM:-}" image timestamp emergency_backup
+ if [[ -z "${confirm}" ]] && has_tty; then
+ read -r -p "回滚会用更新前数据库覆盖当前数据库,确认继续吗?[y/N]: " confirm /dev/null
- if command -v nginx >/dev/null 2>&1 && [[ -f "${NGINX_CONFIG}" ]]; then
- nginx -t >/dev/null
- if command -v systemctl >/dev/null 2>&1; then
- systemctl reload nginx
- else
- nginx -s reload
- fi
+ if [[ -f "${NGINX_CONFIG}" ]]; then
+ reload_nginx || fail "Nginx 配置检查或重载失败。"
fi
}
@@ -694,9 +946,100 @@ do_certificate() {
install_certificate
write_nginx_https_config
reload_services
+ generate_guide >/dev/null || warn "证书已应用,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "SSL 证书已安装并应用。"
}
+generate_guide() {
+ [[ -f "${INSTALL_DIR}/.env" ]] || return 1
+ local public_url admin_url hostname admin_username certificate_expiry="未安装" renewal_status="未开启" next_renewal="等待 acme.sh 生成续期计划"
+ local acme_info="" tmp
+ public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
+ hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)"
+ admin_username="$(env_value LANQIN_ADMIN_USERNAME || true)"
+ public_url="${public_url:-http://${hostname}}"
+ admin_url="${public_url%/}/admin"
+
+ if [[ -s "${CERT_DIR}/fullchain.pem" ]] && command -v openssl >/dev/null 2>&1; then
+ certificate_expiry="$(openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -enddate 2>/dev/null | sed 's/^notAfter=//' || true)"
+ certificate_expiry="${certificate_expiry:-无法读取}"
+ fi
+ if [[ -x /root/.acme.sh/acme.sh ]]; then
+ acme_info="$(/root/.acme.sh/acme.sh --info --domain "${hostname}" --ecc 2>/dev/null || true)"
+ next_renewal="$(printf '%s\n' "${acme_info}" | sed -n "s/^Le_NextRenewTimeStr=['\"]*\([^'\"]*\).*/\1/p" | tail -n 1)"
+ next_renewal="${next_renewal:-等待 acme.sh 生成续期计划}"
+ if acme_cron_enabled; then
+ renewal_status="已开启"
+ else
+ renewal_status="已安装 acme.sh,但未检测到定时任务"
+ fi
+ fi
+
+ tmp="$(mktemp)"
+ cat > "${tmp}" < 系统设置 -> 邮件」。
+2. 开启「账号自助申请邮箱」,并勾选至少一个开放域名。
+3. 用户登录邮箱前台后,可在「设置 -> 邮箱管理」中自行申请邮箱。
+
+--------------------------------------------------
+三、开启无人收件
+--------------------------------------------------
+
+1. 进入「管理后台 -> 系统设置 -> 邮件」。
+2. 开启「无人收件」并保存。
+3. 已启用域名下未注册地址收到的邮件,只能由管理员在「未知收件」中查看。
+
+--------------------------------------------------
+四、服务器管理与更新
+--------------------------------------------------
+
+打开菜单:ns
+完整命令:newszxcn-email menu
+更新系统:newszxcn-email update
+查看状态:newszxcn-email status
+查看日志:newszxcn-email logs
+恢复版本:newszxcn-email rollback
+
+公开教程:
+https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/GUIDE.md
+EOF
+ install -m 0600 "${tmp}" "${GUIDE_FILE}"
+ rm -f "${tmp}"
+}
+
+do_guide() {
+ generate_guide || fail "尚未安装,无法生成邮箱指南。"
+ cat "${GUIDE_FILE}"
+ success "指南已更新并保存到 ${GUIDE_FILE}。"
+}
+
do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose ps
@@ -709,14 +1052,27 @@ do_status() {
do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
+ local confirm="${LANQIN_UNINSTALL_CONFIRM:-}" remove_renewal="${LANQIN_REMOVE_CERT_RENEWAL:-}" hostname
+ if [[ -z "${confirm}" ]] && has_tty; then
+ read -r -p "确认停止并卸载服务吗?邮件和配置将保留。[y/N]: " confirm /dev/null 2>&1 && nginx -t >/dev/null 2>&1; then
- if command -v systemctl >/dev/null 2>&1; then
- systemctl reload nginx
+ reload_nginx || warn "Nginx 未能重载,请检查并手动重载。"
+ fi
+ if [[ -x /root/.acme.sh/acme.sh && -n "${hostname}" ]]; then
+ if [[ -z "${remove_renewal}" ]] && has_tty; then
+ read -r -p "是否停止 ${hostname} 的证书自动续期?[Y/n]: " remove_renewal /dev/null 2>&1; then
+ success "已停止 ${hostname} 的证书自动续期。"
else
- nginx -s reload
+ warn "未能移除 ${hostname} 的续期记录,请使用 acme.sh 手动检查。"
fi
fi
fi
@@ -724,28 +1080,74 @@ do_uninstall() {
}
do_backup_reinstall() {
- local backup_dir
+ local backup_dir failed_dir old_image
backup_dir="${INSTALL_DIR}.backup-$(date -u +%Y%m%dT%H%M%SZ)"
+ failed_dir="${INSTALL_DIR}.failed-$(date -u +%Y%m%dT%H%M%SZ)"
- if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] && command -v docker >/dev/null 2>&1; then
- ensure_docker
- compose down --remove-orphans
+ [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "旧安装缺少 docker-compose.yml,请先选择修复现有安装。"
+ ensure_docker
+ old_image="$(current_image_id || true)"
+ [[ -n "${old_image}" ]] || fail "无法确定旧安装镜像,已取消重新安装。"
+ printf '%s\n' "${old_image}" > "${INSTALL_DIR}/.reinstall-image"
+ if [[ -f "${CLI_PATH}" ]]; then
+ cp -p "${CLI_PATH}" "${INSTALL_DIR}/.reinstall-installer"
fi
+ if [[ -f "${NGINX_CONFIG}" ]]; then
+ cp -p "${NGINX_CONFIG}" "${INSTALL_DIR}/.reinstall-nginx.conf"
+ else
+ : > "${INSTALL_DIR}/.reinstall-nginx.absent"
+ fi
+ compose down --remove-orphans
if [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}"
- if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then
- if command -v systemctl >/dev/null 2>&1; then
- systemctl reload nginx
- else
- nginx -s reload
- fi
+ if ! reload_nginx; then
+ install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
+ reload_nginx || true
+ LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate \
+ || fail "Nginx 配置已恢复,但旧容器启动失败,请检查 ${INSTALL_DIR}。"
+ wait_for_health 90 || fail "Nginx 配置已恢复,但旧服务健康检查失败,请查看日志。"
+ fail "Nginx 重载失败,已恢复旧配置并取消重新安装。"
fi
fi
- mv "${INSTALL_DIR}" "${backup_dir}"
+ if ! mv "${INSTALL_DIR}" "${backup_dir}"; then
+ if [[ -f "${INSTALL_DIR}/.reinstall-nginx.conf" ]]; then
+ install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
+ fi
+ LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate || true
+ reload_nginx || true
+ fail "旧安装目录备份失败,已取消重新安装。"
+ fi
success "旧安装已完整备份到 ${backup_dir}。"
log "现在开始全新安装。"
- do_install
+ if (do_install); then
+ success "重新安装完成;旧安装备份保留在 ${backup_dir}。"
+ return 0
+ fi
+
+ warn "重新安装失败,正在自动恢复旧安装。"
+ if [[ -d "${INSTALL_DIR}" ]]; then
+ if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
+ compose down --remove-orphans >/dev/null 2>&1 || true
+ fi
+ mv "${INSTALL_DIR}" "${failed_dir}"
+ fi
+ mv "${backup_dir}" "${INSTALL_DIR}" || fail "无法恢复旧安装目录,备份仍位于 ${backup_dir}。"
+ old_image="$(tr -d '\r\n' < "${INSTALL_DIR}/.reinstall-image")"
+ if [[ -f "${INSTALL_DIR}/.reinstall-installer" ]]; then
+ install -m 0755 "${INSTALL_DIR}/.reinstall-installer" "${CLI_PATH}"
+ fi
+ if [[ -f "${INSTALL_DIR}/.reinstall-nginx.conf" ]]; then
+ install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
+ elif [[ -f "${INSTALL_DIR}/.reinstall-nginx.absent" ]]; then
+ rm -f "${NGINX_CONFIG}"
+ fi
+ LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate \
+ || fail "旧安装目录已恢复,但旧容器启动失败,请检查 ${INSTALL_DIR}。"
+ reload_nginx || fail "旧安装目录和容器已恢复,但 Nginx 重载失败,请手动检查。"
+ wait_for_health 90 || fail "旧安装已恢复,但健康检查失败,请查看日志。"
+ ensure_cli_alias
+ fail "重新安装失败,旧安装已自动恢复。失败的新安装保存在 ${failed_dir}。"
}
do_menu() {
@@ -766,19 +1168,20 @@ do_menu() {
prompt_text ' 状态:未安装\n'
fi
prompt_text '--------------------------------------------------\n'
- prompt_text ' 1. 安装 / 重新安装(旧数据自动备份)\n'
- prompt_text ' 2. 更新系统(数据库自动备份)\n'
- prompt_text ' 3. 修复现有安装\n'
+ prompt_text ' 1. 安装 / 重新安装(完整备份,失败自动恢复)\n'
+ prompt_text ' 2. 更新系统(数据库备份,失败自动回滚)\n'
+ prompt_text ' 3. 检查并修复现有安装\n'
prompt_text ' 4. 查看运行状态\n'
prompt_text ' 5. 重启服务\n'
prompt_text ' 6. 查看实时日志\n'
- prompt_text ' 7. 申请或续期 SSL 证书\n'
- prompt_text ' 8. 回滚上个命令行版本\n'
- prompt_text ' 9. 卸载服务(保留数据)\n'
+ prompt_text ' 7. 申请、检查或续期 SSL 证书\n'
+ prompt_text ' 8. 回滚到上次更新前版本\n'
+ prompt_text ' 9. NewSzxcn 邮箱指南\n'
+ prompt_text ' 10. 卸载服务(保留数据)\n'
prompt_text ' 0. 退出\n'
prompt_text '==================================================\n'
- choice="$(prompt_menu_choice "${default_choice}")"
+ choice="$(prompt_menu_choice "${default_choice}" "10")"
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
fail "尚未安装,请先选择 1。"
fi
@@ -793,7 +1196,8 @@ do_menu() {
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
7) do_certificate ;;
8) ensure_docker; do_rollback ;;
- 9) ensure_docker; do_uninstall ;;
+ 9) do_guide ;;
+ 10) ensure_docker; do_uninstall ;;
esac
}
@@ -804,6 +1208,10 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
exit 0
fi
+if [[ "${EUID}" -eq 0 ]]; then
+ ensure_cli_alias
+fi
+
case "${COMMAND}" in
help|-h|--help) usage ;;
menu) require_root; require_curl; do_menu ;;
@@ -815,6 +1223,7 @@ case "${COMMAND}" in
reload) require_root; require_curl; reload_services ;;
certificate) require_root; require_curl; do_certificate ;;
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
+ guide) require_root; require_curl; do_guide ;;
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;;
esac
diff --git a/tests/install_test.sh b/tests/install_test.sh
index 40df73a..a681889 100644
--- a/tests/install_test.sh
+++ b/tests/install_test.sh
@@ -114,38 +114,341 @@ test_menu_choice() {
assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action"
export LANQIN_MENU_ACTION=1
assert_eq "1" "$(prompt_menu_choice 2)" "menu install action"
- export LANQIN_MENU_ACTION=9
- assert_eq "9" "$(prompt_menu_choice 1)" "menu uninstall action"
+ export LANQIN_MENU_ACTION=10
+ assert_eq "10" "$(prompt_menu_choice 1 10)" "menu uninstall action"
+ if (has_tty() { return 1; }; LANQIN_MENU_ACTION=11 prompt_menu_choice 1 10 >/dev/null 2>&1); then
+ fail_test "out-of-range menu action accepted"
+ fi
unset LANQIN_MENU_ACTION
}
-test_backup_reinstall_preserves_existing_directory() (
- local temp_dir backup_dir
+test_offline_database_backup() (
+ local temp_dir destination
+ temp_dir="$(mktemp -d)"
+ INSTALL_DIR="${temp_dir}/install"
+ mkdir -p "${INSTALL_DIR}/data/backups"
+ sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO test_items(value) VALUES ("saved");'
+ compose() { return 0; }
+ destination="${INSTALL_DIR}/data/backups/offline.db"
+ backup_database "${destination}" "unused-image"
+ [[ -s "${destination}" ]] || fail_test "offline database backup missing"
+ assert_eq "saved" "$(sqlite3 "${destination}" 'SELECT value FROM test_items LIMIT 1;')" "offline database content"
+)
+
+test_guide_generation() (
+ local temp_dir
+ temp_dir="$(mktemp -d)"
+ INSTALL_DIR="${temp_dir}/install"
+ CERT_DIR="${INSTALL_DIR}/certs"
+ GUIDE_FILE="${temp_dir}/guide.txt"
+ mkdir -p "${CERT_DIR}"
+ cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env"
+ set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
+ set_env LANQIN_PUBLIC_BASE_URL "https://mail.example.com"
+ set_env LANQIN_ADMIN_USERNAME "admin"
+ generate_guide
+ grep -Fq '邮箱前台:https://mail.example.com' "${GUIDE_FILE}" || fail_test "guide frontend URL missing"
+ grep -Fq '管理后台:https://mail.example.com/admin' "${GUIDE_FILE}" || fail_test "guide admin URL missing"
+ grep -Fq '管理员密码:仅在安装完成时显示' "${GUIDE_FILE}" || fail_test "guide password safety text missing"
+ [[ "$(stat -f '%Lp' "${GUIDE_FILE}" 2>/dev/null || stat -c '%a' "${GUIDE_FILE}")" == "600" ]] || fail_test "guide permissions are not 600"
+)
+
+test_acme_cron_detection() (
+ crontab() {
+ printf '49 0,6,12,18 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null\n'
+ }
+ acme_cron_enabled || fail_test "quoted acme.sh Cron entry was not detected"
+)
+
+test_cli_alias_safety() (
+ local temp_dir
+ temp_dir="$(mktemp -d)"
+ CLI_PATH="${temp_dir}/newszxcn-email"
+ CLI_ALIAS_PATH="${temp_dir}/ns"
+ printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
+ chmod 0755 "${CLI_PATH}"
+ ensure_cli_alias
+ [[ -L "${CLI_ALIAS_PATH}" ]] || fail_test "ns alias was not created"
+ assert_eq "${CLI_PATH}" "$(readlink "${CLI_ALIAS_PATH}")" "ns alias target"
+ rm -f "${CLI_ALIAS_PATH}"
+ printf 'occupied\n' > "${CLI_ALIAS_PATH}"
+ ensure_cli_alias
+ grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
+)
+
+test_compose_runtime_image_pin() (
+ local temp_dir calls
+ temp_dir="$(mktemp -d)"
+ INSTALL_DIR="${temp_dir}/install"
+ RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
+ calls="${temp_dir}/docker-calls"
+ mkdir -p "${INSTALL_DIR}"
+ printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
+ printf 'sha256:rollback-image\n' > "${RUNTIME_IMAGE_PIN}"
+ docker() {
+ printf '%s|%s\n' "${LANQIN_IMAGE:-}" "$*" >> "${calls}"
+ }
+
+ compose ps
+ grep -Fq 'sha256:rollback-image|compose ' "${calls}" || fail_test "rollback image pin was not applied to Compose"
+ clear_runtime_image_pin
+ compose ps
+ [[ "$(tail -n 1 "${calls}" | cut -d '|' -f 1)" == "" ]] || fail_test "cleared image pin still affected Compose"
+)
+
+test_update_snapshot_restore() (
+ local temp_dir snapshot
+ temp_dir="$(mktemp -d)"
+ INSTALL_DIR="${temp_dir}/install"
+ CERT_DIR="${INSTALL_DIR}/certs"
+ NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
+ CLI_PATH="${temp_dir}/newszxcn-email-cli"
+ CLI_ALIAS_PATH="${temp_dir}/ns"
+ ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
+ ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
+ RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
+ mkdir -p "${INSTALL_DIR}/data/backups" "${CERT_DIR}"
+ printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
+ printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\nOLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
+ printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
+ printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
+ chmod 0755 "${CLI_PATH}"
+ printf 'old-nginx\n' > "${NGINX_CONFIG}"
+ printf 'old-certificate\n' > "${CERT_DIR}/fullchain.pem"
+ sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("before-update");'
+
+ current_image_id() { printf 'sha256:old-image\n'; }
+ docker() {
+ if [[ "$*" == *'org.opencontainers.image.version'* ]]; then
+ printf '1.2.4\n'
+ fi
+ return 0
+ }
+ compose() {
+ if [[ "${1:-}" == "up" ]]; then
+ grep -Fq 'sha256:old-image' "${RUNTIME_IMAGE_PIN}" || fail_test "restore started without image pin"
+ fi
+ return 0
+ }
+ nginx() { return 0; }
+ systemctl() { return 0; }
+ wait_for_health() { return 0; }
+ ensure_cli_alias() { return 0; }
+
+ create_update_snapshot
+ snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
+ [[ -s "${snapshot}/rollback-manifest.json" ]] || fail_test "rollback manifest missing"
+
+ printf 'new-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
+ printf 'NEW_ENV=yes\n' > "${INSTALL_DIR}/.env"
+ printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
+ printf '#!/bin/sh\necho new-installer\n' > "${CLI_PATH}"
+ printf 'new-nginx\n' > "${NGINX_CONFIG}"
+ printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
+ sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'DELETE FROM test_items; INSERT INTO test_items VALUES ("after-update");'
+
+ restore_update_snapshot "${snapshot}"
+ grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "Compose file was not restored"
+ grep -Fq 'OLD_ENV=yes' "${INSTALL_DIR}/.env" || fail_test "environment file was not restored"
+ grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "environment example was not restored"
+ grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "installer was not restored"
+ grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored"
+ grep -Fq 'old-certificate' "${CERT_DIR}/fullchain.pem" || fail_test "certificate was not restored"
+ assert_eq "before-update" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT value FROM test_items;')" "restored database content"
+ assert_eq "sha256:old-image" "$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")" "restored runtime image pin"
+)
+
+test_snapshot_restores_absent_optional_files() (
+ local temp_dir snapshot
+ temp_dir="$(mktemp -d)"
+ INSTALL_DIR="${temp_dir}/install"
+ CERT_DIR="${INSTALL_DIR}/certs"
+ NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
+ CLI_PATH="${temp_dir}/newszxcn-email-cli"
+ CLI_ALIAS_PATH="${temp_dir}/ns"
+ ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
+ ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
+ RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
+ mkdir -p "${INSTALL_DIR}/data/backups"
+ printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
+ printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
+ sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("saved");'
+
+ current_image_id() { printf 'sha256:old-image\n'; }
+ docker() { return 0; }
+ compose() { return 0; }
+ nginx() { return 0; }
+ systemctl() { return 0; }
+ wait_for_health() { return 0; }
+ ensure_cli_alias() { return 0; }
+
+ create_update_snapshot
+ snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
+ [[ -f "${snapshot}/env-example.absent" ]] || fail_test "missing env example marker"
+ [[ -f "${snapshot}/installer.absent" ]] || fail_test "missing installer marker"
+ [[ -f "${snapshot}/nginx.absent" ]] || fail_test "missing Nginx marker"
+ [[ -f "${snapshot}/certs.absent" ]] || fail_test "missing certificate marker"
+
+ mkdir -p "${CERT_DIR}"
+ printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
+ printf '#!/bin/sh\n' > "${CLI_PATH}"
+ printf 'new-nginx\n' > "${NGINX_CONFIG}"
+ printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
+ restore_update_snapshot "${snapshot}"
+ [[ ! -e "${INSTALL_DIR}/.env.example" ]] || fail_test "new env example survived rollback"
+ [[ ! -e "${CLI_PATH}" ]] || fail_test "new installer survived rollback"
+ [[ ! -e "${NGINX_CONFIG}" ]] || fail_test "new Nginx configuration survived rollback"
+ [[ ! -e "${CERT_DIR}" ]] || fail_test "new certificate directory survived rollback"
+)
+
+test_pre_start_restore_preserves_current_database() (
+ local temp_dir snapshot
+ temp_dir="$(mktemp -d)"
+ INSTALL_DIR="${temp_dir}/install"
+ CERT_DIR="${INSTALL_DIR}/certs"
+ NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
+ CLI_PATH="${temp_dir}/newszxcn-email-cli"
+ CLI_ALIAS_PATH="${temp_dir}/ns"
+ ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
+ ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
+ RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
+ mkdir -p "${INSTALL_DIR}/data/backups"
+ printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
+ printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
+ sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE received_mail (subject TEXT); INSERT INTO received_mail VALUES ("before-snapshot");'
+
+ current_image_id() { printf 'sha256:old-image\n'; }
+ docker() { return 0; }
+ compose() { return 0; }
+ reload_nginx() { return 0; }
+ wait_for_health() { return 0; }
+ ensure_cli_alias() { return 0; }
+
+ create_update_snapshot
+ snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
+ sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'INSERT INTO received_mail VALUES ("received-during-pull");'
+ restore_update_snapshot "${snapshot}" false
+ assert_eq "2" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT COUNT(*) FROM received_mail;')" "database preserved before new container start"
+ assert_eq "received-during-pull" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT subject FROM received_mail ORDER BY rowid DESC LIMIT 1;')" "mail received during pull"
+)
+
+test_failed_asset_validation_preserves_production() (
+ local temp_dir source_dir
+ temp_dir="$(mktemp -d)"
+ source_dir="${temp_dir}/source"
+ INSTALL_DIR="${temp_dir}/install"
+ CLI_PATH="${temp_dir}/newszxcn-email-cli"
+ RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
+ mkdir -p "${source_dir}/deploy" "${INSTALL_DIR}"
+ printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
+ printf 'OLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
+ printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
+ printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
+ printf 'sha256:pinned-image\n' > "${RUNTIME_IMAGE_PIN}"
+ printf 'invalid compose\n' > "${source_dir}/deploy/docker-compose.yml"
+ cp "${ROOT_DIR}/deploy/.env.example" "${source_dir}/deploy/.env.example"
+ cp "${ROOT_DIR}/install.sh" "${source_dir}/install.sh"
+
+ script_dir() { printf '%s\n' "${source_dir}"; }
+ docker() { return 1; }
+ if (stage_assets >/dev/null 2>&1); then
+ fail_test "invalid Compose file passed staging validation"
+ fi
+ grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "production Compose changed after failed validation"
+ grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "production env example changed after failed validation"
+ grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "production installer changed after failed validation"
+ grep -Fq 'sha256:pinned-image' "${RUNTIME_IMAGE_PIN}" || fail_test "runtime image pin changed after failed validation"
+)
+
+test_backup_reinstall_restores_on_failure() (
+ local temp_dir failed_dir
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/newszxcn-email"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
+ CLI_PATH="${temp_dir}/newszxcn-email-cli"
+ CLI_ALIAS_PATH="${temp_dir}/ns"
mkdir -p "${INSTALL_DIR}"
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
+ printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
+ printf 'old-nginx\n' > "${NGINX_CONFIG}"
+ printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
+ chmod 0755 "${CLI_PATH}"
+ ensure_docker() { return 0; }
+ current_image_id() { printf 'sha256:old-image\n'; }
+ compose() { return 0; }
+ nginx() { return 0; }
+ systemctl() { return 0; }
+ wait_for_health() { return 0; }
+ ensure_cli_alias() { return 0; }
do_install() {
- [[ ! -e "${INSTALL_DIR}" ]] || fail_test "fresh install started before old directory was moved"
+ mkdir -p "${INSTALL_DIR}"
+ printf 'failed-install\n' > "${INSTALL_DIR}/failed-marker"
+ return 1
}
- do_backup_reinstall
- backup_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.backup-*' -print -quit)"
- [[ -n "${backup_dir}" ]] || fail_test "existing install backup directory missing"
- grep -Fq 'existing-data' "${backup_dir}/marker" || fail_test "existing install data was not preserved"
+ if (do_backup_reinstall); then
+ fail_test "failed reinstall unexpectedly succeeded"
+ fi
+ grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install directory was not restored"
+ grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "old Nginx configuration was not restored"
+ failed_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.failed-*' -print -quit)"
+ [[ -n "${failed_dir}" ]] || fail_test "failed reinstall directory was not preserved"
+)
+
+test_backup_reinstall_recovers_from_nginx_reload_failure() (
+ local temp_dir compose_calls reload_count_file
+ temp_dir="$(mktemp -d)"
+ INSTALL_DIR="${temp_dir}/newszxcn-email"
+ NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
+ CLI_PATH="${temp_dir}/newszxcn-email-cli"
+ CLI_ALIAS_PATH="${temp_dir}/ns"
+ compose_calls="${temp_dir}/compose-calls"
+ reload_count_file="${temp_dir}/reload-count"
+ mkdir -p "${INSTALL_DIR}"
+ printf 'existing-data\n' > "${INSTALL_DIR}/marker"
+ printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
+ printf 'old-nginx\n' > "${NGINX_CONFIG}"
+ printf '0\n' > "${reload_count_file}"
+
+ ensure_docker() { return 0; }
+ current_image_id() { printf 'sha256:old-image\n'; }
+ compose() { printf '%s\n' "$*" >> "${compose_calls}"; return 0; }
+ reload_nginx() {
+ local count
+ count="$(cat "${reload_count_file}")"
+ printf '%s\n' "$((count + 1))" > "${reload_count_file}"
+ [[ "${count}" -gt 0 ]]
+ }
+ wait_for_health() { return 0; }
+ do_install() { fail_test "fresh install started after Nginx reload failure"; }
+
+ if (do_backup_reinstall >/dev/null 2>&1); then
+ fail_test "reinstall continued after Nginx reload failure"
+ fi
+ grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install changed after Nginx reload failure"
+ grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored after reload failure"
+ grep -Fq 'up -d --remove-orphans --force-recreate' "${compose_calls}" || fail_test "old containers were not restarted after Nginx reload failure"
)
test_hostname_validation
test_password_validation
test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false"
test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
-test_install_configuration 3 3 "80" "http://mail.example.com" "true"
test_nginx_configuration
test_compose_configuration
test_legacy_configuration_is_preserved
test_menu_choice
-test_backup_reinstall_preserves_existing_directory
+test_offline_database_backup
+test_guide_generation
+test_acme_cron_detection
+test_cli_alias_safety
+test_compose_runtime_image_pin
+test_update_snapshot_restore
+test_snapshot_restores_absent_optional_files
+test_pre_start_restore_preserves_current_database
+test_failed_asset_validation_preserves_production
+test_backup_reinstall_restores_on_failure
+test_backup_reinstall_recovers_from_nginx_reload_failure
printf 'install.sh tests passed\n'