This commit is contained in:
parent
a8f88af887
commit
fcf81cbcb9
193
deploy/install-windows.ps1
Normal file
193
deploy/install-windows.ps1
Normal file
@ -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
|
||||
}
|
||||
36
deploy/uninstall-windows.ps1
Normal file
36
deploy/uninstall-windows.ps1
Normal file
@ -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 服务和程序已卸载;配置、数据库、证书和用户数据未删除。"
|
||||
61
server/cmd/smail-admin/windows_deployment_policy_test.go
Normal file
61
server/cmd/smail-admin/windows_deployment_policy_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
9
server/cmd/smail/run_nonwindows.go
Normal file
9
server/cmd/smail/run_nonwindows.go
Normal file
@ -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)
|
||||
}
|
||||
71
server/cmd/smail/run_windows.go
Normal file
71
server/cmd/smail/run_windows.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user