PayrollManagementSystem/scripts/windows/TaskHost.Common.ps1

120 lines
7.4 KiB
PowerShell
Raw Normal View History

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Assert-TaskValue([string]$Value) {
if ([string]::IsNullOrWhiteSpace($Value) -or $Value.IndexOfAny([char[]]@('"', "`r", "`n")) -ge 0) {
throw '任务参数不能为空或包含双引号、换行。'
}
}
function Quote-TaskArgument([string]$Value) {
Assert-TaskValue $Value
# Windows路径不能包含双引号;尾反斜线加倍,避免转义原生命令行的闭引号。
return '"' + ($Value -replace '(\\+)$', '$1$1') + '"'
}
function Get-PayrollTaskPlan {
param([string]$ProjectRoot, [string]$PythonPath, [string]$RuntimeDirectory,
[string]$ConnectionFile, [string]$TaskPath, [int]$ApiPort = 8000)
foreach ($value in @($ProjectRoot, $PythonPath, $RuntimeDirectory, $ConnectionFile)) { Assert-TaskValue $value }
if ($TaskPath -notmatch '^\\PayrollManagementSystem(?:-Test-[0-9a-f]{8})?\\$') { throw '任务目录必须为项目专用目录。' }
if ($ApiPort -lt 1024 -or $ApiPort -gt 65535) { throw '应用端口必须在1024到65535之间。' }
$root = (Resolve-Path -LiteralPath $ProjectRoot).Path
$python = (Resolve-Path -LiteralPath $PythonPath).Path
$connection = (Resolve-Path -LiteralPath $ConnectionFile).Path
if (-not (Test-Path -LiteralPath (Join-Path $root 'backend/app/cli.py') -PathType Leaf)) { throw '项目根目录缺少后端CLI。' }
if ((Get-Item -LiteralPath $connection).Length -gt 16384) { throw '只读连接文件异常。' }
$runtime = [IO.Path]::GetFullPath($RuntimeDirectory)
$launcher = Join-Path $PSScriptRoot 'Run-PayrollHost.ps1'
$shell = Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe'
$tasks = foreach ($role in @('backend', 'worker', 'maintenance')) {
$arguments = '-NoProfile -NonInteractive -File ' + (Quote-TaskArgument $launcher) +
' -Role ' + $role + ' -ProjectRoot ' + (Quote-TaskArgument $root) +
' -PythonPath ' + (Quote-TaskArgument $python) + ' -RuntimeDirectory ' + (Quote-TaskArgument $runtime) +
' -ConnectionFile ' + (Quote-TaskArgument $connection) + ' -ApiPort ' + $ApiPort
[pscustomobject]@{ Name = 'Payroll-' + $role; Role = $role; Execute = $shell;
Arguments = $arguments; WorkingDirectory = (Join-Path $root 'backend'); Trigger = $(if ($role -eq 'maintenance') { 'StartupAndEveryMinute' } else { 'Startup' }) }
}
return [pscustomobject]@{ Version = 1; ProjectRoot = $root; PythonPath = $python; RuntimeDirectory = $runtime;
ConnectionFile = $connection; TaskPath = $TaskPath; StopFile = (Join-Path $runtime 'stop.requested');
ApiPort = $ApiPort; Tasks = @($tasks); EnabledOnInstall = $false;
MultipleInstances = 'IgnoreNew'; RestartCount = 3; RestartInterval = 'PT1M'; ExecutionTimeLimit = 'PT0S' }
}
function Get-PayrollTaskSettings {
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable
$settings.Enabled = $false
return $settings
}
function Resolve-PayrollAccountSid([string]$Account) {
if ($Account -match '^S-1-\d+(?:-\d+)+$') { return ([Security.Principal.SecurityIdentifier]$Account).Value }
return ([Security.Principal.NTAccount]$Account).Translate([Security.Principal.SecurityIdentifier]).Value
}
function Assert-PayrollAccessRules {
param($Rules, [string[]]$TrustedSids, [bool]$PrivateFile = $false, [switch]$Ancestor, [string]$Label)
$writeMask = [Security.AccessControl.FileSystemRights]::Write -bor
[Security.AccessControl.FileSystemRights]::Delete -bor
[Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[Security.AccessControl.FileSystemRights]::TakeOwnership
if ($Ancestor) {
# 上层目录允许创建其他名称;已有项目可被删除/替换或改ACL才构成越界。
$writeMask = [Security.AccessControl.FileSystemRights]::Delete -bor
[Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[Security.AccessControl.FileSystemRights]::TakeOwnership
}
foreach ($rule in $Rules) {
if ($rule.AccessControlType -ne 'Allow' -or
($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly)) { continue }
$sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value
if ($TrustedSids -contains $sid) { continue }
if (($rule.FileSystemRights -band $writeMask) -or
($PrivateFile -and ($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::ReadData))) {
throw ('权限预检查不通过:' + $Label + '。存在非服务账号/管理员主体的写权限或连接文件读取权限,请管理员按部署说明核对ACL。')
}
}
}
function Assert-PayrollHostAccess {
param($Plan, [string]$ServiceSid)
$trusted = @('S-1-5-18', 'S-1-5-32-544', $ServiceSid)
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
# 独立的管理员账号可以部署;普通安装者不因运行本脚本自动成为可信主体。
if (@($identity.Groups | Where-Object { $_.Value -eq 'S-1-5-32-544' }).Count) { $trusted += $identity.User.Value }
$targets = @($Plan.ProjectRoot, (Join-Path $Plan.ProjectRoot 'backend'),
(Join-Path $Plan.ProjectRoot 'backend/app'), (Join-Path $Plan.ProjectRoot 'backend/app/cli.py'),
$PSScriptRoot, (Join-Path $PSScriptRoot 'Run-PayrollHost.ps1'),
(Join-Path $PSScriptRoot 'TaskHost.Common.ps1'), $Plan.PythonPath,
(Split-Path -Parent $Plan.PythonPath), $Plan.RuntimeDirectory, $Plan.ConnectionFile)
$environmentFile = Join-Path $Plan.ProjectRoot 'backend/.env'
if (Test-Path -LiteralPath $environmentFile) { $targets += $environmentFile }
foreach ($target in @($targets | Select-Object -Unique)) {
if (-not (Test-Path -LiteralPath $target)) { throw ('权限预检查要求目录已由管理员准备:' + $target) }
$acl = Get-Acl -LiteralPath $target
Assert-PayrollAccessRules -Rules $acl.Access -TrustedSids $trusted `
-PrivateFile ($target -eq $Plan.ConnectionFile -or $target -eq $environmentFile) -Label $target
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
if ($trusted -notcontains $ownerSid) { throw ('权限预检查不通过,所有者不是可信部署主体:' + $target) }
# 防止通过父目录的替换权限绕过文件本身ACL。逐级检查至磁盘根。
$parent = Split-Path -Parent $target
while ($parent) {
Assert-PayrollAccessRules -Rules (Get-Acl -LiteralPath $parent).Access -TrustedSids $trusted -Ancestor -Label $parent
$next = Split-Path -Parent $parent
if ($next -eq $parent) { break }
$parent = $next
}
}
}
function Assert-PayrollTaskPrincipal {
param($Actual, $Expected)
if ((Resolve-PayrollAccountSid $Actual.UserId) -ne $Expected.ServiceSid -or
[string]$Actual.LogonType -ne $Expected.LogonType -or [string]$Actual.RunLevel -ne 'Limited') {
throw '任务运行账号或登录/提权方式与安装清单不一致,拒绝管理。'
}
}