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:
159
scripts/iis-migration-to-ruvds/03-ruvds-winacme.ps1
Normal file
159
scripts/iis-migration-to-ruvds/03-ruvds-winacme.ps1
Normal 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" }
|
||||
}
|
||||
23
scripts/iis-migration-to-ruvds/winacme-Web_Config.xml
Normal file
23
scripts/iis-migration-to-ruvds/winacme-Web_Config.xml
Normal 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>
|
||||
Reference in New Issue
Block a user