feat(iis-migration): win-acme HTTP-01 auto-renewal on RUVDS IIS

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>
This commit is contained in:
2026-06-05 21:08:41 +03:00
parent 9cd6d4e22c
commit 3c7ca3416b
8 changed files with 315 additions and 29 deletions

View File

@@ -0,0 +1,159 @@
# 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" }
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
win-acme HTTP-01 challenge web.config template (overwrites C:\win-acme\Web_Config.xml on RUVDS).
Patched for the MoreThenCms / OWIN catch-all: the .well-known/acme-challenge folder is a
separate IIS application in a "No Managed Code" app pool (acme-challenge). The inherited
catch-all Owin *managed* handler 500s in an unmanaged pool, so we <remove> it; tokens are
served by the native StaticFileModule with an extensionless mimeMap. See task decisions log.
-->
<configuration>
<system.webServer>
<httpRedirect enabled="false" />
<validation validateIntegratedModeConfiguration="false" />
<handlers>
<remove name="Owin" />
</handlers>
<modules runAllManagedModulesForAllRequests="false" />
<staticContent>
<clear />
<mimeMap fileExtension="." mimeType="text/json" />
<mimeMap fileExtension=".*" mimeType="text/json" />
</staticContent>
</system.webServer>
</configuration>