diff --git a/deploy/install-windows.ps1 b/deploy/install-windows.ps1 new file mode 100644 index 0000000..f9f217d --- /dev/null +++ b/deploy/install-windows.ps1 @@ -0,0 +1,193 @@ +[CmdletBinding()] +param( + [string]$PackageRoot = (Split-Path -Parent $PSScriptRoot), + [string]$InstallRoot = (Join-Path $env:ProgramData "Smail"), + [switch]$StartService +) + +$ErrorActionPreference = "Stop" + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "必须使用管理员权限运行 Windows 安装脚本" + } +} + +function Invoke-Native { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $false)][string[]]$Arguments = @() + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "命令执行失败:$FilePath,退出码 $LASTEXITCODE" + } +} + +function Resolve-PackageFile { + param( + [Parameter(Mandatory = $true)][string[]]$Candidates, + [Parameter(Mandatory = $true)][string]$Description + ) + + foreach ($candidate in $Candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Resolve-Path -LiteralPath $candidate).Path + } + } + throw "未找到 $Description,请先构建对应 Windows 发布文件" +} + +function Set-RestrictedAcl { + param([Parameter(Mandatory = $true)][string]$Root) + + $mainBinary = Join-Path $Root "smail.exe" + $adminBinary = Join-Path $Root "smail-admin.exe" + $configDirectory = Join-Path $Root "config" + Invoke-Native -FilePath "icacls.exe" -Arguments @( + $Root, + "/inheritance:r", + "/grant:r", + "SYSTEM:(OI)(CI)(F)", + "Administrators:(OI)(CI)(F)", + "NT SERVICE\Smail:(OI)(CI)(M)", + "/C" + ) + Invoke-Native -FilePath "icacls.exe" -Arguments @( + $mainBinary, + "/grant:r", + "NT SERVICE\Smail:(RX)", + "/deny", + "NT SERVICE\Smail:(WD,AD,DC,WA)", + "/C" + ) + Invoke-Native -FilePath "icacls.exe" -Arguments @( + $adminBinary, + "/grant:r", + "SYSTEM:(F)", + "Administrators:(F)", + "/deny", + "NT SERVICE\Smail:(F)", + "/C" + ) + if (Test-Path -LiteralPath $configDirectory -PathType Container) { + Invoke-Native -FilePath "icacls.exe" -Arguments @( + $configDirectory, + "/grant", + "NT SERVICE\Smail:(OI)(CI)(M)", + "/C" + ) + } +} + +function Stop-SmailService { + $service = Get-Service -Name "Smail" -ErrorAction SilentlyContinue + if ($null -eq $service) { + return + } + if ($service.Status -ne [ServiceProcess.ServiceControllerStatus]::Stopped) { + Stop-Service -Name "Smail" -Force + $service.WaitForStatus([ServiceProcess.ServiceControllerStatus]::Stopped, [TimeSpan]::FromSeconds(30)) + } +} + +Assert-Administrator +$mainSource = Resolve-PackageFile -Candidates @( + (Join-Path $PackageRoot "server\smail_windows_amd64.exe"), + (Join-Path $PackageRoot "server\smail.exe"), + (Join-Path $PackageRoot "smail_windows_amd64.exe"), + (Join-Path $PackageRoot "smail.exe") +) -Description "smail.exe" +$adminSource = Resolve-PackageFile -Candidates @( + (Join-Path $PackageRoot "server\smail-admin_windows_amd64.exe"), + (Join-Path $PackageRoot "server\smail-admin.exe"), + (Join-Path $PackageRoot "smail-admin_windows_amd64.exe"), + (Join-Path $PackageRoot "smail-admin.exe") +) -Description "smail-admin.exe" + +$resolvedInstallRoot = [IO.Path]::GetFullPath($InstallRoot) +New-Item -ItemType Directory -Path $resolvedInstallRoot -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $resolvedInstallRoot "config") -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 + +$mainTarget = Join-Path $resolvedInstallRoot "smail.exe" +$adminTarget = Join-Path $resolvedInstallRoot "smail-admin.exe" +$mainBackup = Join-Path $backupRoot "smail.exe" +$adminBackup = Join-Path $backupRoot "smail-admin.exe" +$serviceCreated = $false + +try { + Stop-SmailService + Copy-Item -LiteralPath $mainSource -Destination (Join-Path $transactionRoot "smail.exe") + Copy-Item -LiteralPath $adminSource -Destination (Join-Path $transactionRoot "smail-admin.exe") + if (Test-Path -LiteralPath $mainTarget) { Move-Item -LiteralPath $mainTarget -Destination $mainBackup } + if (Test-Path -LiteralPath $adminTarget) { Move-Item -LiteralPath $adminTarget -Destination $adminBackup } + Move-Item -LiteralPath (Join-Path $transactionRoot "smail.exe") -Destination $mainTarget + Move-Item -LiteralPath (Join-Path $transactionRoot "smail-admin.exe") -Destination $adminTarget + Set-RestrictedAcl -Root $resolvedInstallRoot + + $service = Get-Service -Name "Smail" -ErrorAction SilentlyContinue + if ($null -eq $service) { + $binaryPath = '"' + $mainTarget + '"' + Invoke-Native -FilePath "sc.exe" -Arguments @( + "create", "Smail", "binPath=", $binaryPath, + "start=", "auto", "obj=", "NT SERVICE\Smail", + "DisplayName=", "Smail 邮件服务" + ) + $serviceCreated = $true + } else { + Invoke-Native -FilePath "sc.exe" -Arguments @( + "config", "Smail", "binPath=", ('"' + $mainTarget + '"'), + "start=", "auto", "obj=", "NT SERVICE\Smail" + ) + } + $failureActions = "restart/60000/restart/60000/" + Invoke-Native -FilePath "sc.exe" -Arguments @( + "failure", "Smail", "reset=", "86400", "actions=", $failureActions + ) + Write-Output "Smail Windows 安装完成;超级管理员重置程序不会自动注册或启动服务。" + if ($StartService) { + Start-Service -Name "Smail" + Write-Output "Smail 主服务已启动。" + } + if (-not $StartService) { + Write-Output "主服务保持停止状态,请完成配置后再手动启动。" + } +} catch { + if (Test-Path -LiteralPath $mainTarget) { + Remove-Item -LiteralPath $mainTarget -Force + } + if (Test-Path -LiteralPath $adminTarget) { + Remove-Item -LiteralPath $adminTarget -Force + } + if (Test-Path -LiteralPath $mainBackup) { + Move-Item -LiteralPath $mainBackup -Destination $mainTarget -Force + } + if (Test-Path -LiteralPath $adminBackup) { + Move-Item -LiteralPath $adminBackup -Destination $adminTarget -Force + } + if ($serviceCreated) { + $deleteArguments = @("delete", "Smail") + Invoke-Native -FilePath "sc.exe" -Arguments $deleteArguments + } + if (Test-Path -LiteralPath $transactionRoot) { + Remove-Item -LiteralPath $transactionRoot -Recurse -Force + } + if (Test-Path -LiteralPath $backupRoot) { + Remove-Item -LiteralPath $backupRoot -Recurse -Force + } + throw +} + +if (Test-Path -LiteralPath $transactionRoot) { + Remove-Item -LiteralPath $transactionRoot -Recurse -Force +} +if (Test-Path -LiteralPath $backupRoot) { + Remove-Item -LiteralPath $backupRoot -Recurse -Force +} diff --git a/deploy/uninstall-windows.ps1 b/deploy/uninstall-windows.ps1 new file mode 100644 index 0000000..e91e9f4 --- /dev/null +++ b/deploy/uninstall-windows.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding()] +param( + [string]$InstallRoot = (Join-Path $env:ProgramData "Smail") +) + +$ErrorActionPreference = "Stop" + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "必须使用管理员权限运行 Windows 卸载脚本" + } +} + +Assert-Administrator +$resolvedInstallRoot = [IO.Path]::GetFullPath($InstallRoot) +$service = Get-Service -Name "Smail" -ErrorAction SilentlyContinue +if ($null -ne $service) { + if ($service.Status -ne [ServiceProcess.ServiceControllerStatus]::Stopped) { + Stop-Service -Name "Smail" -Force + $service.WaitForStatus([ServiceProcess.ServiceControllerStatus]::Stopped, [TimeSpan]::FromSeconds(30)) + } + & sc.exe delete Smail + if ($LASTEXITCODE -ne 0) { + throw "删除 Smail Windows 服务失败" + } +} + +foreach ($fileName in @("smail.exe", "smail-admin.exe")) { + $filePath = Join-Path $resolvedInstallRoot $fileName + if (Test-Path -LiteralPath $filePath -PathType Leaf) { + Remove-Item -LiteralPath $filePath -Force + } +} +Write-Output "Smail Windows 服务和程序已卸载;配置、数据库、证书和用户数据未删除。" diff --git a/server/cmd/smail-admin/windows_deployment_policy_test.go b/server/cmd/smail-admin/windows_deployment_policy_test.go new file mode 100644 index 0000000..b0e79ba --- /dev/null +++ b/server/cmd/smail-admin/windows_deployment_policy_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWindowsDeploymentScriptsKeepAdminChannelManual(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{ + "sc.exe", + "smail-admin.exe", + "Set-RestrictedAcl", + "NT SERVICE\\Smail:(F)", + ".backup-", + "$mainBackup", + "$adminBackup", + "不会自动注册或启动服务", + } { + if !strings.Contains(content, required) { + t.Fatalf("Windows installer is missing %q", required) + } + } + for _, forbidden := range []string{ + "create SmailAdmin", + "Start-Service -Name \"SmailAdmin\"", + "start= auto" + "\n" + "start= auto", + } { + if strings.Contains(content, forbidden) { + t.Fatalf("Windows installer contains forbidden admin-service action %q", forbidden) + } + } +} + +func TestWindowsUninstallerPreservesData(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..", "..")) + uninstaller, err := os.ReadFile(filepath.Join(root, "deploy", "uninstall-windows.ps1")) + if err != nil { + t.Fatal(err) + } + content := string(uninstaller) + if strings.Contains(content, "Remove-Item -LiteralPath $resolvedInstallRoot -Recurse") { + t.Fatal("Windows uninstaller recursively removes the data root") + } + for _, required := range []string{ + "smail.exe", + "smail-admin.exe", + "配置、数据库、证书和用户数据未删除", + } { + if !strings.Contains(content, required) { + t.Fatalf("Windows uninstaller is missing %q", required) + } + } +} diff --git a/server/cmd/smail/main.go b/server/cmd/smail/main.go index 3f228f5..c8e6df8 100644 --- a/server/cmd/smail/main.go +++ b/server/cmd/smail/main.go @@ -3,12 +3,10 @@ package main import ( "fmt" "os" - - "git.pcux.cn/xuans/Smail/internal/app" ) func main() { - if err := app.RecoverPanic(app.Run); err != nil { + if err := runMain(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/server/cmd/smail/run_nonwindows.go b/server/cmd/smail/run_nonwindows.go new file mode 100644 index 0000000..61dc271 --- /dev/null +++ b/server/cmd/smail/run_nonwindows.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +import "git.pcux.cn/xuans/Smail/internal/app" + +func runMain() error { + return app.RecoverPanic(app.Run) +} diff --git a/server/cmd/smail/run_windows.go b/server/cmd/smail/run_windows.go new file mode 100644 index 0000000..d0fc766 --- /dev/null +++ b/server/cmd/smail/run_windows.go @@ -0,0 +1,71 @@ +//go:build windows + +package main + +import ( + "time" + + "git.pcux.cn/xuans/Smail/internal/app" + "git.pcux.cn/xuans/Smail/signal" + "golang.org/x/sys/windows/svc" +) + +func runMain() error { + if !svc.IsWindowsService() { + return app.RecoverPanic(app.Run) + } + return svc.Run("Smail", &windowsService{}) +} + +type windowsService struct{} + +func (windowsService) Execute(_ []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + const accepts = svc.AcceptStop | svc.AcceptShutdown + status <- svc.Status{State: svc.StartPending} + + done := make(chan error, 1) + go func() { + done <- app.RecoverPanic(app.Run) + }() + status <- svc.Status{State: svc.Running, Accepts: accepts} + + for { + select { + case err := <-done: + if err != nil { + return false, 1 + } + status <- svc.Status{State: svc.StopPending} + return false, 0 + case request, ok := <-requests: + if !ok { + return false, 0 + } + switch request.Cmd { + case svc.Interrogate: + status <- svc.Status{State: svc.Running, Accepts: accepts} + case svc.Stop, svc.Shutdown: + return windowsServiceStop(done, status) + } + } + } +} + +func windowsServiceStop(done <-chan error, status chan<- svc.Status) (bool, uint32) { + status <- svc.Status{State: svc.StopPending, WaitHint: 30_000} + select { + case signal.StopChan <- true: + case <-time.After(2 * time.Second): + return false, 1 + } + + select { + case err := <-done: + if err != nil { + return false, 1 + } + return false, 0 + case <-time.After(30 * time.Second): + return false, 1 + } +}