✨ feat(server): 增加混淆发布链路与调试页静态说明

- 新增 Obfuscar 混淆发布脚本、发布测试与产物隐私检查
- 调整 VS Code 发布任务和宿主发布配置,统一走混淆流程
- 为 debug 页面内嵌静态接口说明,并补充对应集成测试
- 更新 README 与设计计划文档,记录跨平台发布约束
This commit is contained in:
2026-06-22 15:55:34 +08:00
parent ac41d04a54
commit 070d2ba5c0
13 changed files with 1570 additions and 49 deletions

View File

@@ -0,0 +1,252 @@
<#
.SYNOPSIS
验证混淆发布脚本生成的 Obfuscar 配置满足仓库发布契约。
.DESCRIPTION
该测试使用临时空文件模拟发布目录,仅验证模块筛选、绝对路径和失败语义,
不会执行 dotnet publish,也不会要求测试环境安装 Obfuscar。
#>
$ErrorActionPreference = 'Stop'
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$publishScriptPath = Join-Path $repositoryRoot 'scripts\Publish-Obfuscated.ps1'
$publishProfilePath = Join-Path $repositoryRoot 'src\Flyshot.Server.Host\Properties\PublishProfiles\FolderProfile.pubxml'
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "flyshot-obfuscation-test-$([guid]::NewGuid().ToString('N'))"
$inputDirectory = Join-Path $temporaryRoot 'input'
$outputDirectory = Join-Path $temporaryRoot 'obfuscated'
$configPath = Join-Path $temporaryRoot 'obfuscar.xml'
$expectedAssemblies = @(
'Flyshot.Server.Host.dll'
'Flyshot.ControllerClientCompat.dll'
'Flyshot.Core.Config.dll'
'Flyshot.Core.Domain.dll'
'Flyshot.Core.Planning.dll'
'Flyshot.Core.Triggering.dll'
'Flyshot.Runtime.Fanuc.dll'
'Flyshot.Runtime.Common.dll'
)
$futureAssemblyName = 'Flyshot.Future.Module.dll'
try
{
$publishScriptContent = Get-Content -LiteralPath $publishScriptPath -Raw
$publishScriptTokens = $null
$publishScriptErrors = $null
$publishScriptAst = [System.Management.Automation.Language.Parser]::ParseInput(
$publishScriptContent,
[ref]$publishScriptTokens,
[ref]$publishScriptErrors)
if ($publishScriptErrors.Count -ne 0)
{
throw "Publish-Obfuscated.ps1 存在 PowerShell 语法错误:$($publishScriptErrors[0].Message)"
}
$scriptParameters = @($publishScriptAst.ParamBlock.Parameters | ForEach-Object {
$_.Name.VariablePath.UserPath
})
if ('RuntimeIdentifiers' -in $scriptParameters -or 'ExistingRuntimeIdentifier' -in $scriptParameters)
{
throw '发布脚本不应再暴露运行平台或 RID 参数。'
}
if ($publishScriptContent -match "'-r'|--self-contained'\s*,?\s*'true'|win-x64|linux-x64")
{
throw '发布脚本默认发布流程不得再指定 RID 或生成自包含平台包。'
}
if ($publishScriptContent -notmatch '-p:UseAppHost=false')
{
throw '框架依赖发布必须关闭平台相关 apphost,只保留跨平台 DLL 入口。'
}
[xml]$publishProfile = Get-Content -LiteralPath $publishProfilePath -Raw
$publishNamespace = [System.Xml.XmlNamespaceManager]::new($publishProfile.NameTable)
$publishNamespace.AddNamespace('msb', $publishProfile.Project.NamespaceURI)
$useAppHost = $publishProfile.SelectSingleNode(
'/msb:Project/msb:PropertyGroup/msb:UseAppHost',
$publishNamespace)
if ($null -eq $useAppHost -or $useAppHost.InnerText -ne 'false')
{
throw 'FolderProfile.pubxml 必须关闭平台相关 apphost。'
}
$afterPublishTarget = $publishProfile.SelectSingleNode(
'/msb:Project/msb:Target[@Name="ObfuscatePublishedFlyshotAssemblies"]',
$publishNamespace)
if ($null -eq $afterPublishTarget -or $afterPublishTarget.AfterTargets -ne 'Publish')
{
throw 'FolderProfile.pubxml 必须注册 Publish 完成后的混淆 Target。'
}
$publishExec = $afterPublishTarget.SelectSingleNode('msb:Exec', $publishNamespace)
if (
$null -eq $publishExec -or
$publishExec.Command -notmatch '-ObfuscateExistingPublish' -or
$publishExec.Command -notmatch '\$\(PublishDir\)\.' -or
$publishExec.Command -match 'RuntimeIdentifier'
)
{
throw 'Visual Studio 发布 Target 必须把 PublishDir 以安全的非反斜杠结尾形式交给混淆模式。'
}
New-Item -ItemType Directory -Path $inputDirectory -Force | Out-Null
foreach ($assemblyName in $expectedAssemblies)
{
New-Item -ItemType File -Path (Join-Path $inputDirectory $assemblyName) -Force | Out-Null
}
# 模拟未来新增模块,证明脚本会自动保护所有 Flyshot.* 程序集,而不是只处理当前固定清单。
New-Item -ItemType File -Path (Join-Path $inputDirectory $futureAssemblyName) -Force | Out-Null
# 第三方程序集用于证明配置生成器只选择 Flyshot 自研模块。
New-Item -ItemType File -Path (Join-Path $inputDirectory 'NLog.dll') -Force | Out-Null
& pwsh -NoProfile -File $publishScriptPath `
-GenerateConfigOnly `
-InputDirectory $inputDirectory `
-ObfuscatedDirectory $outputDirectory `
-ConfigPath $configPath
if ($LASTEXITCODE -ne 0)
{
throw "配置生成命令退出码不是 0:$LASTEXITCODE"
}
[xml]$configuration = Get-Content -LiteralPath $configPath -Raw
$modulePaths = @($configuration.Obfuscator.Module | ForEach-Object { [string]$_.file })
$allExpectedAssemblies = @($expectedAssemblies) + $futureAssemblyName
if ($modulePaths.Count -ne $allExpectedAssemblies.Count)
{
throw "预期生成 $($allExpectedAssemblies.Count) 个模块,实际为 $($modulePaths.Count) 个。"
}
foreach ($assemblyName in $allExpectedAssemblies)
{
$expectedPath = [System.IO.Path]::GetFullPath((Join-Path $inputDirectory $assemblyName))
if ($expectedPath -notin $modulePaths)
{
throw "Obfuscar 配置缺少自研程序集:$assemblyName"
}
}
if ($modulePaths | Where-Object { [System.IO.Path]::GetFileName($_) -eq 'NLog.dll' })
{
throw '第三方程序集 NLog.dll 不应进入 Obfuscar 模块列表。'
}
foreach ($path in $modulePaths)
{
if (-not [System.IO.Path]::IsPathFullyQualified($path))
{
throw "Obfuscar 模块路径不是绝对路径:$path"
}
}
foreach ($module in @($configuration.Obfuscator.Module))
{
$anonymousTypeRule = @($module.SkipType | Where-Object {
[string]$_.name -eq '^<>f__AnonymousType.*'
})
if ($anonymousTypeRule.Count -ne 1)
{
throw "模块 $($module.file) 必须显式保留匿名类型元数据。"
}
if (
[string]$anonymousTypeRule[0].skipMethods -ne 'true' -or
[string]$anonymousTypeRule[0].skipFields -ne 'true' -or
[string]$anonymousTypeRule[0].skipProperties -ne 'true' -or
[string]$anonymousTypeRule[0].skipEvents -ne 'true'
)
{
throw "模块 $($module.file) 的匿名类型排除规则不完整。"
}
}
$assemblySearchPaths = @($configuration.Obfuscator.AssemblySearchPath | ForEach-Object {
[string]$_.path
})
if ($assemblySearchPaths.Count -lt 2)
{
throw 'Obfuscar 配置必须包含 .NETCore.App 与 ASP.NET Core 共享框架搜索目录。'
}
foreach ($assemblySearchPath in $assemblySearchPaths)
{
if (-not [System.IO.Path]::IsPathFullyQualified($assemblySearchPath))
{
throw "共享框架搜索目录不是绝对路径:$assemblySearchPath"
}
}
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.NETCore\.App' }))
{
throw 'Obfuscar 配置缺少 Microsoft.NETCore.App 搜索目录。'
}
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.AspNetCore\.App' }))
{
throw 'Obfuscar 配置缺少 Microsoft.AspNetCore.App 搜索目录。'
}
$variables = @{}
foreach ($variable in $configuration.Obfuscator.Var)
{
$variables[[string]$variable.name] = [string]$variable.value
}
if ($variables['KeepPublicApi'] -ne 'true')
{
throw 'KeepPublicApi 必须为 true,以保护 ASP.NET Core 和公共契约。'
}
if ($variables['HidePrivateApi'] -ne 'true')
{
throw 'HidePrivateApi 必须为 true,以混淆内部实现。'
}
if ($variables['RenameProperties'] -ne 'false' -or $variables['RenameEvents'] -ne 'false')
{
throw '属性和事件重命名必须关闭,以保护配置绑定及序列化契约。'
}
if ($variables['SkipGenerated'] -ne 'true')
{
throw '编译器生成类型必须跳过混淆,以保护匿名 JSON 响应和异步状态机。'
}
if ($variables['SkipSpecialName'] -ne 'true')
{
throw 'SpecialName 成员必须跳过混淆,以保护属性、事件和框架反射约定。'
}
Remove-Item -LiteralPath (Join-Path $inputDirectory 'Flyshot.Runtime.Common.dll') -Force
$missingAssemblyConfigPath = Join-Path $temporaryRoot 'missing-assembly.xml'
& pwsh -NoProfile -File $publishScriptPath `
-GenerateConfigOnly `
-InputDirectory $inputDirectory `
-ObfuscatedDirectory $outputDirectory `
-ConfigPath $missingAssemblyConfigPath *> $null
if ($LASTEXITCODE -eq 0)
{
throw '缺少预期的 Flyshot 程序集时,配置生成必须失败。'
}
Write-Host 'Publish-Obfuscated.Tests.ps1: PASS'
}
finally
{
if (Test-Path -LiteralPath $temporaryRoot)
{
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
}
}

View File

@@ -0,0 +1,35 @@
<#
.SYNOPSIS
验证混淆发布包不会泄露 Flyshot 源码文档或调试符号。
.PARAMETER PublishDirectory
需要检查的框架依赖混淆发布目录。
#>
[CmdletBinding()]
param(
[string]$PublishDirectory = (Join-Path $PSScriptRoot '..\..\outputs\obfuscated\framework-dependent')
)
$ErrorActionPreference = 'Stop'
$absolutePublishDirectory = [System.IO.Path]::GetFullPath($PublishDirectory)
if (-not (Test-Path -LiteralPath $absolutePublishDirectory -PathType Container))
{
throw "混淆发布目录不存在:$absolutePublishDirectory"
}
$sensitiveFiles = @(
Get-ChildItem -LiteralPath $absolutePublishDirectory -File |
Where-Object {
$_.Name -like 'Flyshot*.xml' -or
$_.Name -like 'Flyshot*.pdb'
}
)
if ($sensitiveFiles.Count -gt 0)
{
throw "混淆发布包仍包含源码文档或调试符号:$($sensitiveFiles.Name -join ', ')"
}
Write-Host 'Test-PublishedArtifactPrivacy.ps1: PASS'