Ein abgeschotteter Serverschrank in einem dunklen Technikraum, davor eine einzelne Netzwerkleitung die nach aussen fuehrt, kuehles blaues Licht, keine Menschen, sachliche Technikfotografie
Uncategorized 12 Min Read

Ein Exchange, der nicht ins Internet darf — und trotzdem ein Let’s-Encrypt-Zertifikat bekommt

Vier Wochen vor Ablauf fiel mir auf, dass das Zertifikat eines Exchange 2016 an vier Diensten gleichzeitig hängt: IIS, SMTP, IMAP und POP. Läuft es ab, gehen nicht „ein paar…

Audio-Guide KI-generierter Beitrag zum Thema

Vier Wochen vor Ablauf fiel mir auf, dass das Zertifikat eines Exchange 2016 an vier Diensten gleichzeitig hängt: IIS, SMTP, IMAP und POP. Läuft es ab, gehen nicht „ein paar Sachen” nicht mehr. Es gehen Outlook, Autodiscover, ActiveSync, OWA, der Mailversand und die Postfachabholung aus. Alle auf einmal, an einem Dienstagmorgen.

Das Zertifikat war ein gekauftes. Der übliche Weg wäre: neues kaufen, einspielen, in dreizehn Monaten wieder. Rund 230 Euro im Jahr für einen Vorgang, den niemand im Kalender hat und der genau deshalb irgendwann vergessen wird.

Let’s Encrypt kostet nichts und erneuert sich selbst. Es gibt nur ein Problem.

Der Server darf nicht ins Internet

Der Exchange ist aus dem Netz nicht erreichbar. Kein Port 80, kein Port 443. Das ist kein Versehen, sondern Absicht — Exchange 2016 bekommt seit Oktober 2025 keine Sicherheitsupdates mehr, und ein nicht erreichbarer Server ist ein schlecht angreifbarer Server.

Damit fällt der Standardweg weg. Die HTTP-Challenge von Let’s Encrypt funktioniert so, dass die Zertifizierungsstelle eine Datei auf deinem Webserver abruft. Kann sie ihn nicht erreichen, bekommst du kein Zertifikat.

Bleibt DNS-01. Statt einer Datei auf dem Webserver legt der Client einen TXT-Eintrag im DNS ab, und Let’s Encrypt fragt den ab. Der Server selbst muss dafür von außen überhaupt nicht erreichbar sein — er muss nur selbst nach draußen telefonieren können.

Das ist der Punkt, an dem viele Anleitungen aufhören und die interessanten Details anfangen.

Vorher messen, nicht raten

Zwei Verbindungen entscheiden, ob der Weg überhaupt trägt. Beide vom Exchange aus:

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

Test-NetConnection acme-v02.api.letsencrypt.org -Port 443 |
    Select-Object ComputerName, TcpTestSucceeded
Test-NetConnection api.domrobot.com -Port 443 |
    Select-Object ComputerName, TcpTestSucceeded

Die erste ist Let’s Encrypt, die zweite die API meines DNS-Anbieters INWX. Kommen beide mit True zurück, funktioniert die Automatik. Kommt eine mit False, spar dir den Rest und klär erst die Firewall.

Die TLS-Zeile davor ist kein Schmuck. Auf Windows Server 2016 spricht PowerShell sonst TLS 1.0 und scheitert schon beim Installieren des Moduls.

Und noch etwas, bevor es losgeht — den Fingerabdruck des alten Zertifikats notieren:

Get-ExchangeCertificate | Format-List Thumbprint, Services, Subject, NotAfter

Das ist dein Rückweg. Den willst du nicht suchen müssen, während OWA nicht geht.

Ein Konto, das fast nichts darf

Für DNS-01 braucht der Client Schreibzugriff auf die DNS-Zone. Das ist der Teil, bei dem man kurz nachdenken sollte, denn wer DNS ändern kann, kann eine Menge.

Bei INWX lege ich dafür einen Unterbenutzer an und gebe ihm ausschließlich die Rolle „DNS-Verwaltung”. Nicht vergeben werden: Vollzugriff, Abrechnung, Domainverwaltung, Hosting — und vor allem nicht Auth-Code. Das ist die Rolle, mit der man Transfer-Codes ziehen und die Domain wegnehmen könnte.

Wenn diese Zugangsdaten je abhandenkommen, kann jemand DNS-Einträge ändern. Mehr nicht. Für ein Maschinenkonto ist das die richtige Größe.

Zwei-Faktor kann und sollte man einschalten. Posh-ACME kommt damit klar, es braucht nur das 32-stellige Geheimnis aus der Einrichtung — nicht den sechsstelligen Code, sondern das Geheimnis dahinter. Notier es beim Einrichten, danach ist es nicht mehr auslesbar.

Der eigentliche Aufruf

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Install-Module -Name Posh-ACME -Scope AllUsers -Force
Import-Module Posh-ACME
Set-PAServer LE_STAGE

$pArgs = @{
    INWXUsername     = 'acme-benutzer'
    INWXPassword     = ConvertTo-SecureString 'PASSWORT'  -AsPlainText -Force
    INWXSharedSecret = ConvertTo-SecureString 'GEHEIMNIS' -AsPlainText -Force
}

New-PACertificate `
  -Domain 'mail.beispiel.de','owa.beispiel.de','autodiscover.beispiel.de',
          'imap.beispiel.de','smtp.beispiel.de' `
  -Plugin INWX -PluginArgs $pArgs `
  -CertKeyLength 2048 -DnsSleep 300 `
  -AcceptTOS -Contact 'postmaster@beispiel.de' `
  -Verbose

LE_STAGE ist die Testumgebung. Der erste Lauf gehört immer dorthin — er kostet keine Kontingente und beantwortet die einzige offene Frage: Kommen alle Challenges durch die API?

Zwei Parameter verdienen eine Erklärung.

-CertKeyLength statt -KeyLength. In Posh-ACME 3.x hieß er -KeyLength, in 4.x wurde er umbenannt. Fast jede Anleitung im Netz ist älter und nennt den alten Namen. Man bekommt dann eine Fehlermeldung, die den eigentlichen Punkt gut versteckt: „Es wurde kein Parameter gefunden, der dem Parameternamen KeyLength entspricht.”

Bei RSA bleiben, nicht auf ECDSA wechseln. Exchange 2016 will RSA, und einzelne Komponenten wie OAuth brauchen es zwingend.

-DnsSleep 300 statt der Standard-120. Mein DNS-Anbieter veröffentlicht Zonenänderungen verzögert. Im Verwaltungspanel steht die neue Seriennummer sofort, auf den Nameservern ein paar Minuten später. Prüft Let’s Encrypt in diesem Fenster, findet es den alten Stand und die Validierung scheitert. Fünf Minuten sind großzügig; in der Testumgebung kostet Warten nichts.

Wer nachsehen will, ob die Einträge wirklich draußen sind, kann parallel abfragen:

dig +short _acme-challenge.mail.beispiel.de TXT @ns.inwx.de

Umlaute gehen nicht

Eine Domain mit Umlaut muss als Punycode in den Antrag. hörsturz.de wird zu xn--hrsturz-90a.de. ACME akzeptiert keine Unicode-Namen und lehnt die ganze Bestellung ab:

urn:ietf:params:acme:error:rejectedIdentifier
Invalid identifiers requested

Die Meldung nennt den Übeltäter nicht immer. Wenn du mehrere Namen im Antrag hast und einer davon einen Umlaut trägt, weißt du jetzt, wo du suchst.

Der Teil, den ich unterschätzt habe

Der Staging-Lauf lief durch, das Produktivzertifikat war da, importiert, an IIS und SMTP gebunden:

$c = Get-PACertificate

Import-ExchangeCertificate `
    -FileData ([byte[]](Get-Content $c.PfxFullChain -Encoding Byte)) `
    -Password $c.PfxPass -PrivateKeyExportable $true

Enable-ExchangeCertificate -Thumbprint $c.Thumbprint -Services IIS,SMTP

Kleine Falle nebenbei: $c.PfxPass ist bereits ein SecureString. Wer da noch ein ConvertTo-SecureString drumherum baut, weil man das so kennt, bekommt einen Fehler.

Danach zeigte Get-ExchangeCertificate beim neuen Zertifikat brav Services: IMAP, POP, IIS, SMTP. Sieht aus wie fertig.

War es nicht.

netsh http show sslcert | Select-String "IP:Port|Zertifikathash"
IP:Port          : 0.0.0.0:443
Zertifikathash   : 73f48355...        <- das ALTE Zertifikat
IP:Port          : 127.0.0.1:443
Zertifikathash   : 73f48355...
IP:Port          : [::]:443
Zertifikathash   : 73f48355...

Enable-ExchangeCertificate hatte die Exchange-Metadaten gesetzt, aber nicht die tatsächliche Bindung in HTTP.SYS. Und HTTP.SYS entscheidet, welches Zertifikat ausgeliefert wird. Die Services-Spalte hätte mich in falscher Sicherheit gewiegt, wäre ich nicht nachgegangen.

Ursache war ein abgebrochener iisreset. Der lief in einen Timeout:

Fehler beim Versuch, den Dienst neu zu starten. (2147943453, 8007041d)

Bei Exchange ist das eher Regel als Ausnahme — die Anwendungspools brauchen länger, als iisreset wartet. Der Dienst kommt oft trotzdem hoch, hier blieb er auf halbem Weg stehen. Zurechtgerückt hat es:

Get-Service W3SVC, WAS | Select-Object Name, Status
Start-Service WAS, W3SVC

WAS zuerst, W3SVC hängt davon ab.

Der zweite Enable-ExchangeCertificate-Lauf setzte dann IPv4 — und IPv6 nicht. Das musste von Hand nachgezogen werden:

netsh http show sslcert ipport="[::]:443"      # Application-ID ablesen

netsh http delete sslcert ipport="[::]:443"
netsh http add sslcert ipport="[::]:443" `
  certhash=<neuer-thumbprint> `
  appid="{4dc3e181-e14b-4a21-b022-59fc669b0914}" `
  certstorename=MY

Die Anführungszeichen um ipport sind nötig, sonst versucht PowerShell die eckigen Klammern zu interpretieren. Die Application-ID ist die Standard-IIS-GUID.

Hätte ich IPv6 übersehen, wäre nach Ablauf des alten Zertifikats nur für IPv6-Clients alles kaputtgegangen. Für die anderen hätte es funktioniert. Solche Fehler sucht man tagelang.

Prüfen, nicht glauben

Ein Selbsttest vom Server aus scheitert übrigens gern an Nebensächlichkeiten. https://localhost/owa bricht ab, weil der Client „localhost” als gewünschten Hostnamen meldet und IIS für diesen Namen keine Bindung hat. Und der eigene öffentliche Name löst intern womöglich auf das Gateway auf, das die Anfrage nicht zurückreicht.

Beides sagt nichts über das Zertifikat. Für die Prüfung taugt netsh http show sslcert — oder ein echter Client im Netz:

openssl s_client -connect 192.168.x.x:443 -servername mail.beispiel.de </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -fingerprint -sha1

Der Fingerabdruck muss dem Thumbprint aus Exchange entsprechen. Erst dann ist es bewiesen.

Automatisch erneuern heißt: auch automatisch scharfschalten

Hier trennt sich brauchbar von halbfertig. Posh-ACME erneuert das Zertifikat — als Datei auf der Platte. Dass Exchange es danach benutzt, macht Posh-ACME nicht.

Wer nur Submit-Renewal in eine geplante Aufgabe legt, hat in drei Monaten ein frisches Zertifikat im Dateisystem und ein abgelaufenes im Betrieb.

Das Skript muss also die ganze Kette gehen, inklusive der Bindungen, die Enable-ExchangeCertificate nicht zuverlässig setzt:

$ErrorActionPreference = 'Stop'
$appid = '{4dc3e181-e14b-4a21-b022-59fc669b0914}'
$ports = @('0.0.0.0:443','127.0.0.1:443','[::]:443')

Add-PSSnapin Microsoft.Exchange.Management.PowerShell.SnapIn
Import-Module Posh-ACME
Set-PAServer LE_PROD
Set-PAOrder mail.beispiel.de

$alt = (Get-PACertificate).Thumbprint
Submit-Renewal
$c = Get-PACertificate

if ($c.Thumbprint -ne $alt) {
    Import-ExchangeCertificate `
        -FileData ([byte[]](Get-Content $c.PfxFullChain -Encoding Byte)) `
        -Password $c.PfxPass -PrivateKeyExportable $true

    Enable-ExchangeCertificate -Thumbprint $c.Thumbprint -Services IIS,SMTP -Force

    $hash = $c.Thumbprint.ToLower()
    foreach ($p in $ports) {
        netsh http delete sslcert "ipport=$p" | Out-Null
        netsh http add sslcert "ipport=$p" "certhash=$hash" "appid=$appid" certstorename=MY | Out-Null
    }

    # verifizieren, sonst hat man nichts gewonnen
    foreach ($p in $ports) {
        $out = netsh http show sslcert "ipport=$p" | Out-String
        if ($out -notmatch [regex]::Escape($hash)) { throw "Bindung fehlt: $p" }
    }
}

Das -Force bei Enable-ExchangeCertificate ist wichtig. Ohne fragt Exchange bei SMTP nach, ob das Standardzertifikat überschrieben werden soll — und eine geplante Aufgabe wartet dann ewig auf eine Antwort, die niemand sieht.

Submit-Renewal prüft selbst, ob das Erneuerungsfenster erreicht ist. Man kann das Skript also täglich laufen lassen, es tut an 60 von 90 Tagen nichts.

Das Passwort, das keines ist

Die geplante Aufgabe braucht ein Konto. Und hier steckt der Fehler, der solche Automatiken am häufigsten still umbringt: Man hinterlegt sein eigenes Passwort, und beim nächsten turnusmäßigen Wechsel hört die Aufgabe auf zu laufen. Ohne Meldung. Man merkt es Monate später.

Der Ausweg ist, gar kein Passwort zu hinterlegen und als SYSTEM zu laufen:

$prc = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Set-ScheduledTask -TaskName 'ACME Zertifikat' -Principal $prc

Zwei Dinge müssen dafür stimmen.

Erstens: Posh-ACME darf nicht im Benutzerprofil liegen. Standardmäßig landet die Konfiguration unter C:\Users\<du>\AppData\Local\Posh-ACME. Läuft die Aufgabe unter einem anderen Konto, findet sie dort nichts, legt still eine neue Bestellung an — und irgendwann läuft man in die Mengenbegrenzung von Let’s Encrypt.

Copy-Item "$env:LOCALAPPDATA\Posh-ACME" 'C:\ProgramData\Posh-ACME' -Recurse
[Environment]::SetEnvironmentVariable('POSHACME_HOME','C:\ProgramData\Posh-ACME','Machine')
icacls C:\ProgramData\Posh-ACME /inheritance:r /grant "SYSTEM:(OI)(CI)F" /grant "Administratoren:(OI)(CI)F"

Dort liegen der ACME-Kontoschlüssel und die PFX-Passwörter. Die Rechte sind kein Beiwerk.

Zweitens: SYSTEM braucht eine Exchange-Berechtigung. Und das geht nicht direkt, weil SYSTEM als Computerkonto auftritt und Exchange-Rollen sich Computerkonten nicht zuweisen lassen. Der Versuch quittiert mit „Es wurde kein Benutzer mit der Identität … gefunden”. Über eine Sicherheitsgruppe geht es:

New-ADGroup -Name 'Exchange Cert Automation' -GroupScope Universal -GroupCategory Security
Add-ADGroupMember -Identity 'Exchange Cert Automation' -Members 'SERVERNAME$'
New-ManagementRoleAssignment -Role 'Exchange Server Certificates' `
    -SecurityGroup 'Exchange Cert Automation'

Die Rolle „Exchange Server Certificates” reicht. Organization Management braucht es nicht — und je weniger Rechte ein Automatismus hat, desto besser schläft man.

Beim Anlegen der Aufgabe noch eine Stolperstelle: -Principal und -Password gehören zu verschiedenen Parametersätzen von Register-ScheduledTask und lassen sich nicht kombinieren. Entweder -User plus -Password, oder -Principal ganz ohne. Wer beides angibt, bekommt „Der Parametersatz kann mit den angegebenen benannten Parametern nicht aufgelöst werden”.

Was IMAP und POP anders machen

Eine Eigenheit, die man kennen sollte, wenn diese Dienste laufen: IIS und SMTP binden über den Fingerabdruck, IMAP und POP über den Namen.

Get-ImapSettings | Format-List X509CertificateName

Steht dort mail.beispiel.de, dann kommt jedes Zertifikat in Frage, das auf diesen Namen passt. In der Übersicht zeigen dann mehrere Zertifikate Services: IMAP, POP — das ist keine Bindung, das ist eine Kandidatenliste.

Bei mir lagen dort drei: das alte gekaufte, das neue von Let’s Encrypt, und ein selbstsigniertes mit Laufzeit bis 2030. Beim nächsten Neustart des IMAP-Dienstes wählt Exchange darunter neu aus. Fällt die Wahl auf das selbstsignierte, bekommt jeder Client eine Warnung — und weil IMAP dort auf erzwungenes TLS steht, lässt sie sich nicht wegklicken.

Alte Zertifikate aufzuräumen ist deshalb keine Kosmetik. Solange mehrere auf denselben Namen passen, ist das Ergebnis nach jedem Reboot Zufall.

Und wenn die Automatik selbst stirbt?

Der unangenehmste Fehlerfall ist nicht die fehlgeschlagene Erneuerung. Die meldet sich. Der unangenehme Fall ist die Aufgabe, die irgendwann gar nicht mehr läuft — dann kommt auch keine Fehlermeldung, und Stille sieht aus wie Erfolg.

Dagegen hilft eine Prüfung von außen, die nichts vom Server weiß. Jedes Let’s-Encrypt-Zertifikat landet öffentlich in den Certificate-Transparency-Logs. Man kann also schlicht nachsehen, ob überhaupt ein frisches ausgestellt wurde:

curl -s "https://api.certspotter.com/v1/issuances?domain=beispiel.de&include_subdomains=true&expand=dns_names" \
  | python3 -c "
import sys,json,datetime
c=[x for x in json.load(sys.stdin) if 'mail.beispiel.de' in x.get('dns_names',[])]
n=max(c,key=lambda x:x['not_after'])
rest=(datetime.datetime.fromisoformat(n['not_after'].replace('Z','+00:00'))
      -datetime.datetime.now(datetime.timezone.utc)).days
print(f'neuestes Zertifikat laeuft in {rest} Tagen ab')
sys.exit(1 if rest < 20 else 0)"

Unter zwanzig Tagen Restlaufzeit heißt: Es ist nichts passiert. Das läuft auf irgendeinem anderen Rechner, unabhängig davon, ob auf dem Exchange noch etwas funktioniert.

Was übrig bleibt

Der ganze Umbau hat einen Nachmittag gekostet, davon die Hälfte für die zwei Fallen mit den HTTP.SYS-Bindungen. Das Ergebnis: ein Zertifikat, das sich alle drei Monate selbst erneuert, sich selbst scharfschaltet und per Push meldet, wenn etwas schiefgeht. Ohne hinterlegtes Passwort, das ablaufen könnte.

Die 230 Euro im Jahr sind dabei der kleinere Gewinn. Der größere ist, dass niemand mehr daran denken muss.

Und die Lehre, die über diesen einen Server hinausgeht: Bei Zertifikaten reicht es nicht, dass ein Kommando ohne Fehler zurückkommt. Enable-ExchangeCertificate meldete Erfolg und hatte die Bindung nicht gesetzt. Erst die Gegenprobe an der Stelle, die wirklich ausliefert, ist ein Beweis. Alles davor ist eine Vermutung.