Stood up a permanent self-renewing Let's Encrypt pipeline on the RUVDS IIS host, replacing the manual traefik acme.json -> PFX import and closing the 2026-07-22 cert-expiry deadline (new 25-SAN cert valid to 2026-09-03, SYSTEM scheduled task renews 55 days before expiry). Key obstacle: the MoreThenCms OWIN catch-all (owin:HandleAllRequests) swallowed /.well-known/acme-challenge/. Solved by carving the challenge path into a separate IIS application in a No-Managed-Code app pool, plus patching win-acme's Web_Config.xml template to remove the inherited Owin handler. Staging + prod validation green for all 25 hostnames; live TLS smoke confirms the new cert is served (incl degraded maljarka/rimiz). - scripts/iis-migration-to-ruvds/03-ruvds-winacme.ps1 (idempotent setup) - scripts/iis-migration-to-ruvds/winacme-Web_Config.xml (patched template) - .wiki/concepts/winacme-iis-owin-catchall-http01.md (recipe + gotchas) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
7.7 KiB
PowerShell
160 lines
7.7 KiB
PowerShell
# 03-ruvds-winacme.ps1 — win-acme setup + HTTP-01 challenge-path probe on RUVDS IIS host.
|
|
# Idempotent. Run elevated on RUVDS (80.64.31.36) over SSH.
|
|
# Phase switch: -Phase download|probe|cleanprobe
|
|
param([string]$Phase = 'probe')
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
$dest = 'C:\win-acme'
|
|
$root = 'C:\sites\snolla'
|
|
$chDir = Join-Path $root '.well-known\acme-challenge'
|
|
|
|
function Download-WinAcme {
|
|
if (Test-Path (Join-Path $dest 'wacs.exe')) { Write-Output "wacs already present: $dest\wacs.exe"; return }
|
|
$api = 'https://api.github.com/repos/win-acme/win-acme/releases/latest'
|
|
$rel = Invoke-RestMethod -Uri $api -Headers @{ 'User-Agent' = 'ruvds-setup' }
|
|
$asset = $rel.assets | Where-Object { $_.name -match 'x64\.pluggable\.zip$' } | Select-Object -First 1
|
|
if (-not $asset) { throw "no x64.pluggable.zip asset in $($rel.tag_name)" }
|
|
$zip = Join-Path $env:TEMP $asset.name
|
|
Write-Output "downloading $($rel.tag_name): $($asset.name)"
|
|
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zip -Headers @{ 'User-Agent' = 'ruvds-setup' }
|
|
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
|
Expand-Archive -Path $zip -DestinationPath $dest -Force
|
|
Remove-Item $zip -Force
|
|
Write-Output ("installed: " + (Get-Item (Join-Path $dest 'wacs.exe')).VersionInfo.ProductVersion)
|
|
}
|
|
|
|
function Test-ChallengePath {
|
|
New-Item -ItemType Directory -Force -Path $chDir | Out-Null
|
|
$token = 'acme-probe-' + (Get-Random)
|
|
$tokenFile = Join-Path $chDir $token
|
|
Set-Content -Path $tokenFile -Value $token -NoNewline -Encoding ascii
|
|
Write-Output "token file: $tokenFile (content='$token')"
|
|
|
|
$hosts = 'snolla.com','tandemmebel.ru','maljarka.tandemmebel.ru','rimiz.ru'
|
|
Write-Output "--- WITHOUT challenge web.config (baseline CMS behaviour) ---"
|
|
Probe-Hosts $hosts $token
|
|
|
|
# Mirror the proven /admin escape: remove the catch-all Owin handler so OWIN stops
|
|
# intercepting this path, add extensionless-token mime, drop managed-module overhead.
|
|
$webcfg = @'
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<configuration>
|
|
<system.webServer>
|
|
<handlers>
|
|
<remove name="Owin" />
|
|
</handlers>
|
|
<staticContent>
|
|
<remove fileExtension="." />
|
|
<mimeMap fileExtension="." mimeType="text/plain" />
|
|
</staticContent>
|
|
<modules runAllManagedModulesForAllRequests="false" />
|
|
</system.webServer>
|
|
</configuration>
|
|
'@
|
|
Set-Content -Path (Join-Path $chDir 'web.config') -Value $webcfg -Encoding utf8
|
|
Write-Output "--- WITH challenge web.config (win-acme style) ---"
|
|
Probe-Hosts $hosts $token
|
|
}
|
|
|
|
function Probe-Hosts($hosts, $token) {
|
|
foreach ($h in $hosts) {
|
|
try {
|
|
$r = Invoke-WebRequest "http://localhost/.well-known/acme-challenge/$token" -Headers @{ Host = $h } -UseBasicParsing -TimeoutSec 15 -MaximumRedirection 0
|
|
$ok = ($r.Content.Trim() -eq $token)
|
|
Write-Output (" [{0}] HTTP {1} match={2} body='{3}'" -f $h, $r.StatusCode, $ok, ($r.Content -replace '\s+',' ').Substring(0,[Math]::Min(40,$r.Content.Length)))
|
|
} catch {
|
|
$resp = $_.Exception.Response
|
|
if ($resp) { Write-Output (" [{0}] HTTP {1} loc={2}" -f $h, [int]$resp.StatusCode, $resp.Headers['Location']) }
|
|
else { Write-Output (" [{0}] ERR: {1}" -f $h, $_.Exception.Message) }
|
|
}
|
|
}
|
|
}
|
|
|
|
function Setup-ChallengeApp {
|
|
Import-Module WebAdministration
|
|
$pool = 'acme-challenge'
|
|
if (-not (Test-Path "IIS:\AppPools\$pool")) { New-WebAppPool -Name $pool | Out-Null; Write-Output "created app pool $pool" }
|
|
Set-ItemProperty "IIS:\AppPools\$pool" -Name managedRuntimeVersion -Value '' # No Managed Code
|
|
Set-ItemProperty "IIS:\AppPools\$pool" -Name autoStart -Value $true
|
|
New-Item -ItemType Directory -Force -Path $chDir | Out-Null
|
|
# No-Managed-Code pool + drop the inherited catch-all Owin handler (else it 500s in an
|
|
# unmanaged pool), serve extensionless ACME tokens as text/plain. Verified combo.
|
|
$appcfg = @'
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<configuration>
|
|
<system.webServer>
|
|
<handlers>
|
|
<remove name="Owin" />
|
|
</handlers>
|
|
<staticContent>
|
|
<remove fileExtension="." />
|
|
<mimeMap fileExtension="." mimeType="text/plain" />
|
|
</staticContent>
|
|
<modules runAllManagedModulesForAllRequests="false" />
|
|
<directoryBrowse enabled="false" />
|
|
</system.webServer>
|
|
</configuration>
|
|
'@
|
|
Set-Content -Path (Join-Path $chDir 'web.config') -Value $appcfg -Encoding utf8
|
|
$vpath = '/.well-known/acme-challenge'
|
|
$app = Get-WebApplication -Site 'snolla' | Where-Object { $_.path -eq $vpath }
|
|
if (-not $app) {
|
|
New-WebApplication -Site 'snolla' -Name '.well-known/acme-challenge' -PhysicalPath $chDir -ApplicationPool $pool -Force | Out-Null
|
|
Write-Output "created IIS application $vpath (pool=$pool, No Managed Code)"
|
|
} else {
|
|
Set-ItemProperty "IIS:\Sites\snolla$vpath" -Name applicationPool -Value $pool
|
|
Write-Output "IIS application $vpath exists (pool ensured=$pool)"
|
|
}
|
|
}
|
|
|
|
function Probe-Once {
|
|
New-Item -ItemType Directory -Force -Path $chDir | Out-Null
|
|
$token = 'acme-probe-' + (Get-Random)
|
|
Set-Content -Path (Join-Path $chDir $token) -Value $token -NoNewline -Encoding ascii
|
|
Write-Output "probe token: $token"
|
|
Probe-Hosts ('snolla.com','tandemmebel.ru','www.tandemmebel.ru','maljarka.tandemmebel.ru','rimiz.ru','kupimknigi.spb.ru') $token
|
|
}
|
|
|
|
function Clean-Probe {
|
|
if (Test-Path $chDir) { Get-ChildItem $chDir -Filter 'acme-probe-*' | Remove-Item -Force -EA SilentlyContinue; Write-Output "removed probe tokens" }
|
|
}
|
|
|
|
function Setup-RenewTask {
|
|
$wacs = 'C:\win-acme\wacs.exe'
|
|
$taskName = 'win-acme-renew-snolla'
|
|
$action = New-ScheduledTaskAction -Execute $wacs -Argument '--renew --baseuri https://acme-v02.api.letsencrypt.org/'
|
|
$trigger = New-ScheduledTaskTrigger -Daily -At 9am
|
|
try { $trigger.RandomDelay = 'PT4H' } catch {}
|
|
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
|
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 2)
|
|
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description 'Auto-renew Let''s Encrypt cert for IIS site snolla (25 SNI bindings) via HTTP-01.' -Force | Out-Null
|
|
$t = Get-ScheduledTask -TaskName $taskName
|
|
$info = $t | Get-ScheduledTaskInfo
|
|
Write-Output ("task '{0}' state={1} nextRun={2}" -f $taskName, $t.State, $info.NextRunTime)
|
|
|
|
# remove the staging renewal so a stray --renew against staging does nothing
|
|
$stagingRenewals = 'C:\ProgramData\win-acme\acme-staging-v02.api.letsencrypt.org\Renewals'
|
|
if (Test-Path $stagingRenewals) { Remove-Item "$stagingRenewals\*" -Recurse -Force -EA SilentlyContinue; Write-Output "cleared staging renewals" }
|
|
if (Test-Path 'C:\win-acme\stagingtest') { Remove-Item 'C:\win-acme\stagingtest' -Recurse -Force -EA SilentlyContinue; Write-Output "removed stagingtest pemfiles" }
|
|
}
|
|
|
|
function Verify-Certs {
|
|
Import-Module WebAdministration
|
|
# show the cert now bound (thumbprint -> subject/expiry)
|
|
$b = Get-WebBinding -Port 443 | Select-Object -First 1
|
|
$hash = (Get-Item "IIS:\SslBindings\*!443!*" -EA SilentlyContinue | Select-Object -First 1).Thumbprint
|
|
Get-ChildItem Cert:\LocalMachine\WebHosting | Sort-Object NotAfter -Descending | Select-Object -First 3 |
|
|
ForEach-Object { Write-Output ("WebHosting cert: {0} | NotAfter={1} | Issuer={2} | SAN-count via DnsNameList={3}" -f $_.Thumbprint, $_.NotAfter, $_.Issuer, $_.DnsNameList.Count) }
|
|
}
|
|
|
|
switch ($Phase) {
|
|
'download' { Download-WinAcme }
|
|
'probe' { Download-WinAcme; Test-ChallengePath }
|
|
'app' { Download-WinAcme; Setup-ChallengeApp; Probe-Once }
|
|
'task' { Setup-RenewTask; Verify-Certs }
|
|
'verify' { Verify-Certs }
|
|
'cleanprobe' { Clean-Probe }
|
|
default { throw "unknown phase $Phase" }
|
|
}
|