[CmdletBinding()] param( [string]$SourceDirectory = "", [string]$InstallDirectory = (Join-Path $env:ProgramFiles "Scert"), [string]$DataDirectory = (Join-Path $env:ProgramData "Scert"), [string]$CertificateDirectory = (Join-Path $env:ProgramData "Scert\ssl"), [ValidatePattern("^[A-Za-z0-9_.-]+$")] [string]$ServiceName = "Scert", [ValidatePattern("^[A-Za-z0-9_.-]+$")] [string]$AdminServiceName = "Scert-admin" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Definition if ([string]::IsNullOrWhiteSpace($SourceDirectory)) { $SourceDirectory = $scriptDirectory } function Test-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Resolve-RequiredDirectory { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$Label ) if ([string]::IsNullOrWhiteSpace($Path)) { throw "$Label 不能为空。" } $resolved = [System.IO.Path]::GetFullPath($Path) $root = [System.IO.Path]::GetPathRoot($resolved) if ($resolved -eq $root) { throw "$Label 不能使用磁盘根目录:$resolved" } return $resolved.TrimEnd([System.IO.Path]::DirectorySeparatorChar) } function Assert-NoReparsePoint { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$Label ) $resolved = [System.IO.Path]::GetFullPath($Path) $root = [System.IO.Path]::GetPathRoot($resolved) $current = $root $relative = $resolved.Substring($root.Length) foreach ($segment in $relative.Split([System.IO.Path]::DirectorySeparatorChar, [System.StringSplitOptions]::RemoveEmptyEntries)) { $current = Join-Path $current $segment if (Test-Path -LiteralPath $current) { $item = Get-Item -LiteralPath $current -Force if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "$Label 不能包含重解析点或符号链接:$current" } } } } function Assert-ServiceStopped { param( [Parameter(Mandatory = $true)] [string]$Name ) $service = Get-Service -Name $Name -ErrorAction SilentlyContinue if ($null -ne $service -and $service.Status -ne [System.ServiceProcess.ServiceControllerStatus]::Stopped) { throw "升级或安装前必须停止 Windows 服务:$Name" } } function Assert-WindowsAmd64Executable { param( [Parameter(Mandatory = $true)] [string]$Path ) $stream = [System.IO.File]::OpenRead($Path) $reader = [System.IO.BinaryReader]::new($stream) try { if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5A4D) { throw "安装产物不是有效的 Windows PE 文件:$Path" } $stream.Position = 0x3C $peOffset = $reader.ReadInt32() if ($peOffset -lt 0 -or ($peOffset + 6) -gt $stream.Length) { throw "安装产物的 PE 头无效:$Path" } $stream.Position = $peOffset if ($reader.ReadUInt32() -ne 0x00004550 -or $reader.ReadUInt16() -ne 0x8664) { throw "安装产物不是 Windows amd64 可执行文件:$Path" } } finally { $reader.Dispose() $stream.Dispose() } } function Invoke-Sc { param( [Parameter(Mandatory = $true)] [string[]]$Arguments, [switch]$AllowFailure ) $output = & sc.exe @Arguments 2>&1 if ($LASTEXITCODE -ne 0 -and -not $AllowFailure) { throw "Windows 服务命令执行失败:sc.exe $($Arguments -join ' ')`n$($output -join "`n")" } return $output } function Test-ServiceExists { param([Parameter(Mandatory = $true)][string]$Name) & sc.exe query $Name *> $null return $LASTEXITCODE -eq 0 } function Install-OrUpdateService { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$DisplayName, [Parameter(Mandatory = $true)][string]$Description, [Parameter(Mandatory = $true)][string]$BinaryPath, [Parameter(Mandatory = $true)][ValidateSet("auto", "demand")][string]$StartMode ) if (Test-ServiceExists -Name $Name) { Invoke-Sc -Arguments @("config", $Name, "binPath=", $BinaryPath, "start=", $StartMode, "obj=", "NT AUTHORITY\LocalService", "DisplayName=", $DisplayName) | Out-Null } else { Invoke-Sc -Arguments @("create", $Name, "binPath=", $BinaryPath, "start=", $StartMode, "obj=", "NT AUTHORITY\LocalService", "DisplayName=", $DisplayName) | Out-Null } Invoke-Sc -Arguments @("description", $Name, $Description) | Out-Null } function Set-DirectoryAcl { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][ValidateSet("read", "modify")][string]$Mode ) $localServicePermission = if ($Mode -eq "modify") { "(OI)(CI)(M)" } else { "(OI)(CI)(RX)" } & icacls.exe $Path /inheritance:r /grant:r ` "BUILTIN\Administrators:(OI)(CI)(F)" ` "NT AUTHORITY\SYSTEM:(OI)(CI)(F)" ` "NT AUTHORITY\LOCAL SERVICE:$localServicePermission" *> $null if ($LASTEXITCODE -ne 0) { throw "设置目录访问控制失败:$Path" } } if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw "该安装器只能在 Windows 上运行。" } if (-not (Test-Administrator)) { throw "请使用管理员权限运行 Scert Windows 安装器。" } if (-not (Get-Command sc.exe -ErrorAction SilentlyContinue)) { throw "系统中没有找到 sc.exe,无法注册 Windows 服务。" } if (-not (Get-Command icacls.exe -ErrorAction SilentlyContinue)) { throw "系统中没有找到 icacls.exe,无法设置安全权限。" } $source = Resolve-RequiredDirectory -Path $SourceDirectory -Label "安装产物目录" $install = Resolve-RequiredDirectory -Path $InstallDirectory -Label "程序安装目录" $data = Resolve-RequiredDirectory -Path $DataDirectory -Label "数据目录" $certificate = Resolve-RequiredDirectory -Path $CertificateDirectory -Label "证书目录" Assert-NoReparsePoint -Path $source -Label "安装产物目录" Assert-NoReparsePoint -Path $install -Label "程序安装目录" Assert-NoReparsePoint -Path $data -Label "数据目录" Assert-NoReparsePoint -Path $certificate -Label "证书目录" Assert-ServiceStopped -Name $ServiceName Assert-ServiceStopped -Name $AdminServiceName $sourceScert = Join-Path $source "scert.exe" $sourceAdmin = Join-Path $source "scert-admin.exe" if (-not (Test-Path -LiteralPath $sourceScert -PathType Leaf)) { throw "安装产物目录缺少 scert.exe:$sourceScert" } if (-not (Test-Path -LiteralPath $sourceAdmin -PathType Leaf)) { throw "安装产物目录缺少 scert-admin.exe:$sourceAdmin" } Assert-NoReparsePoint -Path $sourceScert -Label "Scert 安装产物" Assert-NoReparsePoint -Path $sourceAdmin -Label "Scert-admin 安装产物" Assert-WindowsAmd64Executable -Path $sourceScert Assert-WindowsAmd64Executable -Path $sourceAdmin $configDirectory = Join-Path $data "config" $databaseDirectory = Join-Path $data "data" $logDirectory = Join-Path $data "logs" $backupDirectory = Join-Path $data "backups" $docsDirectory = Join-Path $data "docs" $runtimeDirectory = Join-Path $data "runtime" foreach ($directory in @($install, $data, $configDirectory, $databaseDirectory, $logDirectory, $backupDirectory, $docsDirectory, $runtimeDirectory, $certificate)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $installedScert = Join-Path $install "scert.exe" $installedAdmin = Join-Path $install "scert-admin.exe" Copy-Item -LiteralPath $sourceScert -Destination $installedScert -Force Copy-Item -LiteralPath $sourceAdmin -Destination $installedAdmin -Force Set-DirectoryAcl -Path $install -Mode read foreach ($directory in @($data, $configDirectory, $databaseDirectory, $logDirectory, $backupDirectory, $runtimeDirectory, $certificate)) { Set-DirectoryAcl -Path $directory -Mode modify } Set-DirectoryAcl -Path $docsDirectory -Mode read $configurationPath = Join-Path $configDirectory "scert.json" $resetCodePath = Join-Path $runtimeDirectory "reset-code" $mainBinaryPath = '"{0}" --config "{1}" serve' -f $installedScert, $configurationPath $adminBinaryPath = '"{0}" --config "{1}" --code-output "{2}"' -f $installedAdmin, $configurationPath, $resetCodePath Install-OrUpdateService ` -Name $ServiceName ` -DisplayName "Scert SSL 证书管理服务" ` -Description "Scert SSL 证书申请、续期和部署服务" ` -BinaryPath $mainBinaryPath ` -StartMode auto Install-OrUpdateService ` -Name $AdminServiceName ` -DisplayName "Scert-admin 临时密码重置服务" ` -Description "仅在管理员手动启动时临时激活 Scert 密码重置入口" ` -BinaryPath $adminBinaryPath ` -StartMode demand $metadata = [ordered]@{ installed_at = [DateTimeOffset]::Now.ToString("o") install_directory = $install data_directory = $data config_directory = $configDirectory certificate_root = $certificate service_name = $ServiceName admin_service_name = $AdminServiceName service_account = "NT AUTHORITY\LocalService" } $metadataPath = Join-Path $configDirectory "installation.json" $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $metadataPath -Encoding UTF8 Write-Host "Scert Windows 文件安装和服务注册完成。" Write-Host "核心程序:$installedScert" Write-Host "密码重置程序:$installedAdmin" Write-Host "配置目录:$configDirectory" Write-Host "数据目录:$databaseDirectory" Write-Host "日志目录:$logDirectory" Write-Host "备份目录:$backupDirectory" Write-Host "证书目录:$certificate" Write-Host "安装记录:$metadataPath" Write-Host "Scert 主服务已注册为自动启动,但本安装器没有启动或重启服务。" Write-Host "Scert-admin 已注册为手动启动,本安装器不会启动它,也不会创建公网防火墙规则。" Write-Host "首次初始化时请使用配置文件路径:$configurationPath"