Install-XiaoshuLegacySyncBridge.ps1 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. [CmdletBinding(SupportsShouldProcess = $true)]
  2. param(
  3. [Parameter(Mandatory = $true)]
  4. [string]$PublishPath,
  5. [Parameter(Mandatory = $true)]
  6. [string]$ConfigurationPath,
  7. [Parameter(Mandatory = $true)]
  8. [string]$SiteName,
  9. [ValidatePattern('^[A-Za-z0-9_-]+$')]
  10. [string]$ApplicationName = 'xiaoshu-sync',
  11. [string]$ApplicationPoolName = 'XiaoshuLegacySync',
  12. [string]$PhysicalPath = 'C:\inetpub\xiaoshu-sync',
  13. [Parameter(Mandatory = $true)]
  14. [string]$HealthUrl,
  15. [switch]$PreflightOnly
  16. )
  17. $ErrorActionPreference = 'Stop'
  18. Set-StrictMode -Version Latest
  19. function Assert-Administrator {
  20. $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
  21. $principal = [Security.Principal.WindowsPrincipal]::new($identity)
  22. if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
  23. throw '请使用“以管理员身份运行”的 PowerShell 执行部署。'
  24. }
  25. }
  26. function Assert-Configuration([string]$Path) {
  27. if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "配置文件不存在:$Path" }
  28. $raw = Get-Content -LiteralPath $Path -Raw
  29. $config = $raw | ConvertFrom-Json
  30. $connection = [string]$config.ConnectionStrings.LegacySqlServer
  31. $keyId = [string]$config.XiaoshuSync.KeyId
  32. $secret = [string]$config.XiaoshuSync.Secret
  33. $allowedIps = @($config.XiaoshuSync.AllowedIps)
  34. if ([string]::IsNullOrWhiteSpace($connection) -or $connection -like 'REPLACE_*') { throw 'LegacySqlServer 连接串尚未配置。' }
  35. if ([string]::IsNullOrWhiteSpace($keyId) -or $keyId -like 'REPLACE_*') { throw '同步桥 KeyId 尚未配置。' }
  36. if ($secret.Length -lt 32 -or $secret -like 'REPLACE_*') { throw '同步桥 Secret 必须是至少 32 位的随机字符串。' }
  37. if ($allowedIps.Count -eq 0 -or $allowedIps | Where-Object { [string]::IsNullOrWhiteSpace([string]$_) -or [string]$_ -like 'REPLACE_*' }) {
  38. throw '必须配置 Parse/云函数服务器的出口 IP 白名单。'
  39. }
  40. return $raw
  41. }
  42. Assert-Administrator
  43. Import-Module WebAdministration
  44. $resolvedPublishPath = (Resolve-Path -LiteralPath $PublishPath).Path
  45. if (-not (Test-Path -LiteralPath (Join-Path $resolvedPublishPath 'Xiaoshu.LegacySyncBridge.dll'))) { throw '发布目录缺少 Xiaoshu.LegacySyncBridge.dll。' }
  46. if (-not (Test-Path -LiteralPath (Join-Path $resolvedPublishPath 'web.config'))) { throw '发布目录缺少 web.config。' }
  47. $productionConfiguration = Assert-Configuration $ConfigurationPath
  48. $configuration = $productionConfiguration | ConvertFrom-Json
  49. $configuredPathBase = [string]$configuration.XiaoshuSync.PathBase
  50. if ($configuredPathBase -ne "/$ApplicationName") { throw '配置中的 PathBase 必须与 IIS 应用路径一致。' }
  51. if (-not (Get-WebGlobalModule -Name 'AspNetCoreModuleV2' -ErrorAction SilentlyContinue)) {
  52. throw '服务器尚未安装 IIS AspNetCoreModuleV2。同步桥为 Windows x64 自包含发布,不要求服务器安装 .NET 8 Runtime,但仍需要该 IIS 模块。'
  53. }
  54. if (-not (Test-Path "IIS:\Sites\$SiteName")) { throw "IIS 站点不存在:$SiteName" }
  55. $healthUri = [Uri]$HealthUrl
  56. if (-not $healthUri.IsAbsoluteUri -or $healthUri.Scheme -notin @('http', 'https') -or $healthUri.AbsolutePath -ne "/$ApplicationName/v1/health" -or $healthUri.Query -or $healthUri.Fragment -or $healthUri.UserInfo) {
  57. throw 'HealthUrl 必须是目标站点的完整 HTTP(S) 健康地址,路径为 /应用名/v1/health。'
  58. }
  59. $matchingBindings = @(Get-WebBinding -Name $SiteName | Where-Object {
  60. $parts = ([string]$_.bindingInformation) -split ':'
  61. $address = $null
  62. $isIpAddress = [Net.IPAddress]::TryParse($healthUri.DnsSafeHost, [ref]$address)
  63. $hostMatches = $parts[-1] -eq $healthUri.DnsSafeHost -or
  64. ($isIpAddress -and [string]::IsNullOrEmpty($parts[-1]) -and ($parts[0] -eq '*' -or $parts[0] -eq $healthUri.DnsSafeHost))
  65. $_.protocol -eq $healthUri.Scheme -and $parts[-2] -eq [string]$healthUri.Port -and
  66. $hostMatches
  67. })
  68. if ($matchingBindings.Count -eq 0) {
  69. throw "健康地址的域名、端口和协议没有绑定到 IIS 站点 '$SiteName'。请指定实际承载旧站域名的站点,不能默认安装到 Default Web Site。"
  70. }
  71. $rootPool = [string](Get-Item "IIS:\Sites\$SiteName").applicationPool
  72. if ($ApplicationPoolName -eq $rootPool) { throw '同步桥必须使用独立应用池,不能使用旧站根应用的应用池。' }
  73. Write-Host '部署前检查通过:'
  74. Write-Host " IIS 站点:$SiteName"
  75. Write-Host " 应用路径:/$ApplicationName"
  76. Write-Host " 应用池:$ApplicationPoolName"
  77. Write-Host " 物理目录:$PhysicalPath"
  78. Write-Host " 健康地址:$HealthUrl"
  79. Write-Host ' 配置:连接串、HMAC KeyId/Secret 与 IP 白名单均已通过格式检查(未输出敏感值)'
  80. if ($PreflightOnly) {
  81. Write-Host 'PreflightOnly 已启用,未创建目录、应用池或 IIS 应用,也未复制任何文件。'
  82. return
  83. }
  84. $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
  85. $backupPath = "$PhysicalPath.backup-$timestamp"
  86. $applicationPath = "/$ApplicationName"
  87. if ($PSCmdlet.ShouldProcess("$SiteName$applicationPath", '部署小树旧系统同步桥')) {
  88. if (Test-Path "IIS:\AppPools\$ApplicationPoolName") {
  89. Stop-WebAppPool -Name $ApplicationPoolName -ErrorAction SilentlyContinue
  90. }
  91. if (Test-Path -LiteralPath $PhysicalPath) {
  92. Copy-Item -LiteralPath $PhysicalPath -Destination $backupPath -Recurse -Force
  93. Write-Host "已备份现有版本:$backupPath"
  94. } else {
  95. New-Item -ItemType Directory -Path $PhysicalPath -Force | Out-Null
  96. }
  97. Copy-Item -Path (Join-Path $resolvedPublishPath '*') -Destination $PhysicalPath -Recurse -Force
  98. $productionConfiguration | Set-Content -LiteralPath (Join-Path $PhysicalPath 'appsettings.Production.json') -Encoding UTF8
  99. if (-not (Test-Path "IIS:\AppPools\$ApplicationPoolName")) {
  100. New-WebAppPool -Name $ApplicationPoolName | Out-Null
  101. }
  102. Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name managedRuntimeVersion -Value ''
  103. Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name enable32BitAppOnWin64 -Value $false
  104. Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name processModel.identityType -Value ApplicationPoolIdentity
  105. Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name startMode -Value AlwaysRunning
  106. $existingApplication = Get-WebApplication -Site $SiteName -Name $ApplicationName -ErrorAction SilentlyContinue
  107. if ($null -eq $existingApplication) {
  108. New-WebApplication -Site $SiteName -Name $ApplicationName -PhysicalPath $PhysicalPath -ApplicationPool $ApplicationPoolName | Out-Null
  109. } else {
  110. Set-ItemProperty "IIS:\Sites\$SiteName\$ApplicationName" -Name physicalPath -Value $PhysicalPath
  111. Set-ItemProperty "IIS:\Sites\$SiteName\$ApplicationName" -Name applicationPool -Value $ApplicationPoolName
  112. }
  113. & icacls.exe $PhysicalPath /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "IIS AppPool\${ApplicationPoolName}:(OI)(CI)RX" /T /C | Out-Null
  114. Start-WebAppPool -Name $ApplicationPoolName
  115. $healthy = $false
  116. foreach ($attempt in 1..12) {
  117. try {
  118. $health = Invoke-RestMethod -Uri $HealthUrl -Method Get -TimeoutSec 10
  119. if ($health.status -eq 'ok' -and $health.service -eq 'xiaoshu-legacy-sync' -and $health.version -eq 'v1') { $healthy = $true; break }
  120. } catch {
  121. Start-Sleep -Seconds 2
  122. }
  123. }
  124. if (-not $healthy) {
  125. throw "IIS 已部署,但健康检查没有通过:$HealthUrl。请检查事件日志与 IIS stdout 日志。"
  126. }
  127. Write-Host '同步桥部署完成,未输出任何连接串或签名密钥。'
  128. Write-Host "健康地址:$HealthUrl"
  129. Write-Host "回滚备份:$(if (Test-Path -LiteralPath $backupPath) { $backupPath } else { '首次部署,无旧版本' })"
  130. }