Exchange Extended Protection und HAProxy

Ein Leser, welcher anonym bleiben möchte, hat mir freundlicherweise seine Scripte und Konfiguration für Let’s Encrypt, HAProxy und Exchange 2019 in Verbindung mit Extended Protection geschickt, damit ich sie hier veröffentlichen kann. Erst einmal möchte ich mich dafür ganz herzlich bedanken, denn ich glaube diese Konfiguration (HAProxy als Reverse Proxy für Exchange, Zertifikate via Let’s Encrypt) dürfte sich häufiger finden lassen.

Damit die Windows Extended Protection funktioniert, müssen die gleichen Zertifikate auf allen Exchange Servern und Loadbalancer und Reverse Proxys genutzt werden. Dabei reicht es nicht aus, dass die Zertifikate die gleichen DNS Namen enthalten, sondern es muss sich um das gleiche Zertifikat handeln. Wer also einen Reverse Proxy wie HAProxy einsetzt und dort ein Zertifikat per Let’s Encrypt automatisch anfordert und konfiguriert, muss dieses Zertifikat auch für die Exchange Server einsetzen.

Der freundliche Leser hat dazu mehrere kleine Scripte erstellt. Bei dem ersten Script handelt es sich um eine angepasste Konfiguration für Certbot. Certbot erneuert damit das Zertifikat um 4:00 Uhr nachts. Das Script bzw. die Konfiguration wird in der Datei /etc/systemd/system/certbot.timer.d/override.conf gespeichert:

[Timer]
OnCalendar=
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=900

Das nächste Script unter /etc/letsencrypt/renewal-hooks/deploy/restart_services sorgt dafür das ein Script mit dem Namen copy_cert.sh aufgerufen wird, welches das Let’s Encrypt Zertifikat in das PKCS12 Format konvertiert und auf einer Freigabe abspeichert:

#! /bin/bash

for DOMAIN in $RENEWED_DOMAINS; do
    case $DOMAIN in
    exchange.mycompany.de)
        /var/local/ExchangeCert/copy_cert.sh
        ;;
    esac
done

Das Script /var/local/ExchangeCert/copy_cert.sh konvertiert das Zertifikat in das PKCS12 Format (PFX-Datei) und kopiert es auf eine Freigabe, sodass es später auf den Exchange Servern importiert werden kann:

#! /bin/bash
#
# Konvertiert das von Let's Encrypt erzeugte Zertifikat für die Verwendung beim Exchange-Server und
# speichert es zusammen mit einer Timestamp-Datei zwecks Austausch auf einer Netzwerk-Freigabe.

LE_PATH="/etc/letsencrypt/live/exchange.mycompany.de"
MOUNT_TARGET="//fileserver/Dokumente/ExchangeCert"
MOUNTPOINT="/mnt/exchangecert"
PFX="${MOUNTPOINT}/exchange_cert.pfx"


# Verzeichnis dieses Skripts ermitteln
SCRIPT_PATH=$(dirname $(realpath $0))

# Netzwerk-Freigabe mounten
test -d ${MOUNTPOINT} || mkdir ${MOUNTPOINT}
mountpoint -q ${MOUNTPOINT} || mount -t cifs ${MOUNT_TARGET} ${MOUNTPOINT} -o "credentials=${SCRIPT_PATH}/smbcredentials"

# PEM nach PFX konvertieren.
# Für den Import wird zwingend ein Passwort erfordert, was hier entsprechend berücksichtigt wird.
openssl pkcs12 -inkey ${LE_PATH}/privkey.pem -in ${LE_PATH}/cert.pem -certfile ${LE_PATH}/chain.pem -export -out ${PFX} -name "$(date +'%Y-%m-%d') Letsencrypt vom Proxy" -passout pass:dummy

# Timestamp in Datei speichern
echo "$(date +'%Y-%m-%d %H:%M:%SZ')" > ${MOUNTPOINT}/timestamp_certcopied.txt

Die Zugangsdaten für die Freigabe werden in der Datei /var/local/ExchangeCert/smbcredentials gespeichert. Diese Datei hat den folgenden Inhalt:

username=
password=
domain=

Auf den Exchange Servern wird ein PowerShell Script verwendet, welches das Zertifikat von der Freigabe in den Exchange Server importiert. Dieses Script wird per geplanten Task jeden Tag nach 04:00 Uhr ausgeführt:

# Mit Aktivierung der "Exchange Extended Protection" (wie von Microsoft empfohlen) ist es notwendig,
# dass auf dem Exchange-Server das gleiche Zertifikat wie auf dem Server eingerichtet ist, der die
# Verbindungen aus dem Internet annimmt. Ansonsten würde Outlook immer wieder nach dem Passwort des
# Benutzers fragen.
#
# Dieses Skript prüft, ob das vom Proxy bereitgestellte Zertifikat (bzw. die erzeugte Timestamp-Datei)
# aktueller als das bisherige ist und installiert/aktiviert dieses dann ggf. für den Exchange-Server.
# Hierzu muss dieses Skript zu den Zeitpunkten, wo eine Aktualisierung beim Proxy erfolgen kann,
# regelmäßig über einen Task gestartet werden, damit nach der Erneuerung beim Proxy und der Erneuerung
# beim Exchange-Server möglichst wenig Zeit vergeht (da die User während dieser Zeit aufgrund der
# Extended Protection nicht mehr auf den Exchange-Server zugreifen können).


# Variablen belegen
$Source_Dir = "\\fileserver\Dokumente\ExchangeCert"
$CertFile = "$Source_Dir\exchange_cert.pfx"
$TimestampFile = "$Source_Dir\timestamp_lastimport.txt"
$LogFile = "$Source_Dir\powershell_log.txt"
$MailDomain = "exchange.mycompany.de"

function WriteLog
{
	Param ([string]$LogString)
	$Stamp = (Get-Date).toString("yyyy-MM-dd HH:mm:ss")
	$LogMessage = "$Stamp $LogString"
	Add-content $LogFile -value $LogMessage

	# Zusätzlich Ausgabe auf Console
	Write-Output $LogMessage
}


# Variable kann zum Debuggen (temporär) auf true gesetzt werden.
[bool] $ShouldImport = $false


# Prüfen, ob die PFX-Datei ein neueres Datum als der Timestamp des letzten Imports hat
if (Test-Path -Path $TimestampFile -PathType Leaf) {

	$filePFX = Get-ChildItem $CertFile
	$fileTimestamp = Get-ChildItem $TimestampFile

	WriteLog "Datum Zertifikat:	$($filePFX.LastWriteTime.ToString("s"))"
	WriteLog "Datum Timestamp:	$($fileTimestamp.LastWriteTime.ToString("s"))"

	if ($filePFX.LastWriteTime -gt $fileTimestamp.LastWriteTime) {
		WriteLog "Aktuelles Zertifikat ist neuer als der letzte Import"
		$ShouldImport = $true
	}
} else {
	$ShouldImport = $true
}


# Importieren
if ($ShouldImport) {

	# Log-Datei leeren, um nur die letzte Durchführung zu protokollieren (und sich ein Logrotate zu sparen)
	Clear-Content -Path $LogFile


	# Exchange-Snapin holen
	# Quelle: https://github.com/win-acme/win-acme/blob/master/dist/Scripts/ImportExchange.ps1
	WriteLog "Searching for Exchange snapin..."
	Get-PSSnapin -Registered `
		| Where-Object {
			$_.Name -match "Microsoft.Exchange.Management.PowerShell" `
			-and (
				$_.Name -match "Admin" -or
				$_.Name -match "E2010" -or
				$_.Name -match "SnapIn"
			)
		} `
		| Add-PSSnapin -ErrorAction SilentlyContinue -PassThru `
		| WriteLog

	# Test if the Cmdlet is there now
	$Command = Get-Command "Enable-ExchangeCertificate" -errorAction SilentlyContinue
	if ($Command -eq $null)
	{
		WriteLog "Exchange Management Tools for Powershell not installed"
		return
	}


	WriteLog "Importiere..."

	try
	{
		# Für den Import muss das Zertifikat zwingend passwort-geschützt sein. Beim Export auf dem Proxy und hier entsprechend berücksichtigt.
		# Die Import-Funktion gibt dummerweise kein Objekt zurück, sodass eine direkte Zuweisung an "$ImportCert" (siehe unten) nicht möglich ist.
		Import-ExchangeCertificate -FileData ([Byte[]]$(Get-Content -Path $CertFile -Encoding byte -ReadCount 0)) -Password (ConvertTo-SecureString -String 'dummy' -AsPlainText -Force) -ErrorAction Stop | Out-Null

		WriteLog "Zertifikat wurde importiert"
	}
	catch
	{
		WriteLog "Fehler bei Import-ExchangeCertificate"
		WriteLog "Exception: $_"
		throw
	}



	# Zertifikat an Exchange/IIS zuweisen
	try
	{
		WriteLog "Updating Exchange services..."

		# Ermitteln des aktuellsten Zertifikats
		$ImportCert = Get-ExchangeCertificate | where {$_.Subject -match $MailDomain} | Sort-Object NotBefore | Select -Last 1
		WriteLog "ImportCert: $ImportCert"


		WriteLog "Enable-ExchangeCertificate"
		Enable-ExchangeCertificate -Services IIS -Thumbprint $ImportCert.Thumbprint -Force -ErrorAction Stop

		WriteLog "Zertifikat an Exchange zugewiesen"
	}
	catch
	{
		WriteLog "Fehler bei Enable-ExchangeCertificate"
		WriteLog "Exception: $_"
		throw
	}


	# Timestamp anpassen
	WriteLog "Timestamp aktualisieren..."
	Get-Date -Format "u" | Out-File -FilePath $TimestampFile


	# Aufräumen
	try
	{
		WriteLog "Abgelaufene Zertifikate löschen"

		# Alle abgelaufenen Zertifikate auswählen.
		# Das gerade erneuerte Zertifikat ist normalerweise noch einen Monat gültig und bleibt somit erstmal bestehen.
		Get-ExchangeCertificate | where {$_.Subject -match $MailDomain -and $_.NotAfter -lt $today} | Remove-Item
	}
	catch
	{
		WriteLog "Fehler beim Löschen der alten Zertifikate"
		WriteLog "Exception: $_"
		throw
	}

	WriteLog "Exit"
}

Ich selbst habe die Script bzw. die Konfiguration nicht nachgestellt. Die Konfiguration sowie die Scripte sehen für mich aber schlüssig aus. Zu guter Letzt hat der freundliche Leser auch noch seine HAProxy Konfiguration mitgeschickt:

global
	# HTTP-Requests loggen, aber später über rsyslog nur die bzgl. tarpit in haproxy-tarpit.log schreiben.
	# Die restlichen Log-Einträge werden weiterhin normal in haproxy.log geschrieben.
	log /dev/log	local0 info

	chroot /var/lib/haproxy
	stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
	stats timeout 30s
	user haproxy
	group haproxy
	daemon

	# Default SSL material locations
	ca-base /etc/ssl/certs
	crt-base /etc/ssl/private

	# generated 2021-12-22, Mozilla Guideline v5.6, HAProxy 2.2.9, OpenSSL 1.1.1k, intermediate configuration
	# https://ssl-config.mozilla.org/#server=haproxy&version=2.2.9&config=intermediate&openssl=1.1.1k&guideline=5.6
	ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
	ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
	ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets


defaults
	log	global
	mode	http
	option	httplog
	option	dontlognull
	timeout http-request 5s
	timeout connect 5s
	# Connection Resets durch höhere Timeouts vermeiden, siehe
	# https://discourse.haproxy.org/t/high-number-of-connection-resets-during-transfers-exchange-2013/1158/6
	# Timeouts Client/Server waren vorher auf 50 Sekunden eingestellt. Probleme sind dabei zumindest nicht aufgefallen.
	timeout client 1000s
	timeout server 1000s


listen stats
	bind *:9000
	mode http
	stats enable
	stats uri /
	stats auth admin:HIEREINPASSWORTEINTRAGEN


frontend fe

	# SSL: Angabe eines Verzeichnisses lädt alle darin vorhandenen Zertifikate.
	# HAProxy verwendet je nach angefragter Domain das passende Zertifikat.
	bind :80
	bind :85 ssl crt /etc/haproxy/ssl/
	bind :443 ssl crt /etc/haproxy/ssl/


	# Let's Encrypt für Proxmox Mail Gateway durchreichen.
	# Wird dort verarbeitet.
	acl domain_is_mailgateway hdr_dom(host) -i mail.example.de


	# Let's Encrypt für alles andere
	acl is_certbot path_beg /.well-known/acme-challenge/

	# Von http auf https umleiten
	# 301 für "Moved Permanently"
	http-request redirect scheme https code 301 unless { ssl_fc } or is_certbot or domain_is_mailgateway


	# Je nach Bedingung oder Domain auf entsprechenden Zielserver weiterleiten
	use_backend ProxmoxMailGateway if domain_is_mailgateway
	use_backend certbot if is_certbot

	# Konfiguration für Exchange
	# Verwendet "exchange.example.de" und "autodiscover.example.de"

	no option httpclose

	acl eas path_beg -i /Microsoft-Server-ActiveSync
	acl autodiscover path_beg -i /autodiscover
	acl ews path_beg -i /ews
	acl mapi url_beg /mapi
	acl oab path_beg -i /oab
	acl owa path_beg -i /owa
	acl rpc path_beg -i /rpc/rpcproxy.dll
	acl ecp path_beg -i /ecp

	# Nicht sicher, ob das favicon.ico nur bei Aufrufen von OWA requested wird, aber da es sonst
	# im tarpit landet, wird es halt an OWA weitergereicht.
	acl owa path_beg -i /favicon.ico

	use_backend ExchangeActiveSync if eas
	use_backend ExchangeAutoDiscover if autodiscover
	use_backend ExchangeECP if ecp
	use_backend ExchangeEWS if ews
	use_backend ExchangeMAPI if mapi
	use_backend ExchangeOAB if oab
	use_backend ExchangeOWA if owa
	use_backend OutlookAnywhere if rpc



	# Der Rest (Angriffe, Scans, etc.) landet im Tarpit.
	default_backend tarpit


backend certbot
	server certbot 127.0.0.1:9080

backend ProxmoxMailGateway
	server MEINPMGSERVER 192.168.120.5


# Exchange-Backends
# Quelle:
# https://www.bayreuth.tk/home/linux-und-bsd/haproxy-als-loadbalancer-und-ssl-offloader-fuer-microsoft-exchange-cas-server.html
# Da wir nur einen Exchange-Server verwenden, alles mit Sticky-Table entfernt.

backend ExchangeActiveSync
	option httpchk HEAD /Microsoft-Server-ActiveSync
	http-check expect status 401
	server MEINEXCHANGESERVER 192.168.120.3:443 ssl verify none

backend ExchangeOWA
	option http-server-close
	option redispatch
	option httpchk GET /owa
	http-check expect status 301
	server MEINEXCHANGESERVER 192.168.120.3:443 ssl verify none

# Anfragen auf ECP werden aus Sicherheitsgründen nicht weitergeleitet, da hierüber die Administration erfolgt.
# Blockt auch Sachen wie die Erkennung von Abwesenheiten, aber das ist für uns unwichtig.
backend ExchangeECP
	http-request deny if TRUE

backend ExchangeEWS
	option httpchk GET /ews
	http-check expect status 401
	server MEINEXCHANGESERVER 192.168.120.3:443 ssl verify none

backend ExchangeAutoDiscover
	server MEINEXCHANGESERVER 192.168.120.3:443 ssl verify none

backend ExchangeOAB
	server MEINEXCHANGESERVER 192.168.120.3:443 ssl verify none

backend ExchangeMAPI
	server MEINEXCHANGESERVER 192.168.120.3:443 ssl verify none

backend OutlookAnywhere
	option redispatch
	server MEINEXCHANGESERVER 192.168.120.3:443 ssl verify none

# Tarpit: Angreifer ausbremsen
# https://www.haproxy.com/de/blog/use-haproxy-response-policies-to-stop-threats/
# Würde sonst auf "fe/<NOSRV>" landen und 503-Fehler bekommen, aber nicht verzögert werden.
# Bietet keinen Schutz, aber bindet auf Angreifer-Seite Ressourcen (auch bei uns etwas).
backend tarpit
	timeout tarpit 10s
	http-request tarpit deny_status 403 if TRUE

Somit lässt sich dieses Szenario recht einfach nachbauen. Die Scripte müssen noch ein klein bisschen angepasst werden, beispielsweise Zugangsdaten, Pfade und IPs, aber dies sollte kein Problem darstellen. In einer ruhigen Minute werde ich diese Scripte auch einmal ausprobieren.

Exchange Extended Protection und HAProxy

16 Gedanken zu „Exchange Extended Protection und HAProxy“

  1. It works almost fine, only ExchangeMAPI is not working for me. It constantly ask for user and password when connecting via Outlook through /mapi. OWA is working fine, activesync is working fine.

    Antworten
  2. Hi, habe mich eingelesen und getestet…nur kommt ein Problem, das Zertifikat wird importiert, ist aber nur in der MMC-Konsole sichtbar und nicht im Admin-Center und auch nicht per Get-ExchangeCertificate zu sehen. Hat da jemand eine Idee ?

    Antworten
  3. Leider ist die Anleitung auf meiner OpenSense FW mit HaProxy und LetsEncrypt Plugin nicht umsetzbar.
    Weder die genannten Pfade noch die Dateien existieren, auch eine Volltextsuche brachte keine Licht ins dunkel.
    Hat das schon jemand auf OpenSense/pfSense umgesetzt?

    Antworten
    • Falls das Thema noch aktuell ist.
      Ich habe aktuell die gleiche Situation.
      Hier meine Lösung:
      Im opnSense unter Services->ACME Client->Automations einen Eintrag erstellen und „Upload certificate via SFTP“ auswählen.
      (auf dem Exchange entsprechend den openssl-server aktivieren, einen Nutzer einrichten, Ordner anlegen und openSSL entsprechend konfigurieren

      Im Gegensatz zu der hier vorgestellten Lösung würde ich das Importieren der Zertifikats nicht über die Uhrzeit regeln sondern einen Filesystem-Watcher installieren, der bei Änderung des Zertifikats den Import triggert.

      Antworten
  4. Interessante Lösung, danke fürs Teilen (und an den Autor ).

    Beim Überfliegen fiel mir allerdings eine Kleinigkeit auf: in Zeile 40 des Powershell-Scripts sollte $ShouldImport doch eher auf false stehen, oder nicht? So wie es aktuell eingestellt ist wird (wenn ich den Code richtig lese) der Timestamp-Check quasi ausgehebelt und das Zertifikat jedes Mal ausgetauscht wenn das Script läuft.

    Antworten
    • Nein, der Abschnitt wird ausgeführt, wenn die Marker-Datei noch nicht existiert, also bei der allerersten Ausführung. Da hätte der Kommentar besser formuliert sein, bzw. vor der folgenden if-Abfrage stehen können, um es deutlicher zu machen.

      Antworten
  5. hallo

    ist es ok dass in if , beide werte $ShouldImport auf True ??

    if ($filePFX.LastWriteTime -gt $fileTimestamp.LastWriteTime) {
    WriteLog „Aktuelles Zertifikat ist neuer als der letzte Import“
    $ShouldImport = $true
    }
    } else {
    $ShouldImport = $true
    }

    Antworten
  6. Sehr schön,
    und danke auch von mir an den Leser!
    ich mach diese Schritte seit einer Weile jeden Monat manuell, weil ich nicht die Zeit finde das zu scripten.
    ich werde es morgen mal bei mir einbauen.
    Ich frage mich allerdings, wieso HAProxy als reverse Proxy benutzt wird und nicht zum Beispiel nginx. da kann man doch gleich Pfade sperren oder nur auf bestimmte Clients freischalten. Zum beispiel owa nur für bestimmte Smartphones mit bestimmten User Logins….

    Antworten
    • Hallo Christop
      Ich nutze den HAproxy schon länger als PlugIn auf der pfSense

      >da kann man doch gleich Pfade sperren oder
      Ich denke, HAproxy kann auch mir Pfaden umgehen – oder täusche ich mich da ?

      > nur auf bestimmte Clients freischalten. Zum beispiel owa nur für bestimmte Smartphones mit bestimmten User Logins….
      WIe muss ich mir das vorstelle ? Wie muss ich mir das vorstellen ?
      Danke und Grüße, Rolf

      Antworten
    • Dachte mir, eine Proxy-Software wäre besser als ein Webserver für einen Proxy ;-)

      Hatte mit nginx bisher nicht viel zu tun und die Anleitungen, auf denen das basiert, haben halt HAProxy verwendet. Also eigentlich kein konkreter Grund, HAProxy zu verwenden, bzw. bisher keine Notwendigkeit für nginx gehabt.

      Falls Du das mit nginx umsetzen solltest, würde mich Deine Konfig aber auch interessieren.

      Antworten

Schreibe einen Kommentar