120 lines
7.4 KiB
PowerShell
120 lines
7.4 KiB
PowerShell
|
|
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 '任务运行账号或登录/提权方式与安装清单不一致,拒绝管理。'
|
|||
|
|
}
|
|||
|
|
}
|