diff --git a/scripts/deploy-iis-git.ps1 b/scripts/deploy-iis-git.ps1 index 674fe18..71910bd 100644 --- a/scripts/deploy-iis-git.ps1 +++ b/scripts/deploy-iis-git.ps1 @@ -1,29 +1,33 @@ <# .SYNOPSIS - Packages this repository and deploys it to IIS over Tailscale/OpenSSH. + Validates, packages, and deploys this repository to its dedicated IIS site. .DESCRIPTION - This is the controller/CI entry point. It creates a ZIP containing the full - repository (excluding VCS and local deployment artifacts), computes SHA-256, - copies the package and host installer with scp, then invokes the installer on - the Windows host through ssh. - - -DryRun performs local validation and prints the remote operations without - connecting. Gitea 1.11.4 does not run this itself; use a trusted external CI - worker, a scheduled task, or an operator workstation. + The controller packages the complete repository, computes SHA-256, transfers + the package and host installer over OpenSSH, and invokes the installer. + -LocalPreflightOnly validates source provenance, XML, package layout, and + archive safety without connecting. -PreflightOnly additionally streams the + installer to the host and runs its read-only IIS preflight without writing a + remote installer or package. #> [CmdletBinding()] param( - [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9_. -]+$')] - [string]$SiteName, - + [string]$SiteName = 'AspClassicUnifiedFramework', + [ValidatePattern('^[A-Za-z0-9_. -]+$')] + [string]$AppPoolName = 'AspClassicUnifiedFramework', + [string]$DeployRoot = 'D:\Deployments\AspClassicUnifiedFramework', + [string]$BindingIpAddress = '100.97.39.23', + [ValidateRange(1, 65535)] + [int]$BindingPort = 8085, + [AllowEmptyString()] + [string]$HostHeader = '', + [string]$InitialWebConfigPath = '', [string]$RemoteTarget = 'webserver-1', [ValidateRange(1, 65535)] [int]$RemotePort = 22, [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), - [string]$DeployRoot = '', [string]$BaseUrl = '', [string]$ReleaseId = '', [string]$ExpectedBranch = 'master', @@ -35,7 +39,9 @@ param( [switch]$AllowDirty, [switch]$RunMigrations, [switch]$SkipSmokeTest, - [switch]$RemotePreflightOnly, + [Alias('HostPreflightOnly', 'RemotePreflightOnly')] + [switch]$PreflightOnly, + [switch]$LocalPreflightOnly, [switch]$Rollback, [string]$RollbackTo = '', [switch]$DryRun, @@ -45,10 +51,7 @@ param( Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' -function Write-Step { - param([string]$Message) - Write-Host ('==> ' + $Message) -} +function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } function Assert-Command { param([string]$Name) @@ -72,177 +75,232 @@ function Add-RemoteArgument { $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value)) } +function Assert-SafeNameValue { + param([string]$Name, [string]$Value) + if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_. -]+$') { + throw "$Name contains unsupported characters." + } + if ($Value -match '(?i)schedulicious') { + throw "$Name must not identify a Schedulicious resource." + } +} + +function Assert-SafeDeploymentValues { + Assert-SafeNameValue -Name 'SiteName' -Value $SiteName + Assert-SafeNameValue -Name 'AppPoolName' -Value $AppPoolName + if ($DeployRoot -match '(?i)schedulicious') { + throw 'DeployRoot must not reference Schedulicious.' + } + if ([string]::IsNullOrWhiteSpace($BindingIpAddress)) { + throw 'BindingIpAddress must not be empty.' + } + $parsedAddress = $null + if (-not [System.Net.IPAddress]::TryParse($BindingIpAddress, [ref]$parsedAddress)) { + throw "BindingIpAddress is not a valid IP address: $BindingIpAddress" + } + if ($HostHeader -match '[:/\\]') { + throw 'HostHeader must be empty or a DNS host name without a scheme, port, slash, or backslash.' + } + if ($HostHeader -match '(?i)schedulicious') { + throw 'HostHeader must not reference Schedulicious.' + } +} + function Copy-ReleaseSource { - param( - [string]$From, - [string]$To - ) + param([string]$From, [string]$To) - New-Item -ItemType Directory -Force -Path $To | Out-Null $excludedNames = @('.git', '.deployment', 'releases') - Get-ChildItem -LiteralPath $From -Force | ForEach-Object { - if ($excludedNames -notcontains $_.Name) { - Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force + $packageRoots = @(Get-ChildItem -LiteralPath $From -Force | Where-Object { $excludedNames -notcontains $_.Name }) + $unsafeSourceItem = $packageRoots | + ForEach-Object { + if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $_ } + elseif ($_.PSIsContainer) { Get-ChildItem -LiteralPath $_.FullName -Recurse -Force } + } | + Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | + Select-Object -First 1 + if ($null -ne $unsafeSourceItem) { + throw "Source contains a reparse point/symbolic link, which is not package-safe: $($unsafeSourceItem.FullName)" + } + + New-Item -ItemType Directory -Force -Path $To | Out-Null + $packageRoots | ForEach-Object { + if ($excludedNames -contains $_.Name) { return } + if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Source contains a reparse point/symbolic link, which is not package-safe: $($_.FullName)" } + Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force } -} -$SourcePath = [System.IO.Path]::GetFullPath($SourcePath) -$installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' -if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { - throw "Host installer is missing: $installerPath" + $unsafeEntry = Get-ChildItem -LiteralPath $To -Recurse -Force | + Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | + Select-Object -First 1 + if ($null -ne $unsafeEntry) { + throw "Package staging contains a reparse point/symbolic link: $($unsafeEntry.FullName)" + } } -$requiredSourceFiles = @( - 'public\Default.asp', - 'public\web.config', - 'core\autoload_core.asp', - 'app\controllers\autoload_controllers.asp' -) -foreach ($relativePath in $requiredSourceFiles) { - if (-not (Test-Path -LiteralPath (Join-Path $SourcePath $relativePath) -PathType Leaf)) { - throw "Source tree is incomplete; missing $relativePath" +function Assert-ReleaseLayout { + param([string]$Root) + $required = @( + 'public\Default.asp', + 'public\web.config', + 'core\autoload_core.asp', + 'app\controllers\autoload_controllers.asp', + 'scripts\install-iis-release.ps1' + ) + foreach ($relativePath in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath) -PathType Leaf)) { + throw "Package/source tree is incomplete; missing $relativePath" + } + } + foreach ($xmlFile in Get-ChildItem -LiteralPath $Root -Recurse -Force -Filter 'web.config') { + try { + [xml](Get-Content -LiteralPath $xmlFile.FullName -Raw) | Out-Null + } catch { + throw "$($xmlFile.FullName) is not valid XML: $($_.Exception.Message)" + } } } -try { - [xml](Get-Content -LiteralPath (Join-Path $SourcePath 'public\web.config') -Raw) | Out-Null -} catch { - throw "Source public\web.config is not valid XML: $($_.Exception.Message)" -} +Assert-SafeDeploymentValues +$SourcePath = [System.IO.Path]::GetFullPath($SourcePath) +$installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' +Assert-ReleaseLayout -Root $SourcePath $directGitRoot = Test-Path -LiteralPath (Join-Path $SourcePath '.git') $commit = 'nogit' if ($directGitRoot) { Assert-Command 'git' $branch = (& git -C $SourcePath branch --show-current).Trim() - if ($LASTEXITCODE -ne 0) { - throw 'Could not determine the Git branch.' - } + if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git branch.' } if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { - throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'. Use -AllowAnyBranch only for an intentional exception." + throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'." } - $dirty = & git -C $SourcePath status --porcelain - if ($LASTEXITCODE -ne 0) { - throw 'Could not inspect the Git worktree.' - } + if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the Git worktree.' } if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) { - throw 'Refusing to deploy a dirty worktree. Commit/stash changes or use -AllowDirty for an intentional, auditable exception.' + throw 'Refusing to deploy a dirty worktree. Use -AllowDirty only for a reviewed exception.' } - $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() - if ($LASTEXITCODE -ne 0) { - throw 'Could not determine the Git commit.' - } + if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git commit.' } Write-Host "Source branch: $branch" Write-Host "Source commit: $commit" } else { Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.' if (-not $AllowAnyBranch) { - throw 'Use a standalone CI checkout, or pass -AllowAnyBranch explicitly for a reviewed non-Git source tree.' + throw 'Use a standalone checkout, or pass -AllowAnyBranch for a reviewed non-Git source tree.' } } if ([string]::IsNullOrWhiteSpace($ReleaseId)) { $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit } -if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { - throw 'ReleaseId may contain only letters, numbers, dot, underscore, and hyphen.' -} -if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { - throw '-Rollback requires -RollbackTo .' +if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { throw 'ReleaseId contains unsupported characters.' } +if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-Rollback requires -RollbackTo.' } +if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-RollbackTo requires -Rollback.' } +if ($RunMigrations -and $Rollback) { throw '-RunMigrations is not valid during rollback.' } +if (($PreflightOnly -or $LocalPreflightOnly) -and $Rollback) { + throw 'Preflight modes cannot be combined with -Rollback.' } -if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { - throw '-RollbackTo is only valid with -Rollback.' -} -if ($RunMigrations -and $Rollback) { - throw '-RunMigrations is not valid during rollback.' +if ($PreflightOnly -and $LocalPreflightOnly) { + throw '-PreflightOnly and -LocalPreflightOnly are mutually exclusive.' } $workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N')) $packageStage = Join-Path $workRoot 'package' +$packageExtract = Join-Path $workRoot 'verify' $packagePath = Join-Path $workRoot ($ReleaseId + '.zip') $remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId $remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip' $remoteInstaller = $remoteDirectory + '\install-iis-release.ps1' try { - if (-not $Rollback -and -not $RemotePreflightOnly) { - Write-Step 'Staging the full repository for packaging' + $sha256 = '' + if (-not $Rollback) { + Write-Step 'Staging the complete repository for packaging' Copy-ReleaseSource -From $SourcePath -To $packageStage + Assert-ReleaseLayout -Root $packageStage Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::CreateFromDirectory( - $packageStage, - $packagePath, - [System.IO.Compression.CompressionLevel]::Optimal, - $false - ) + [System.IO.Compression.ZipFile]::CreateFromDirectory($packageStage, $packagePath, [System.IO.Compression.CompressionLevel]::Optimal, $false) + [System.IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $packageExtract) + Assert-ReleaseLayout -Root $packageExtract $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash Write-Host "Package: $packagePath" Write-Host "SHA-256: $sha256" - } else { - $sha256 = '' + } + + if ($LocalPreflightOnly) { + Write-Step 'Local/controller preflight passed; no network connection or host change was made' + exit 0 } $remoteArguments = New-Object 'System.Collections.Generic.List[string]' $remoteArguments.Add('&') $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) - Add-RemoteArgument -Arguments $remoteArguments -Name '-SiteName' -Value $SiteName + Add-RemoteArgument $remoteArguments '-SiteName' $SiteName + Add-RemoteArgument $remoteArguments '-AppPoolName' $AppPoolName + Add-RemoteArgument $remoteArguments '-DeployRoot' $DeployRoot + Add-RemoteArgument $remoteArguments '-BindingIpAddress' $BindingIpAddress + Add-RemoteArgument $remoteArguments '-BindingPort' $BindingPort.ToString() + Add-RemoteArgument $remoteArguments '-HostHeader' $HostHeader + Add-RemoteArgument $remoteArguments '-KeepReleases' $KeepReleases.ToString() + if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { Add-RemoteArgument $remoteArguments '-InitialWebConfigPath' $InitialWebConfigPath } + if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { Add-RemoteArgument $remoteArguments '-BaseUrl' $BaseUrl } if ($Rollback) { - Add-RemoteArgument -Arguments $remoteArguments -Name '-RollbackTo' -Value $RollbackTo - } elseif (-not $RemotePreflightOnly) { - Add-RemoteArgument -Arguments $remoteArguments -Name '-PackagePath' -Value $remotePackage - Add-RemoteArgument -Arguments $remoteArguments -Name '-ReleaseId' -Value $ReleaseId - Add-RemoteArgument -Arguments $remoteArguments -Name '-ExpectedSha256' -Value $sha256 - } else { + Add-RemoteArgument $remoteArguments '-RollbackTo' $RollbackTo + } elseif ($PreflightOnly) { $remoteArguments.Add('-PreflightOnly') + } else { + Add-RemoteArgument $remoteArguments '-PackagePath' $remotePackage + Add-RemoteArgument $remoteArguments '-ReleaseId' $ReleaseId + Add-RemoteArgument $remoteArguments '-ExpectedSha256' $sha256 } - - if (-not [string]::IsNullOrWhiteSpace($DeployRoot)) { - Add-RemoteArgument -Arguments $remoteArguments -Name '-DeployRoot' -Value $DeployRoot - } - if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { - Add-RemoteArgument -Arguments $remoteArguments -Name '-BaseUrl' -Value $BaseUrl - } - Add-RemoteArgument -Arguments $remoteArguments -Name '-KeepReleases' -Value $KeepReleases.ToString() if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } $remoteScript = $remoteArguments -join ' ' - $remoteBytes = [Text.Encoding]::Unicode.GetBytes($remoteScript) - $remoteEncodedCommand = [Convert]::ToBase64String($remoteBytes) + $remoteEncodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand - Write-Host "Remote target: $RemoteTarget (Tailscale/OpenSSH port $RemotePort)" if ($DryRun) { Write-Step 'Dry-run complete; no network connection was made' - Write-Host "Would create remote directory: $remoteDirectory" - Write-Host "Would copy installer: $installerPath" - if (-not $Rollback -and -not $RemotePreflightOnly) { - Write-Host "Would copy package: $packagePath" + Write-Host "Would use dedicated site '$SiteName' and app pool '$AppPoolName'." + Write-Host "Would use binding ${BindingIpAddress}:$BindingPort with host header '$HostHeader'." + if ($PreflightOnly) { + Write-Host 'Would stream the installer over SSH for a read-only host preflight; no remote file would be written.' + } else { + Write-Host "Would create remote directory: $remoteDirectory" + Write-Host "Would copy installer: $installerPath" + if (-not $Rollback) { Write-Host "Would copy package: $packagePath" } + Write-Host ('Would execute host script: ' + $remoteScript) } - Write-Host ('Would execute host script: ' + $remoteScript) - Write-Host ('Transport command uses PowerShell -EncodedCommand to avoid remote-shell quoting ambiguity.') exit 0 } Assert-Command $SshExe - Assert-Command $ScpExe + if ($PreflightOnly) { + Write-Step 'Streaming the installer for read-only host preflight' + $installerSource = Get-Content -LiteralPath $installerPath -Raw + $argumentTail = @($remoteArguments | Select-Object -Skip 2) -join ' ' + $stdinScript = "& {`r`n" + $installerSource + "`r`n} " + $argumentTail + $stdinScript | & $SshExe -p $RemotePort $RemoteTarget 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -Command -' + if ($LASTEXITCODE -ne 0) { throw "Remote preflight failed with exit code $LASTEXITCODE." } + Write-Step 'Remote host preflight completed successfully without persistent host changes' + exit 0 + } - Write-Step 'Creating remote staging directory over Tailscale/OpenSSH' + Assert-Command $ScpExe + Write-Step 'Creating remote temporary directory' $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null" - $mkdirBytes = [Text.Encoding]::Unicode.GetBytes($mkdirScript) - $mkdirEncodedCommand = [Convert]::ToBase64String($mkdirBytes) - & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncodedCommand) + $mkdirEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($mkdirScript)) + & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncoded) if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' } Write-Step 'Copying the host installer' & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1') if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' } - - if (-not $Rollback -and -not $RemotePreflightOnly) { + if (-not $Rollback) { Write-Step 'Copying the release package' & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } @@ -251,7 +309,6 @@ try { Write-Step 'Invoking the host-side installer' & $SshExe -p $RemotePort $RemoteTarget $remoteCommand if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } - Write-Step 'Remote operation completed successfully' } finally { if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) { @@ -259,7 +316,5 @@ try { Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force Write-Host "Package retained at $keptPath" } - if (Test-Path -LiteralPath $workRoot) { - Remove-Item -LiteralPath $workRoot -Recurse -Force - } + if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force } }