feat: harden installer management menu

This commit is contained in:
zxyszx
2026-08-04 02:23:46 +08:00
parent 6d0147b639
commit 6301182cfc
8 changed files with 861 additions and 138 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Check installer - name: Check installer
run: | run: |
sudo apt-get update 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 bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh bash tests/install_test.sh
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Check installer - name: Check installer
run: | run: |
sudo apt-get update 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 bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh bash tests/install_test.sh
+9 -5
View File
@@ -37,13 +37,13 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员 脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将 账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,再进入首次安装流程。更新会先备份数据库,并在 `/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
启动失败时自动回滚 校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复
脚本会自动完成: 脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2 - 安装或检查 Docker Engine 与 Docker Compose v2
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口 - 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位 - 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 - 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
@@ -70,15 +70,19 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
sudo newszxcn-email update sudo newszxcn-email update
``` ```
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行: 命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
```bash ```bash
sudo newszxcn-email rollback sudo newszxcn-email rollback
``` ```
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
常用运维命令: 常用运维命令:
```bash ```bash
sudo ns
sudo newszxcn-email guide
sudo newszxcn-email status sudo newszxcn-email status
sudo newszxcn-email logs sudo newszxcn-email logs
sudo newszxcn-email restart sudo newszxcn-email restart
@@ -86,7 +90,7 @@ sudo newszxcn-email certificate
sudo newszxcn-email uninstall sudo newszxcn-email uninstall
``` ```
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。 `uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
## DNS 与端口 ## DNS 与端口
+1 -2
View File
@@ -174,7 +174,6 @@ export function MailPage() {
const canDownloadAttachments = hasPermission(user, "mail.attachments.download") const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
const canManageSignatures = hasPermission(user, "mail.signatures.manage") const canManageSignatures = hasPermission(user, "mail.signatures.manage")
const canViewUnknownMail = user?.role === "admin" const canViewUnknownMail = user?.role === "admin"
const canManageMailboxes = hasPermission(user, "admin.mailboxes.view")
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
@@ -1469,7 +1468,7 @@ export function MailPage() {
) : !canReadMail ? ( ) : !canReadMail ? (
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} /> <PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? ( ) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
<NoMailboxState onManageMailboxes={() => navigate(canManageMailboxes ? "/admin?section=mailboxes" : "/profile?tab=mailboxes")} /> <NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
) : mailView === "scheduled" && canScheduleMail ? ( ) : mailView === "scheduled" && canScheduleMail ? (
<ScheduledSendView <ScheduledSendView
compact={compactMailLayout} compact={compactMailLayout}
+4 -1
View File
@@ -15,11 +15,14 @@ sudo newszxcn-email update
sudo newszxcn-email status sudo newszxcn-email status
sudo newszxcn-email logs sudo newszxcn-email logs
sudo newszxcn-email rollback sudo newszxcn-email rollback
sudo newszxcn-email guide
``` ```
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。 一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。 首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
## 最简单部署:单容器镜像版 ## 最简单部署:单容器镜像版
+7 -2
View File
@@ -88,9 +88,11 @@ sudo newszxcn-email certificate
重新打开安装与运维菜单: 重新打开安装与运维菜单:
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) sudo ns
``` ```
也可以执行 `sudo newszxcn-email menu`,或重新运行一键安装命令。
常用命令: 常用命令:
```bash ```bash
@@ -100,9 +102,12 @@ sudo newszxcn-email restart
sudo newszxcn-email logs sudo newszxcn-email logs
sudo newszxcn-email certificate sudo newszxcn-email certificate
sudo newszxcn-email rollback sudo newszxcn-email rollback
sudo newszxcn-email guide
``` ```
命令行更新会备份 SQLite 数据库、拉取最新镜像并执行健康检查。当前 `rollback` 命令用于恢复上次命令行更新前保存的 Docker 镜像 命令行更新会创建完整回滚快照、校验 SQLite 数据库备份、拉取最新镜像并执行健康检查。`rollback` 命令会先备份当前数据库并要求确认,然后恢复上次更新前的镜像、数据库、Compose、环境、安装脚本和 Nginx 配置。回滚镜像会保持锁定,下一次执行更新时解除
`guide` 命令会读取当前安装地址、管理员用户名、证书到期时间和 acme.sh 续期状态,重新生成仅 root 可读的 `/root/newszxcn-email-guide.txt`
超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。 超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。
+524 -115
View File
@@ -6,9 +6,14 @@ RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}" INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
COMMAND="${1:-menu}" COMMAND="${1:-menu}"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image" ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
NGINX_CONFIG="/etc/nginx/conf.d/newszxcn-email.conf" NGINX_CONFIG="/etc/nginx/conf.d/newszxcn-email.conf"
ACME_WEBROOT="/var/www/newszxcn-acme" ACME_WEBROOT="/var/www/newszxcn-acme"
CERT_DIR="${INSTALL_DIR}/certs" CERT_DIR="${INSTALL_DIR}/certs"
GUIDE_FILE="/root/newszxcn-email-guide.txt"
CLI_PATH="${LANQIN_CLI_PATH:-/usr/local/bin/newszxcn-email}"
CLI_ALIAS_PATH="${LANQIN_CLI_ALIAS_PATH:-/usr/local/bin/ns}"
log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; } log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; }
success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; } success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; }
@@ -28,7 +33,8 @@ NewSzxcn Email 管理命令
logs 持续查看运行日志 logs 持续查看运行日志
restart 重启服务并重载 Nginx restart 重启服务并重载 Nginx
certificate 申请或续期自动模式的 SSL 证书 certificate 申请或续期自动模式的 SSL 证书
rollback 回滚到上次命令行更新前的镜像 rollback 回滚到上次更新前版本
guide 显示并更新 NewSzxcn 邮箱指南
uninstall 停止并移除容器,保留邮件与配置 uninstall 停止并移除容器,保留邮件与配置
EOF EOF
} }
@@ -68,31 +74,93 @@ ensure_docker() {
} }
compose() { compose() {
docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@" local runtime_image=""
if [[ -s "${RUNTIME_IMAGE_PIN}" ]]; then
runtime_image="$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")"
fi
if [[ -n "${runtime_image}" ]]; then
LANQIN_IMAGE="${runtime_image}" docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
else
docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
fi
}
clear_runtime_image_pin() {
rm -f "${RUNTIME_IMAGE_PIN}"
} }
script_dir() { script_dir() {
cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd
} }
refresh_assets() { stage_assets() {
local source_dir local_source="false" local source_dir local_source="false"
local compose_new env_example_new installer_new
source_dir="$(script_dir || true)" source_dir="$(script_dir || true)"
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then
local_source="true" local_source="true"
fi fi
install -d -m 0755 "${INSTALL_DIR}" 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 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/docker-compose.yml" "${compose_new}"
install -m 0644 "${source_dir}/deploy/.env.example" "${INSTALL_DIR}/.env.example" install -m 0644 "${source_dir}/deploy/.env.example" "${env_example_new}"
install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email install -m 0755 "${source_dir}/install.sh" "${installer_new}"
else else
curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${INSTALL_DIR}/docker-compose.yml" curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${compose_new}"
curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${INSTALL_DIR}/.env.example" curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${env_example_new}"
curl -fsSL "${RAW_BASE}/install.sh" -o /usr/local/bin/newszxcn-email.new curl -fsSL "${RAW_BASE}/install.sh" -o "${installer_new}"
chmod 0755 /usr/local/bin/newszxcn-email.new
mv /usr/local/bin/newszxcn-email.new /usr/local/bin/newszxcn-email
fi 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() { random_secret() {
@@ -165,7 +233,7 @@ prompt_choice() {
} }
prompt_menu_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 if [[ -z "${value}" ]] && ! has_tty; then
fail "非交互环境请直接使用 install、update、status 等子命令。" fail "非交互环境请直接使用 install、update、status 等子命令。"
fi fi
@@ -174,13 +242,13 @@ prompt_menu_choice() {
read -r -p "请选择 [${default_value}]: " value </dev/tty read -r -p "请选择 [${default_value}]: " value </dev/tty
fi fi
value="${value:-${default_value}}" value="${value:-${default_value}}"
if [[ "${value}" =~ ^[0-9]$ ]]; then if [[ "${value}" =~ ^[0-9]+$ ]] && (( value >= 0 && value <= max_value )); then
printf '%s' "${value}" printf '%s' "${value}"
return return
fi fi
prompt_text "[提示] 请输入 0 至 9。\n" prompt_text "[提示] 请输入 0 至 ${max_value}。\n"
value="" value=""
has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 9。" has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 ${max_value}"
done done
} }
@@ -258,8 +326,8 @@ configure_first_install() {
fi fi
local firewall_mode hostname admin_username admin_password web_mode public_url update_token local firewall_mode hostname admin_username admin_password web_mode public_url update_token
prompt_text '\n防火墙配置 [1]\n1. 仅开放邮局必要端口(推荐)\n2. 保留现有防火墙,由用户自行配置\n3. 开放全部端口(不推荐)\n' prompt_text '\n防火墙配置 [1]\n1. 自动添加邮局必要端口规则(推荐)\n2. 保留现有防火墙,由用户自行配置\n'
firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1")" firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1" "2")"
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")" hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。" valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。"
@@ -343,7 +411,7 @@ configure_restricted_firewall() {
firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null
done done
firewall-cmd --reload >/dev/null firewall-cmd --reload >/dev/null
success "firewalld 已仅开放 SSH 和邮局必要端口。" success "firewalld 已添加 SSH 和邮局必要端口规则。"
return return
fi fi
@@ -355,36 +423,17 @@ configure_restricted_firewall() {
ufw allow "${ssh_port}/tcp" >/dev/null ufw allow "${ssh_port}/tcp" >/dev/null
done done
ufw --force enable >/dev/null ufw --force enable >/dev/null
success "UFW 已开放 SSH 和邮局必要端口。" success "UFW 已添加 SSH 和邮局必要端口规则。"
return return
fi fi
fail "没有找到可管理的 UFW 或 firewalld。" 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() { configure_firewall() {
case "$(env_value LANQIN_INSTALL_FIREWALL_MODE || true)" in case "$(env_value LANQIN_INSTALL_FIREWALL_MODE || true)" in
1) configure_restricted_firewall ;; 1) configure_restricted_firewall ;;
2) warn "已保留现有防火墙,请自行开放 SSH、25、80、443、465、587、993、995/TCP。" ;; 2) warn "已保留现有防火墙,请自行开放 SSH、25、80、443、465、587、993、995/TCP。" ;;
3) configure_open_firewall ;; 3) warn "检测到旧版开放全部端口配置。为避免破坏现有安全规则,本次不再修改防火墙。" ;;
"") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;; "") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;;
*) fail "防火墙模式配置无效。" ;; *) fail "防火墙模式配置无效。" ;;
esac esac
@@ -415,6 +464,16 @@ ensure_nginx() {
fi 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() { write_nginx_http_config() {
local hostname tmp local hostname tmp
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)" hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
@@ -505,6 +564,7 @@ EOF
} }
ensure_acme() { ensure_acme() {
ensure_cron_scheduler
if [[ ! -x /root/.acme.sh/acme.sh ]]; then if [[ ! -x /root/.acme.sh/acme.sh ]]; then
local hostname local hostname
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)" hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
@@ -512,6 +572,33 @@ ensure_acme() {
curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}" curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}"
fi fi
[[ -x /root/.acme.sh/acme.sh ]] || fail "acme.sh 安装失败。" [[ -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() { install_certificate() {
@@ -531,7 +618,7 @@ install_certificate() {
--domain "${hostname}" \ --domain "${hostname}" \
--fullchain-file "${CERT_DIR}/fullchain.pem" \ --fullchain-file "${CERT_DIR}/fullchain.pem" \
--key-file "${CERT_DIR}/privkey.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 0644 "${CERT_DIR}/fullchain.pem"
chmod 0600 "${CERT_DIR}/privkey.pem" chmod 0600 "${CERT_DIR}/privkey.pem"
set_env LANQIN_TLS_CERT_FILE "/certs/fullchain.pem" set_env LANQIN_TLS_CERT_FILE "/certs/fullchain.pem"
@@ -564,50 +651,204 @@ configure_web_mode() {
esac esac
} }
backup_database() { current_image_id() {
local timestamp local container_id image_ref
timestamp="$(date -u +%Y%m%dT%H%M%SZ)" container_id="$(compose ps -aq lanqin-email 2>/dev/null | head -n 1 || true)"
if [[ -n "$(compose ps -q lanqin-email 2>/dev/null || true)" ]]; then if [[ -n "${container_id}" ]]; 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 docker inspect --format '{{.Image}}' "${container_id}"
log "数据库已备份到 data/backups/cli-update-${timestamp}.db" return
fi 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() { sqlite_integrity_check() {
local container_id image_id rollback_tag local database="$1" image="$2" relative result
container_id="$(compose ps -q lanqin-email 2>/dev/null || true)" if command -v sqlite3 >/dev/null 2>&1; then
[[ -n "${container_id}" ]] || return 0 result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
image_id="$(docker inspect --format '{{.Image}}' "${container_id}")" else
rollback_tag="newszxcn-email:rollback-$(date -u +%Y%m%d%H%M%S)" relative="${database#"${INSTALL_DIR}"/data/}"
docker image tag "${image_id}" "${rollback_tag}" [[ "${relative}" != "${database}" ]] || return 1
printf '%s\n' "${rollback_tag}" > "${ROLLBACK_FILE}" 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" <<EOF
{
"image": "${image}",
"database_backup": "${snapshot}/database.db",
"compose_backup": "${snapshot}/docker-compose.yml",
"installer_backup": "${snapshot}/newszxcn-email",
"version": "${version:-unknown}",
"created_at": "${timestamp}"
}
EOF
find "${snapshot}" -maxdepth 1 -type f -exec chmod 0600 {} +
pointer_tmp="${ROLLBACK_POINTER}.new"
printf '%s\n' "${snapshot}" > "${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() { do_repair_install() {
refresh_assets
ensure_update_token
configure_runtime_bindings
ensure_docker ensure_docker
backup_database create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
remember_current_image stage_assets
configure_firewall clear_runtime_image_pin
prepare_directories 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 服务..." log "正在拉取并修复 NewSzxcn Email 服务..."
compose pull if ! compose pull; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
fi
log "正在启动服务..." log "正在启动服务..."
if ! compose up -d --remove-orphans; then if ! compose up -d --remove-orphans; then
warn "修复后容器启动失败,正在自动回滚。" warn "修复后容器启动失败,正在自动回滚。"
do_rollback restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已回滚到原镜像。" fail "修复失败,已恢复到修复前版本。"
fi fi
if ! wait_for_health 90; then if ! wait_for_health 90; then
warn "修复后健康检查失败,正在自动回滚。" warn "修复后健康检查失败,正在自动回滚。"
do_rollback restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已回滚到原镜像。" fail "修复失败,已恢复到修复前版本。"
fi 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)" success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。" warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
} }
do_install() { do_install() {
@@ -629,54 +870,65 @@ do_install() {
compose up -d --remove-orphans compose up -d --remove-orphans
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。" wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
configure_web_mode configure_web_mode
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)" success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。" warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
} }
do_update() { do_update() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。" [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。"
ensure_docker ensure_docker
refresh_assets create_update_snapshot || fail "更新前备份失败,未修改现有安装。"
ensure_update_token stage_assets
backup_database clear_runtime_image_pin
remember_current_image if ! apply_staged_assets || ! ensure_update_token; then
restore_update_snapshot "" false || true
fail "更新文件替换失败,已恢复原安装。"
fi
log "正在拉取最新版..." log "正在拉取最新版..."
compose pull if ! compose pull; then
restore_update_snapshot "" false || true
fail "镜像拉取失败,已恢复到更新前版本。"
fi
if ! compose up -d --remove-orphans; then if ! compose up -d --remove-orphans; then
warn "新版本容器启动失败,正在自动回滚。" warn "新版本容器启动失败,正在自动回滚。"
do_rollback restore_update_snapshot || fail "更新失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "更新失败,已回滚到原镜像。" fail "更新失败,已恢复到更新前版本。"
fi fi
if ! wait_for_health 90; then if ! wait_for_health 90; then
warn "新版本健康检查失败,正在自动回滚。" warn "新版本健康检查失败,正在自动回滚。"
do_rollback restore_update_snapshot || fail "更新失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "更新失败,已回滚到原镜像。" fail "更新失败,已恢复到更新前版本。"
fi fi
ensure_cli_alias
generate_guide >/dev/null || warn "更新成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "系统已更新,配置、邮件、证书和数据库均已保留。" success "系统已更新,配置、邮件、证书和数据库均已保留。"
} }
do_rollback() { do_rollback() {
[[ -f "${ROLLBACK_FILE}" ]] || fail "没有可用的回滚镜像。" [[ -f "${ROLLBACK_POINTER}" ]] || fail "没有可用的完整回滚快照。"
local image local confirm="${LANQIN_ROLLBACK_CONFIRM:-}" image timestamp emergency_backup
image="$(tr -d '\r\n' < "${ROLLBACK_FILE}")" if [[ -z "${confirm}" ]] && has_tty; then
docker image inspect "${image}" >/dev/null 2>&1 || fail "回滚镜像已不存在:${image}" read -r -p "回滚会用更新前数据库覆盖当前数据库,确认继续吗?[y/N]: " confirm </dev/tty
log "正在回滚到 ${image}..." fi
LANQIN_IMAGE="${image}" compose up -d --no-deps --force-recreate lanqin-email [[ "${confirm}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]] || { success "已取消回滚。"; return 0; }
wait_for_health 90 || fail "回滚后服务仍未通过健康检查,请查看日志。" image="$(current_image_id || true)"
success "已回滚到 ${image}" [[ -n "${image}" ]] || fail "无法确定当前镜像,已取消回滚。"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
emergency_backup="${INSTALL_DIR}/data/backups/pre-rollback-${timestamp}.db"
backup_database "${emergency_backup}" "${image}" || fail "当前数据库备份失败,已取消回滚。"
restore_update_snapshot || fail "完整回滚失败,请检查回滚快照和服务日志。"
log "回滚前数据库已保留:${emergency_backup}"
success "已恢复镜像、数据库、Compose、环境配置、安装脚本和 Nginx 配置。"
} }
reload_services() { reload_services() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0 [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0
ensure_docker ensure_docker
compose restart lanqin-email >/dev/null compose restart lanqin-email >/dev/null
if command -v nginx >/dev/null 2>&1 && [[ -f "${NGINX_CONFIG}" ]]; then if [[ -f "${NGINX_CONFIG}" ]]; then
nginx -t >/dev/null reload_nginx || fail "Nginx 配置检查或重载失败。"
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
fi fi
} }
@@ -694,9 +946,100 @@ do_certificate() {
install_certificate install_certificate
write_nginx_https_config write_nginx_https_config
reload_services reload_services
generate_guide >/dev/null || warn "证书已应用,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "SSL 证书已安装并应用。" 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}" <<EOF
==================================================
NewSzxcn 邮箱指南
==================================================
【安装信息】
邮箱前台:${public_url}
管理后台:${admin_url}
管理员账号:${admin_username:-admin}
管理员密码:仅在安装完成时显示;修改后请使用新密码
SSL 证书:有效期至 ${certificate_expiry}
自动续期:${renewal_status}
预计续期:${next_renewal}
--------------------------------------------------
一、添加邮件域名并配置 DNS
--------------------------------------------------
1. 登录管理后台,进入「域名管理」。
2. 添加邮件域名并保存,然后点击该域名右侧的「DNS」。
3. 在域名服务商处添加系统列出的 MX、SPF、DKIM、DMARC 记录。
4. 返回管理后台点击「检测」,确认所有记录均已通过。
--------------------------------------------------
二、开启账号自助申请邮箱
--------------------------------------------------
1. 进入「管理后台 -> 系统设置 -> 邮件」。
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() { do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。" [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose ps compose ps
@@ -709,14 +1052,27 @@ do_status() {
do_uninstall() { do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。" [[ -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/tty
fi
[[ "${confirm}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]] || { success "已取消卸载。"; return 0; }
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)"
compose down --remove-orphans compose down --remove-orphans
if [[ -f "${NGINX_CONFIG}" ]]; then if [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}" rm -f "${NGINX_CONFIG}"
if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then reload_nginx || warn "Nginx 未能重载,请检查并手动重载。"
if command -v systemctl >/dev/null 2>&1; then fi
systemctl reload nginx 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/tty
fi
remove_renewal="${remove_renewal:-y}"
if [[ "${remove_renewal}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]]; then
if /root/.acme.sh/acme.sh --remove --domain "${hostname}" --ecc >/dev/null 2>&1; then
success "已停止 ${hostname} 的证书自动续期。"
else else
nginx -s reload warn "未能移除 ${hostname} 的续期记录,请使用 acme.sh 手动检查。"
fi fi
fi fi
fi fi
@@ -724,28 +1080,74 @@ do_uninstall() {
} }
do_backup_reinstall() { 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)" 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 [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "旧安装缺少 docker-compose.yml,请先选择修复现有安装。"
ensure_docker ensure_docker
compose down --remove-orphans 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 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 if [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}" rm -f "${NGINX_CONFIG}"
if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then if ! reload_nginx; then
if command -v systemctl >/dev/null 2>&1; then install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
systemctl reload nginx reload_nginx || true
else LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate \
nginx -s reload || fail "Nginx 配置已恢复,但旧容器启动失败,请检查 ${INSTALL_DIR}"
fi wait_for_health 90 || fail "Nginx 配置已恢复,但旧服务健康检查失败,请查看日志。"
fail "Nginx 重载失败,已恢复旧配置并取消重新安装。"
fi fi
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}" success "旧安装已完整备份到 ${backup_dir}"
log "现在开始全新安装。" 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() { do_menu() {
@@ -766,19 +1168,20 @@ do_menu() {
prompt_text ' 状态:未安装\n' prompt_text ' 状态:未安装\n'
fi fi
prompt_text '--------------------------------------------------\n' prompt_text '--------------------------------------------------\n'
prompt_text ' 1. 安装 / 重新安装(旧数据自动备份\n' prompt_text ' 1. 安装 / 重新安装(完整备份,失败自动恢复\n'
prompt_text ' 2. 更新系统(数据库自动备份)\n' prompt_text ' 2. 更新系统(数据库备份,失败自动回滚\n'
prompt_text ' 3. 修复现有安装\n' prompt_text ' 3. 检查并修复现有安装\n'
prompt_text ' 4. 查看运行状态\n' prompt_text ' 4. 查看运行状态\n'
prompt_text ' 5. 重启服务\n' prompt_text ' 5. 重启服务\n'
prompt_text ' 6. 查看实时日志\n' prompt_text ' 6. 查看实时日志\n'
prompt_text ' 7. 申请或续期 SSL 证书\n' prompt_text ' 7. 申请、检查或续期 SSL 证书\n'
prompt_text ' 8. 回滚上个命令行版本\n' prompt_text ' 8. 回滚到上次更新前版本\n'
prompt_text ' 9. 卸载服务(保留数据)\n' prompt_text ' 9. NewSzxcn 邮箱指南\n'
prompt_text ' 10. 卸载服务(保留数据)\n'
prompt_text ' 0. 退出\n' prompt_text ' 0. 退出\n'
prompt_text '==================================================\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 if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
fail "尚未安装,请先选择 1。" fail "尚未安装,请先选择 1。"
fi fi
@@ -793,7 +1196,8 @@ do_menu() {
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;; 6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
7) do_certificate ;; 7) do_certificate ;;
8) ensure_docker; do_rollback ;; 8) ensure_docker; do_rollback ;;
9) ensure_docker; do_uninstall ;; 9) do_guide ;;
10) ensure_docker; do_uninstall ;;
esac esac
} }
@@ -804,6 +1208,10 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
exit 0 exit 0
fi fi
if [[ "${EUID}" -eq 0 ]]; then
ensure_cli_alias
fi
case "${COMMAND}" in case "${COMMAND}" in
help|-h|--help) usage ;; help|-h|--help) usage ;;
menu) require_root; require_curl; do_menu ;; menu) require_root; require_curl; do_menu ;;
@@ -815,6 +1223,7 @@ case "${COMMAND}" in
reload) require_root; require_curl; reload_services ;; reload) require_root; require_curl; reload_services ;;
certificate) require_root; require_curl; do_certificate ;; certificate) require_root; require_curl; do_certificate ;;
rollback) require_root; require_curl; ensure_docker; do_rollback ;; 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 ;; uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;; *) usage; fail "未知命令:${COMMAND}" ;;
esac esac
+314 -11
View File
@@ -114,38 +114,341 @@ test_menu_choice() {
assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action" assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action"
export LANQIN_MENU_ACTION=1 export LANQIN_MENU_ACTION=1
assert_eq "1" "$(prompt_menu_choice 2)" "menu install action" assert_eq "1" "$(prompt_menu_choice 2)" "menu install action"
export LANQIN_MENU_ACTION=9 export LANQIN_MENU_ACTION=10
assert_eq "9" "$(prompt_menu_choice 1)" "menu uninstall action" 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 unset LANQIN_MENU_ACTION
} }
test_backup_reinstall_preserves_existing_directory() ( test_offline_database_backup() (
local temp_dir backup_dir 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)" temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/newszxcn-email" INSTALL_DIR="${temp_dir}/newszxcn-email"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf" NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
mkdir -p "${INSTALL_DIR}" mkdir -p "${INSTALL_DIR}"
printf 'existing-data\n' > "${INSTALL_DIR}/marker" 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() { 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 if (do_backup_reinstall); then
backup_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.backup-*' -print -quit)" fail_test "failed reinstall unexpectedly succeeded"
[[ -n "${backup_dir}" ]] || fail_test "existing install backup directory missing" fi
grep -Fq 'existing-data' "${backup_dir}/marker" || fail_test "existing install data was not preserved" 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_hostname_validation
test_password_validation test_password_validation
test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false" 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 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_nginx_configuration
test_compose_configuration test_compose_configuration
test_legacy_configuration_is_preserved test_legacy_configuration_is_preserved
test_menu_choice 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' printf 'install.sh tests passed\n'