Kaynağa Gözat

fix(sync-bridge): validate IIS target and strengthen deployment checks

彭峰 2 gün önce
ebeveyn
işleme
863a32eb23

+ 14 - 4
legacy-sync-bridge/README.md

@@ -37,13 +37,17 @@ npm run sync-bridge:package
 3. 先使用管理员 PowerShell 做零修改部署前检查:
 
    ```powershell
-   .\deployment\Install-XiaoshuLegacySyncBridge.ps1 -PublishPath . -ConfigurationPath C:\secure\xiaoshu-sync.production.json -PreflightOnly
+   Get-Website | Select-Object Name, State, PhysicalPath
+   Get-WebBinding | Select-Object ItemXPath, Protocol, BindingInformation
+   # 填写绑定 a018.2018.z01.com 的实际 IIS 站点名称。
+   $targetSite = Read-Host '目标 IIS 站点名称'
+   .\deployment\Install-XiaoshuLegacySyncBridge.ps1 -PublishPath . -ConfigurationPath C:\secure\xiaoshu-sync.production.json -SiteName $targetSite -HealthUrl https://a018.2018.z01.com/xiaoshu-sync/v1/health -PreflightOnly
    ```
 
 4. 部署前检查通过后,再运行正式安装:
 
    ```powershell
-   .\deployment\Install-XiaoshuLegacySyncBridge.ps1 -PublishPath . -ConfigurationPath C:\secure\xiaoshu-sync.production.json
+   .\deployment\Install-XiaoshuLegacySyncBridge.ps1 -PublishPath . -ConfigurationPath C:\secure\xiaoshu-sync.production.json -SiteName $targetSite -HealthUrl https://a018.2018.z01.com/xiaoshu-sync/v1/health
    ```
 
 5. 使用外部可访问地址执行签名验证(应用部署在 `/xiaoshu-sync`,程序内部路由使用 `/v1`,不会重复路径):
@@ -52,14 +56,20 @@ npm run sync-bridge:package
    .\deployment\Test-XiaoshuLegacySyncBridge.ps1 -BaseUrl https://a018.2018.z01.com/xiaoshu-sync -ConfigurationPath C:\secure\xiaoshu-sync.production.json
    ```
 
-安装脚本会先备份现有物理目录、创建独立应用池、收紧目录 ACL,并等待 `/xiaoshu-sync/v1/health` 成功;不会把连接串或 Secret 打印到终端。首次只上线读取桥,写入过程保持失败关闭。需要回滚时,使用安装输出的备份目录执行:
+安装脚本必须显式指定目标站点和完整健康地址,先核对域名绑定、端口、协议与配置 PathBase,再备份现有物理目录、创建独立的 64 位应用池、收紧目录 ACL。健康响应必须包含 `service=xiaoshu-legacy-sync` 和 `version=v1`。验收脚本兼容 Windows PowerShell 5.1,除健康与签名 manifest 外,还读取每个数据集第一页,确认数据库查询和投影可用;不会打印业务记录、连接串或 Secret。首次只上线读取桥,写入过程保持失败关闭。需要回滚时,使用安装输出的备份目录执行:
 
 ```powershell
-.\deployment\Rollback-XiaoshuLegacySyncBridge.ps1 -BackupPath C:\inetpub\xiaoshu-sync.backup-YYYYMMDD-HHMMSS
+.\deployment\Rollback-XiaoshuLegacySyncBridge.ps1 -BackupPath C:\inetpub\xiaoshu-sync.backup-YYYYMMDD-HHMMSS -HealthUrl https://a018.2018.z01.com/xiaoshu-sync/v1/health
 ```
 
 回滚脚本会先保留当前失败版本,再恢复备份、启动应用池并重新检查健康地址。
 
+### 健康地址返回 404 时
+
+`/v1/health` 不需要 HMAC 或数据库连接。404 应先检查 IIS 站点、子应用映射与反向代理路径,不能通过重试同步或更换签名密钥修复。文件上传到目录也不代表已创建 IIS 应用。
+
+在服务器管理员 PowerShell 中查看 `Get-WebApplication -Site $targetSite`、`Get-WebBinding -Name $targetSite`、`Get-WebAppPoolState -Name XiaoshuLegacySync`,确认 `/xiaoshu-sync` 是目标域名所在站点下的独立应用,其物理目录含 `Xiaoshu.LegacySyncBridge.dll`、`web.config` 和安全填写的生产配置。若服务文件和应用均存在,检查该站点 IIS 日志的 `sc-status/sc-substatus/sc-win32-status` 及事件查看器中的 ASP.NET Core Module 记录;仅凭外部 404 不能确定具体 IIS 子状态。CMS 的模板编辑、静态站点发布和上传文件管理不能代替 IIS 子应用管理。
+
 外部健康地址为 `/xiaoshu-sync/v1/health`,manifest 为 `/xiaoshu-sync/v1/manifest`。HMAC 原文包含 IIS 的 `PathBase`,因此测试脚本签名的路径与服务器校验路径完全一致。`manifest.consistent=false` 表示读取数量期间旧库发生了变化,调用方必须重试,不能把该次结果作为同水位对账依据。
 
 数据库脚本执行后必须再执行 `database/verify-readonly.sql`。它会核对日志表、命令过程和 12 个业务变更触发器,输出各数据集当前数量与水位,并明确显示写桥是否仍处于失败关闭状态。只读阶段必须保持 `WritesFailClosed=1`;没有取得旧站正式后端/BLL 源码时,不允许用直接更新业务表的方式替代。

+ 28 - 3
legacy-sync-bridge/deployment/Install-XiaoshuLegacySyncBridge.ps1

@@ -6,11 +6,14 @@ param(
   [Parameter(Mandatory = $true)]
   [string]$ConfigurationPath,
 
-  [string]$SiteName = 'Default Web Site',
+  [Parameter(Mandatory = $true)]
+  [string]$SiteName,
+  [ValidatePattern('^[A-Za-z0-9_-]+$')]
   [string]$ApplicationName = 'xiaoshu-sync',
   [string]$ApplicationPoolName = 'XiaoshuLegacySync',
   [string]$PhysicalPath = 'C:\inetpub\xiaoshu-sync',
-  [string]$HealthUrl = 'http://127.0.0.1/xiaoshu-sync/v1/health',
+  [Parameter(Mandatory = $true)]
+  [string]$HealthUrl,
   [switch]$PreflightOnly
 )
 
@@ -49,11 +52,32 @@ $resolvedPublishPath = (Resolve-Path -LiteralPath $PublishPath).Path
 if (-not (Test-Path -LiteralPath (Join-Path $resolvedPublishPath 'Xiaoshu.LegacySyncBridge.dll'))) { throw '发布目录缺少 Xiaoshu.LegacySyncBridge.dll。' }
 if (-not (Test-Path -LiteralPath (Join-Path $resolvedPublishPath 'web.config'))) { throw '发布目录缺少 web.config。' }
 $productionConfiguration = Assert-Configuration $ConfigurationPath
+$configuration = $productionConfiguration | ConvertFrom-Json
+$configuredPathBase = [string]$configuration.XiaoshuSync.PathBase
+if ($configuredPathBase -ne "/$ApplicationName") { throw '配置中的 PathBase 必须与 IIS 应用路径一致。' }
 
 if (-not (Get-WebGlobalModule -Name 'AspNetCoreModuleV2' -ErrorAction SilentlyContinue)) {
   throw '服务器尚未安装 IIS AspNetCoreModuleV2。同步桥为 Windows x64 自包含发布,不要求服务器安装 .NET 8 Runtime,但仍需要该 IIS 模块。'
 }
 if (-not (Test-Path "IIS:\Sites\$SiteName")) { throw "IIS 站点不存在:$SiteName" }
+$healthUri = [Uri]$HealthUrl
+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) {
+  throw 'HealthUrl 必须是目标站点的完整 HTTP(S) 健康地址,路径为 /应用名/v1/health。'
+}
+$matchingBindings = @(Get-WebBinding -Name $SiteName | Where-Object {
+  $parts = ([string]$_.bindingInformation) -split ':'
+  $address = $null
+  $isIpAddress = [Net.IPAddress]::TryParse($healthUri.DnsSafeHost, [ref]$address)
+  $hostMatches = $parts[-1] -eq $healthUri.DnsSafeHost -or
+    ($isIpAddress -and [string]::IsNullOrEmpty($parts[-1]) -and ($parts[0] -eq '*' -or $parts[0] -eq $healthUri.DnsSafeHost))
+  $_.protocol -eq $healthUri.Scheme -and $parts[-2] -eq [string]$healthUri.Port -and
+    $hostMatches
+})
+if ($matchingBindings.Count -eq 0) {
+  throw "健康地址的域名、端口和协议没有绑定到 IIS 站点 '$SiteName'。请指定实际承载旧站域名的站点,不能默认安装到 Default Web Site。"
+}
+$rootPool = [string](Get-Item "IIS:\Sites\$SiteName").applicationPool
+if ($ApplicationPoolName -eq $rootPool) { throw '同步桥必须使用独立应用池,不能使用旧站根应用的应用池。' }
 
 Write-Host '部署前检查通过:'
 Write-Host "  IIS 站点:$SiteName"
@@ -90,6 +114,7 @@ if ($PSCmdlet.ShouldProcess("$SiteName$applicationPath", '部署小树旧系统
     New-WebAppPool -Name $ApplicationPoolName | Out-Null
   }
   Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name managedRuntimeVersion -Value ''
+  Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name enable32BitAppOnWin64 -Value $false
   Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name processModel.identityType -Value ApplicationPoolIdentity
   Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name startMode -Value AlwaysRunning
 
@@ -108,7 +133,7 @@ if ($PSCmdlet.ShouldProcess("$SiteName$applicationPath", '部署小树旧系统
   foreach ($attempt in 1..12) {
     try {
       $health = Invoke-RestMethod -Uri $HealthUrl -Method Get -TimeoutSec 10
-      if ($health.status -eq 'ok') { $healthy = $true; break }
+      if ($health.status -eq 'ok' -and $health.service -eq 'xiaoshu-legacy-sync' -and $health.version -eq 'v1') { $healthy = $true; break }
     } catch {
       Start-Sleep -Seconds 2
     }

+ 3 - 2
legacy-sync-bridge/deployment/Rollback-XiaoshuLegacySyncBridge.ps1

@@ -5,7 +5,8 @@ param(
 
   [string]$ApplicationPoolName = 'XiaoshuLegacySync',
   [string]$PhysicalPath = 'C:\inetpub\xiaoshu-sync',
-  [string]$HealthUrl = 'http://127.0.0.1/xiaoshu-sync/v1/health'
+  [Parameter(Mandatory = $true)]
+  [string]$HealthUrl
 )
 
 $ErrorActionPreference = 'Stop'
@@ -48,7 +49,7 @@ if ($PSCmdlet.ShouldProcess($PhysicalPath, "从 $resolvedBackupPath 回滚小树
   foreach ($attempt in 1..12) {
     try {
       $health = Invoke-RestMethod -Uri $HealthUrl -Method Get -TimeoutSec 10
-      if ($health.status -eq 'ok') { $healthy = $true; break }
+      if ($health.status -eq 'ok' -and $health.service -eq 'xiaoshu-legacy-sync' -and $health.version -eq 'v1') { $healthy = $true; break }
     } catch {
       Start-Sleep -Seconds 2
     }

+ 38 - 19
legacy-sync-bridge/deployment/Test-XiaoshuLegacySyncBridge.ps1

@@ -17,32 +17,51 @@ $secret = [string]$configuration.XiaoshuSync.Secret
 if ([string]::IsNullOrWhiteSpace($keyId) -or $secret.Length -lt 32) { throw '配置文件中的 KeyId 或 Secret 无效。' }
 
 $base = $BaseUrl.TrimEnd('/')
+$baseUri = [Uri]$base
+if (-not $baseUri.IsAbsoluteUri -or $baseUri.Scheme -notin @('http', 'https') -or $baseUri.Query -or $baseUri.Fragment -or $baseUri.UserInfo -or $baseUri.AbsolutePath.TrimEnd('/') -ne [string]$configuration.XiaoshuSync.PathBase) {
+  throw 'BaseUrl 必须是与配置 PathBase 一致的 HTTP(S) 应用根地址,不能附加 /v1。'
+}
 $health = Invoke-RestMethod -Uri "$base/v1/health" -Method Get -TimeoutSec 15
-if ($health.status -ne 'ok') { throw '健康检查返回异常。' }
+if ($health.status -ne 'ok' -or $health.service -ne 'xiaoshu-legacy-sync' -or $health.version -ne 'v1') { throw '健康地址未返回小树同步桥 v1,请检查域名绑定和 IIS 子应用。' }
 
-$baseUri = [Uri]$base
 $pathBase = $baseUri.AbsolutePath.TrimEnd('/')
-$pathAndQuery = "$pathBase/v1/manifest"
-$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
-$nonce = [Guid]::NewGuid().ToString('N')
-$emptyBodyHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes(''))).ToLowerInvariant()
-$canonical = "GET`n$pathAndQuery`n$timestamp`n$nonce`n$emptyBodyHash"
-$hmac = [Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($secret))
-try {
-  $signature = [Convert]::ToHexString($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).ToLowerInvariant()
-} finally {
-  $hmac.Dispose()
-}
+function Invoke-SignedRead([string]$RelativePath) {
+  $pathAndQuery = "$pathBase$RelativePath"
+  $timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
+  $nonce = [Guid]::NewGuid().ToString('N')
+  $sha256 = [Security.Cryptography.SHA256]::Create()
+  try {
+    $emptyBodyHash = ([BitConverter]::ToString($sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes('')))).Replace('-', '').ToLowerInvariant()
+  } finally {
+    $sha256.Dispose()
+  }
+  $canonical = "GET`n$pathAndQuery`n$timestamp`n$nonce`n$emptyBodyHash"
+  $hmac = [Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($secret))
+  try {
+    $signature = ([BitConverter]::ToString($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical)))).Replace('-', '').ToLowerInvariant()
+  } finally {
+    $hmac.Dispose()
+  }
 
-$headers = @{
-  'X-Xiaoshu-Key-Id' = $keyId
-  'X-Xiaoshu-Timestamp' = $timestamp
-  'X-Xiaoshu-Nonce' = $nonce
-  'X-Xiaoshu-Signature' = $signature
+  $headers = @{
+    'X-Xiaoshu-Key-Id' = $keyId
+    'X-Xiaoshu-Timestamp' = $timestamp
+    'X-Xiaoshu-Nonce' = $nonce
+    'X-Xiaoshu-Signature' = $signature
+  }
+  return Invoke-RestMethod -Uri "$base$RelativePath" -Method Get -Headers $headers -TimeoutSec 120
 }
-$manifest = Invoke-RestMethod -Uri "$base/v1/manifest" -Method Get -Headers $headers -TimeoutSec 120
+$manifest = Invoke-SignedRead '/v1/manifest'
 if (-not $manifest.manifestTime -or @($manifest.datasets).Count -lt 10 -or $manifest.consistent -ne $true) { throw 'manifest 返回结构不完整或读取期间旧库仍在变化,请稍后重试。' }
+foreach ($dataset in @($manifest.datasets)) {
+  $key = [Uri]::EscapeDataString([string]$dataset.key)
+  $page = Invoke-SignedRead "/v1/changes?dataset=$key&limit=1"
+  if ($page.dataset -ne $dataset.key -or $page.phase -ne 'snapshot' -or -not $page.cursor -or $page.count -ne @($page.items).Count -or $page.count -gt 1) {
+    throw "数据集 '$($dataset.key)' 首次读取结构异常。"
+  }
+}
 
 Write-Host "健康检查:通过($($health.time))"
 Write-Host "数据水位:$($manifest.manifestTime)"
+Write-Host '各数据集 changes 读取:通过(未输出业务记录)'
 @($manifest.datasets) | Select-Object key, label, count, watermark | Format-Table -AutoSize