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>
127 lines
7.7 KiB
Markdown
127 lines
7.7 KiB
Markdown
---
|
||
title: Экспорт LE certs из traefik acme.json в IIS (PFX + SNI bindings)
|
||
type: concept
|
||
tags: [traefik, iis, letsencrypt, certs, pfx, sni, windows]
|
||
sources: [../sources/iis-migration-to-ruvds-2026-05-23.md]
|
||
updated: 2026-05-24
|
||
---
|
||
|
||
# traefik `acme.json` → IIS cert import
|
||
|
||
Recipe для one-shot переноса Let's Encrypt certs из traefik `acme.json` (single-file ACME store) в IIS на Windows host с SNI multi-binding'ами. Использован при миграции [[../entities/ruvds-iis-host]] 2026-05-23 для 14 LE certs → 25 HTTPS hostnames bound via SNI. **Это не renewal pipeline**, а bootstrap-перенос — для long-term renewal recommend `win-acme` standalone на IIS host'е (см. footnote).
|
||
|
||
> **Update 2026-06-05 — SUPERSEDED для renewal'а.** Этот ручной PFX-перенос больше не на критическом пути: на RUVDS поднят постоянный self-renewing win-acme HTTP-01 pipeline — см. [[winacme-iis-owin-catchall-http01]]. RUVDS больше не зависит от домашнего traefik по сертификатам. Этот recipe оставлен как reference для bootstrap-сценария / если HTTP-01 недоступен.
|
||
|
||
## Когда применять
|
||
|
||
- Migration: traefik-on-A → IIS-on-B, certs нужны до того как ACME-validation с нового host'а станет возможной (DNS ещё указывает на старый host, HTTP-01 challenge bounce'нется).
|
||
- Bootstrap пилотного IIS-сервера с готовыми certs для smoke testing **до** DNS swap'а.
|
||
|
||
**Не применять для regular renewal** — каждый renewal цикл traefik будет генерировать новый cert, и manual re-export не масштабируется. После full DNS swap → `win-acme` HTTP-01 на IIS-side directly.
|
||
|
||
## Входные данные
|
||
|
||
- `acme.json` — обычно в `<traefik-data>/letsencrypt/acme.json`. Содержит JSON с массивом `Certificates[].domain.main`, `.sans`, `.certificate` (base64 PEM bundle), `.key` (base64 PEM private key).
|
||
- Target IIS host с PowerShell + OpenSSL (для cert conversion) + access to `Cert:\LocalMachine\My` store.
|
||
|
||
## Recipe (per cert)
|
||
|
||
### 1. Распаковать пару PEM из acme.json
|
||
|
||
```powershell
|
||
$acme = Get-Content C:\path\to\acme.json | ConvertFrom-Json
|
||
# Resolver key — обычно "letsencrypt" или имя из traefik.yml
|
||
$resolver = $acme.letsencrypt
|
||
foreach ($cert in $resolver.Certificates) {
|
||
$domain = $cert.domain.main
|
||
$sans = $cert.domain.sans # может быть $null
|
||
$safeName = $domain -replace '\*','wildcard'
|
||
|
||
[System.Convert]::FromBase64String($cert.certificate) | Set-Content "C:\temp\$safeName.crt" -AsByteStream
|
||
[System.Convert]::FromBase64String($cert.key) | Set-Content "C:\temp\$safeName.key" -AsByteStream
|
||
}
|
||
```
|
||
|
||
### 2. Convert PEM pair → PFX (OpenSSL)
|
||
|
||
```powershell
|
||
$pfxPass = 'pfximport' # temp passphrase, only for Import step
|
||
& openssl pkcs12 -export `
|
||
-inkey "C:\temp\$safeName.key" `
|
||
-in "C:\temp\$safeName.crt" `
|
||
-out "C:\temp\$safeName.pfx" `
|
||
-name $domain `
|
||
-password "pass:$pfxPass"
|
||
```
|
||
|
||
### 3. Import PFX → `Cert:\LocalMachine\My`
|
||
|
||
```powershell
|
||
$securePass = ConvertTo-SecureString $pfxPass -AsPlainText -Force
|
||
$imported = Import-PfxCertificate `
|
||
-FilePath "C:\temp\$safeName.pfx" `
|
||
-CertStoreLocation 'Cert:\LocalMachine\My' `
|
||
-Password $securePass
|
||
# $imported.Thumbprint — нужен для следующего шага
|
||
```
|
||
|
||
### 4. SNI binding в IIS (per hostname)
|
||
|
||
Один cert может покрывать несколько SANs — для каждого hostname создать **separate binding** с **SslFlags=1 (SNI)**:
|
||
|
||
```powershell
|
||
$allHosts = @($domain) + @($sans | Where-Object { $_ })
|
||
foreach ($hostname in $allHosts) {
|
||
# 4a. Create binding without cert
|
||
New-WebBinding -Name 'snolla' `
|
||
-IPAddress '*' -Port 443 -Protocol 'https' `
|
||
-HostHeader $hostname -SslFlags 1 # 1 = SNI
|
||
|
||
# 4b. Attach cert via thumbprint (нужен netsh-стиль через IIS:\)
|
||
$binding = Get-WebBinding -Name 'snolla' -Port 443 -HostHeader $hostname
|
||
$binding.AddSslCertificate($imported.Thumbprint, 'My')
|
||
}
|
||
```
|
||
|
||
> **SslFlags=1 обязателен.** Без SNI один port 443 = один cert per IP — Windows откажется bind'ить второй cert на тот же `*:443`. С SNI можно навесить ~unlimited hostnames на один `*:443`.
|
||
|
||
## Verification
|
||
|
||
```powershell
|
||
# Список bindings:
|
||
Get-WebBinding -Port 443 | Format-Table protocol, bindingInformation, sslFlags
|
||
|
||
# Сертификат за binding:
|
||
$binding.attributes['certificateHash'].Value # thumbprint
|
||
$binding.attributes['certificateStoreName'].Value # "My"
|
||
|
||
# External smoke (с другого host'а — home middlebox может mangle Host header):
|
||
curl -k --resolve "$hostname:443:80.64.31.36" "https://$hostname/" -I
|
||
# Должен вернуть chain LE R13 + correct Subject CN
|
||
```
|
||
|
||
HTTP/2 negotiate'ится auto (IIS 10 + Windows Server 2019+ → ALPN supported из коробки).
|
||
|
||
## Gotchas
|
||
|
||
- **`acme.json` permission** на Linux: `chmod 600`, traefik не запустится с другим. На Windows copy-out для conversion'а — нужны Admin perms.
|
||
- **PEM bundle order** в acme.json: `certificate` поле содержит leaf + chain (Let's Encrypt R13 + ISRG Root X1). OpenSSL pkcs12 импортит весь bundle корректно — IIS отдаст полный chain.
|
||
- **PFX password** — только для transit между PEM и cert store, после import не используется. `pfximport` (или любой placeholder) — OK; не путать с pass для encrypted PFX-on-disk storage.
|
||
- **HostHeader case-sensitivity** в `New-WebBinding`: IIS lowercase'ит автоматически, но скрипт принимай canonical lowercase.
|
||
- **Wildcard SANs:** если cert `*.example.com` — нужно отдельный binding для каждого конкретного subdomain'а (Windows не делает wildcard matching на binding'ах автоматически). Либо использовать default-cert на listener'е (`netsh http add sslcert`) — но это break'нет multi-cert SNI scenario.
|
||
- **Cert duplicates:** повторный import того же cert'а создаёт duplicate в `Cert:\LocalMachine\My`. Idempotency — check thumbprint exists перед import'ом.
|
||
- **`netsh http show sslcert`** — для low-level audit что binding'и реально привязаны (PowerShell `Get-WebBinding` иногда показывает stale state после ручных правок IIS Manager'ом).
|
||
|
||
## Limits / когда recipe мал
|
||
|
||
- **>30 hostnames** — `Import-PfxCertificate` + `New-WebBinding` цикл становится медленным (~5 sec/cert). Batch'ить через `appcmd add binding /commit:apphost`.
|
||
- **TLS 1.3** — IIS 10 на Server 2022+ supports; на Server 2019 — Edge case. Не зависит от recipe — от Windows feature.
|
||
- **Renewal** — этот recipe **не покрывает**. Variant A (win-acme HTTP-01 standalone) предпочтительнее, см. [[../sources/iis-migration-to-ruvds-2026-05-23]] Outstanding (post-soak).
|
||
|
||
## Cross-refs
|
||
|
||
- Use case: [[../sources/iis-migration-to-ruvds-2026-05-23]] Phase 6
|
||
- Source acme.json расположение: traefik-on-windows-host setup, см. [[traefik-on-windows-docker-desktop]]
|
||
- Long-term replacement: `win-acme` (wacs.exe) standalone — пока не documented as concept; reference https://www.win-acme.com/
|
||
- Bootstrap-host где recipe был применён: [[../entities/ruvds-iis-host]]
|