From 9eabdd204963289069ee2326e76f6ee6b6387c7b Mon Sep 17 00:00:00 2001 From: smail Date: Thu, 1 Oct 2026 11:48:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=9C=80=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E7=9A=84=E5=85=A8=E6=96=B0=E9=83=A8=E7=BD=B2=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认升级保留 Smail 运行数据;Linux 和 Windows 的全新部署均要求输入确认词,先备份后仅清理 Smail 专属目录,并补充部署策略回归用例。 --- .../cmd/smail-admin/systemd_policy_test.go | 23 ++++ .../windows_deployment_policy_test.go | 23 ++++ deploy/install-systemd.sh | 105 ++++++++++++++---- deploy/install-windows.ps1 | 56 +++++++--- 4 files changed, 168 insertions(+), 39 deletions(-) diff --git a/backend/cmd/smail-admin/systemd_policy_test.go b/backend/cmd/smail-admin/systemd_policy_test.go index a0c738d..73e0200 100644 --- a/backend/cmd/smail-admin/systemd_policy_test.go +++ b/backend/cmd/smail-admin/systemd_policy_test.go @@ -96,6 +96,29 @@ func TestInstallerManagesProtectedEnvironmentFile(t *testing.T) { } } +func TestInstallerRequiresExplicitFreshReset(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..", "..")) + installer, err := os.ReadFile(filepath.Join(root, "deploy", "install-systemd.sh")) + if err != nil { + t.Fatal(err) + } + content := string(installer) + for _, required := range []string{ + "--fresh", + "确认全新部署 Smail", + "cp -a -- \"$runtime_dir/.\" \"$backup_dir/runtime/\"", + "find \"$runtime_dir\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +", + "systemctl disable smail smail-admin", + } { + if !strings.Contains(content, required) { + t.Fatalf("installer is missing explicit fresh-reset behavior %q", required) + } + } + if strings.Contains(content, "全新部署已停止:$target 非空") { + t.Fatal("installer must not reject existing data without an explicit fresh-reset request") + } +} + func TestSmailServiceRetainsPluginIsolationCapabilities(t *testing.T) { root := filepath.Clean(filepath.Join("..", "..", "..")) unit, err := os.ReadFile(filepath.Join(root, "deploy", "systemd", "smail.service")) diff --git a/backend/cmd/smail-admin/windows_deployment_policy_test.go b/backend/cmd/smail-admin/windows_deployment_policy_test.go index 485598d..e087909 100644 --- a/backend/cmd/smail-admin/windows_deployment_policy_test.go +++ b/backend/cmd/smail-admin/windows_deployment_policy_test.go @@ -50,6 +50,29 @@ func TestWindowsDeploymentScriptsKeepAdminChannelManual(t *testing.T) { } } +func TestWindowsInstallerRequiresExplicitFreshReset(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..", "..")) + installer, err := os.ReadFile(filepath.Join(root, "deploy", "install-windows.ps1")) + if err != nil { + t.Fatal(err) + } + content := string(installer) + for _, required := range []string{ + "[switch]$Fresh", + "确认全新部署 Smail", + ".smail-fresh-backup-", + "Copy-Item -LiteralPath $item.FullName -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force", + "Remove-Item -LiteralPath $item.FullName -Recurse -Force", + } { + if !strings.Contains(content, required) { + t.Fatalf("Windows installer is missing explicit fresh-reset behavior %q", required) + } + } + if strings.Contains(content, "全新部署已停止:$existing 不是空目录") { + t.Fatal("Windows installer must not reject existing data without an explicit fresh-reset request") + } +} + func hasUTF8BOM(data []byte) bool { return len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF } diff --git a/deploy/install-systemd.sh b/deploy/install-systemd.sh index bf776f8..86c276f 100644 --- a/deploy/install-systemd.sh +++ b/deploy/install-systemd.sh @@ -1,13 +1,53 @@ #!/usr/bin/env bash set -eu +usage() { + echo "用法:$0 [--fresh] [发布目录]" + echo "默认模式保留配置、数据库、证书、日志和插件;--fresh 模式必须输入确认词后才会备份并清理 Smail 专属运行数据。" +} + +fresh=false +root_argument="" +while [ "$#" -gt 0 ]; do + case "$1" in + --fresh) + fresh=true + ;; + --help|-h) + usage + exit 0 + ;; + -* ) + echo "未知参数:$1" >&2 + usage >&2 + exit 1 + ;; + *) + if [ -n "$root_argument" ]; then + echo "发布目录只能指定一次。" >&2 + exit 1 + fi + root_argument="$1" + ;; + esac + shift +done + if [ "$(id -u)" -ne 0 ]; then echo "必须使用 root 用户安装 Smail" >&2 exit 1 fi -root_dir="$(realpath -e -- "${1:-$(dirname "$0")/..}")" +root_dir="$(realpath -e -- "${root_argument:-$(dirname "$0")/..}")" runtime_dir=/opt/smail +fresh_confirmation="确认全新部署 Smail" +if [ "$fresh" = true ]; then + printf '这是不可逆的 Smail 专属数据清理操作。请输入“%s”继续: ' "$fresh_confirmation" + if ! IFS= read -r confirmation || [ "$confirmation" != "$fresh_confirmation" ]; then + echo "未获得有效确认,已取消全新部署。" >&2 + exit 1 + fi +fi case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; @@ -66,31 +106,48 @@ if [ -L /run/smail/admin-control.sock ] || { [ -e /run/smail/admin-control.sock exit 1 fi -# 全新安装不继承目标目录中的配置、证书或业务数据,也不自动清理它们。 -for directory in config data ssl log plugins; do - target="$runtime_dir/$directory" - if [ -d "$target" ] && [ -n "$(find "$target" -mindepth 1 -maxdepth 1 -print -quit)" ]; then - echo "全新部署已停止:$target 非空。请先确认并处理旧数据,再通过初始化页面重新配置。" >&2 - exit 1 - fi -done -for file in config.json config.dev.json smail.db smail-state.db; do - if [ -e "$runtime_dir/$file" ] || [ -L "$runtime_dir/$file" ]; then - echo "全新部署已停止:发现历史运行文件 $file,不会自动继承或删除。" >&2 - exit 1 - fi -done -if [ -e /etc/smail/smail.env ]; then - echo "全新部署已停止:请先确认并处理 /etc/smail/smail.env 中的旧部署环境配置。" >&2 - exit 1 -fi -if [ -d /etc/systemd/system/smail.service.d ] && [ -n "$(find /etc/systemd/system/smail.service.d -mindepth 1 -maxdepth 1 -print -quit)" ]; then - echo "全新部署已停止:请先确认并处理 Smail 的旧服务覆盖配置。" >&2 - exit 1 -fi - # 安装备份存放在系统备份目录,不在部署根目录遗留 .bak 文件。 backup_dir="$(mktemp -d /var/backups/smail-deploy-XXXXXXXX)" +chmod 0700 "$backup_dir" +if [ "$fresh" = true ]; then + if [ -d "$runtime_dir" ] && [ -n "$(find "$runtime_dir" -mindepth 1 -maxdepth 1 -type l -print -quit)" ]; then + echo "全新部署已停止:/opt/smail 顶层存在符号链接,拒绝清理。" >&2 + exit 1 + fi + install -d -m 0700 "$backup_dir/runtime" + if [ -d "$runtime_dir" ]; then + cp -a -- "$runtime_dir/." "$backup_dir/runtime/" + fi + if [ -d /etc/smail ]; then + cp -a -- /etc/smail "$backup_dir/etc-smail" + fi + if [ -d /etc/systemd/system/smail.service.d ]; then + cp -a -- /etc/systemd/system/smail.service.d "$backup_dir/smail.service.d" + fi + for name in smail.service smail-admin.service; do + if [ -f "/etc/systemd/system/$name" ]; then + cp -p -- "/etc/systemd/system/$name" "$backup_dir/$name" + fi + done + if [ -e /etc/smail/smail.env ]; then + cp -p -- /etc/smail/smail.env "$backup_dir/smail.env" + fi + if [ -d "$runtime_dir" ]; then + find "$runtime_dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + fi + if [ -d /etc/smail ]; then + find /etc/smail -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + fi + if [ -d /etc/systemd/system/smail.service.d ]; then + rm -rf -- /etc/systemd/system/smail.service.d + fi + if [ -d /run/smail ]; then + find /run/smail -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + fi + systemctl disable smail smail-admin >/dev/null 2>&1 || true + systemctl daemon-reload + echo "已完成 Smail 专属运行数据清理;完整备份保存在 $backup_dir。" +fi for name in smail smail-admin; do if [ -f "$runtime_dir/$name" ]; then cp -p -- "$runtime_dir/$name" "$backup_dir/$name"; fi done diff --git a/deploy/install-windows.ps1 b/deploy/install-windows.ps1 index 43b61c6..18870f4 100644 --- a/deploy/install-windows.ps1 +++ b/deploy/install-windows.ps1 @@ -2,6 +2,7 @@ param( [string]$PackageRoot = $null, [string]$InstallRoot = (Join-Path $env:ProgramData "Smail"), + [switch]$Fresh, [switch]$StartService ) @@ -99,6 +100,16 @@ function Stop-SmailService { } Assert-Administrator +if ($Fresh -and $StartService) { + throw "全新部署必须先完成初始化配置,不能在安装时启动服务" +} +if ($Fresh) { + $freshConfirmation = "确认全新部署 Smail" + $confirmation = Read-Host "这是不可逆的 Smail 专属数据清理操作。请输入“$freshConfirmation”继续" + if ($confirmation -cne $freshConfirmation) { + throw "未获得有效确认,已取消全新部署" + } +} $mainSource = Resolve-PackageFile -Candidates @( (Join-Path $PackageRoot "out\backend\smail_windows_amd64.exe"), (Join-Path $PackageRoot "backend\smail_windows_amd64.exe"), @@ -121,27 +132,38 @@ if (Test-Path -LiteralPath $resolvedInstallRoot) { throw "安装目录不能是文件或链接" } } -foreach ($name in @('config', 'data', 'ssl', 'log', 'plugins')) { - $existing = Join-Path $resolvedInstallRoot $name - if (-not (Test-Path -LiteralPath $existing)) { continue } - $info = Get-Item -LiteralPath $existing -Force - if (-not $info.PSIsContainer -or $info.Attributes -band [IO.FileAttributes]::ReparsePoint -or - (Get-ChildItem -LiteralPath $existing -Force | Select-Object -First 1)) { - throw "全新部署已停止:$existing 不是空目录。请先确认并处理旧数据,再通过初始化页面重新配置。" - } -} -foreach ($name in @('config.json', 'config.dev.json', 'smail.db', 'smail-state.db')) { - if (Test-Path -LiteralPath (Join-Path $resolvedInstallRoot $name)) { - throw "全新部署已停止:发现历史运行文件 $name,不会自动继承或删除。" +if ($Fresh -and (Test-Path -LiteralPath $resolvedInstallRoot)) { + foreach ($item in Get-ChildItem -LiteralPath $resolvedInstallRoot -Force) { + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "全新部署已停止:安装目录顶层存在重解析点,拒绝清理:$($item.FullName)" + } } } New-Item -ItemType Directory -Path $resolvedInstallRoot -Force | Out-Null +if ($Fresh) { + Stop-SmailService + $backupRoot = Join-Path ([IO.Path]::GetDirectoryName($resolvedInstallRoot)) (".smail-fresh-backup-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null + if (Test-Path -LiteralPath $resolvedInstallRoot) { + foreach ($item in Get-ChildItem -LiteralPath $resolvedInstallRoot -Force) { + Copy-Item -LiteralPath $item.FullName -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force + } + foreach ($item in Get-ChildItem -LiteralPath $resolvedInstallRoot -Force) { + Remove-Item -LiteralPath $item.FullName -Recurse -Force + } + } + Write-Output "已完成 Smail 专属运行数据清理;完整备份保存在 $backupRoot。" +} else { + $backupRoot = Join-Path $resolvedInstallRoot (".backup-" + [Guid]::NewGuid().ToString("N")) +} New-Item -ItemType Directory -Path (Join-Path $resolvedInstallRoot "config") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $resolvedInstallRoot "data") -Force | Out-Null $transactionRoot = Join-Path $resolvedInstallRoot (".upgrade-" + [Guid]::NewGuid().ToString("N")) -$backupRoot = Join-Path $resolvedInstallRoot (".backup-" + [Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $transactionRoot -Force | Out-Null -New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null +if (-not $Fresh) { + $backupRoot = Join-Path $resolvedInstallRoot (".backup-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null +} $mainTarget = Join-Path $resolvedInstallRoot "smail.exe" $adminTarget = Join-Path $resolvedInstallRoot "smail-admin.exe" @@ -214,7 +236,11 @@ try { throw } -foreach ($temporaryRoot in @($transactionRoot, $backupRoot)) { +$temporaryRoots = @($transactionRoot) +if (-not $Fresh) { + $temporaryRoots += $backupRoot +} +foreach ($temporaryRoot in $temporaryRoots) { if (Test-Path -LiteralPath $temporaryRoot) { $resolvedTemporaryRoot = (Resolve-Path -LiteralPath $temporaryRoot).Path if ([IO.Path]::GetDirectoryName($resolvedTemporaryRoot) -ne $resolvedInstallRoot.TrimEnd('\')) {