Hier werden die Unterschiede zwischen zwei Versionen angezeigt.
| Beide Seiten der vorigen Revision Vorhergehende Überarbeitung Nächste Überarbeitung | Vorhergehende Überarbeitung | ||
|
microsoft_exchange [2026/01/14 16:06] jango [Public Folder] |
microsoft_exchange [2026/02/27 09:40] (aktuell) jango [EMS] |
||
|---|---|---|---|
| Zeile 315: | Zeile 315: | ||
| ====Logs==== | ====Logs==== | ||
| + | |||
| + | Send und ReceiveLog | ||
| + | |||
| + | < | ||
| + | # Frontend Transport | ||
| + | C:\Program Files\Microsoft\Exchange Server\V15\TransportRoles\Logs\FrontEnd\ProtocolLog | ||
| + | # Hub Transport | ||
| + | C:\Program Files\Microsoft\Exchange Server\V15\TransportRoles\Logs\Hub\ProtocolLog | ||
| + | </ | ||
| Aufgaben kann man mit [[coding: | Aufgaben kann man mit [[coding: | ||
| Zeile 387: | Zeile 396: | ||
| # Nur Top-Level | # Nur Top-Level | ||
| Get-PublicFolder -ResultSize Unlimited -Recurse | select Name, | Get-PublicFolder -ResultSize Unlimited -Recurse | select Name, | ||
| + | |||
| + | Add-PublicFolderClientPermission -Identity \My-Folder -User test.user -AccessRights Editor|Owner|Publisher | ||
| + | Get-PublicFolderClientPermission -Identity \My-Folder | ||
| + | Remove-PublicFolderClientPermission -Identity \My-Folder -User test.user | ||
| </ | </ | ||
| Zeile 459: | Zeile 472: | ||
| IsValid | IsValid | ||
| ObjectState | ObjectState | ||
| + | </ | ||
| + | |||
| + | ===Test Script=== | ||
| + | |||
| + | <code powershell> | ||
| + | # EWS Config | ||
| + | $MailboxForAutodiscover = " | ||
| + | $UseDefaultCredentials | ||
| + | $EwsCred = Get-Credential | ||
| + | |||
| + | function Get-PublicFolderFolderClassEws { | ||
| + | param([Parameter(Mandatory)][string]$PfPath) | ||
| + | |||
| + | # DLL laden (Standardpfad; | ||
| + | $dll = " | ||
| + | if (-not (" | ||
| + | if (-not (Test-Path $dll)) { return $null } | ||
| + | Add-Type -Path $dll | ||
| + | } | ||
| + | |||
| + | $service = New-Object Microsoft.Exchange.WebServices.Data.ExchangeService( | ||
| + | [Microsoft.Exchange.WebServices.Data.ExchangeVersion]:: | ||
| + | ) | ||
| + | |||
| + | if ($UseDefaultCredentials) { | ||
| + | $service.UseDefaultCredentials = $true | ||
| + | } else { | ||
| + | $service.Credentials = New-Object Microsoft.Exchange.WebServices.Data.WebCredentials( | ||
| + | $EwsCred.UserName, | ||
| + | ) | ||
| + | } | ||
| + | |||
| + | $service.AutodiscoverUrl($MailboxForAutodiscover, | ||
| + | |||
| + | $current = [Microsoft.Exchange.WebServices.Data.Folder]:: | ||
| + | $service, [Microsoft.Exchange.WebServices.Data.WellKnownFolderName]:: | ||
| + | ) | ||
| + | |||
| + | foreach ($seg in $PfPath.Trim(" | ||
| + | $view = New-Object Microsoft.Exchange.WebServices.Data.FolderView(100) | ||
| + | $filter = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo( | ||
| + | [Microsoft.Exchange.WebServices.Data.FolderSchema]:: | ||
| + | ) | ||
| + | $res = $service.FindFolders($current.Id, | ||
| + | if ($res.TotalCount -lt 1) { return $null } | ||
| + | $current = $res.Folders[0] | ||
| + | } | ||
| + | |||
| + | return $current.FolderClass | ||
| + | } | ||
| + | |||
| + | function Show-Subs { | ||
| + | param( | ||
| + | [Parameter(Mandatory)] $ParentEntryId, | ||
| + | [int] $Level = 1 | ||
| + | ) | ||
| + | |||
| + | $subs = Get-PublicFolder -ResultSize Unlimited -Recurse | Where-Object { $_.ParentFolder -eq $ParentEntryId } | ||
| + | |||
| + | foreach ($sub in $subs) { | ||
| + | |||
| + | $perms = Get-PublicFolderClientPermission -Identity $sub.Identity -ErrorAction SilentlyContinue | ||
| + | $owners = $perms | Where-Object { $_.AccessRights -contains " | ||
| + | if (-not $owners) { $owners = "< | ||
| + | $permString = ($perms | ForEach-Object { " | ||
| + | |||
| + | #Write-Host (" | ||
| + | Write-Host (" | ||
| + | Write-Host ("{0}- EntryId: {1}" -f (" | ||
| + | Write-Host ("{0}- ParentFolder: | ||
| + | |||
| + | Write-Host ("{0}- Owner(s): {1}" -f (" | ||
| + | Write-Host ("{0}- Permissions: | ||
| + | |||
| + | $addr = Get-MailPublicFolder -Identity $sub.Identity -ErrorAction SilentlyContinue | ||
| + | if ($addr -and $addr.PrimarySmtpAddress) { | ||
| + | Write-Host ("{0}- Address: {1}" -f (" | ||
| + | } else { | ||
| + | Write-Host ("{0}- Address: ---" -f (" | ||
| + | } | ||
| + | |||
| + | $type = Get-PublicFolderFolderClassEws -PfPath $sub.Identity | ||
| + | if (-not $type) { $type = "< | ||
| + | Write-Host ("{0}- Type: {1}" -f (" | ||
| + | |||
| + | Show-Subs -ParentEntryId $sub.EntryId -Level ($Level + 1) | ||
| + | |||
| + | } | ||
| + | } | ||
| + | |||
| + | Get-PublicFolder -ResultSize Unlimited -Recurse | Where-Object { $_.ParentPath -eq " | ||
| + | | ||
| + | $curr = $_ | ||
| + | | ||
| + | $perms = Get-PublicFolderClientPermission -Identity $curr.Identity -ErrorAction SilentlyContinue | ||
| + | $owners = $perms | Where-Object { $_.AccessRights -contains " | ||
| + | if (-not $owners) { $owners = "< | ||
| + | $permString = ($perms | ForEach-Object { " | ||
| + | |||
| + | #Write-Host " | ||
| + | Write-Host " | ||
| + | Write-Host "`t- EntryId: $($curr.EntryId)" | ||
| + | Write-Host "`t- ParentFolder: | ||
| + | Write-Host ("`t- Owner(s): {0}" -f ($owners -join ', ')) | ||
| + | Write-Host ("`t- Permissions: | ||
| + | |||
| + | try { | ||
| + | $addr = Get-MailPublicFolder -Identity $curr.Identity -ErrorAction SilentlyContinue | ||
| + | if ($addr -and $addr.PrimarySmtpAddress) { | ||
| + | Write-Host ("`t- Address: {0}" -f ($addr.PrimarySmtpAddress)) | ||
| + | } else { | ||
| + | Write-Host ("`t- Address: ---") | ||
| + | } | ||
| + | } catch {} | ||
| + | |||
| + | $type = Get-PublicFolderFolderClassEws -PfPath $curr.Identity | ||
| + | if (-not $type) { $type = "< | ||
| + | Write-Host ("`t- Type: {0}" -f $type) | ||
| + | |||
| + | Show-Subs -ParentEntryId $curr.EntryId -Level 1 | ||
| + | |||
| + | } | ||
| </ | </ | ||
| ====Mail enabled==== | ====Mail enabled==== | ||
| Zeile 552: | Zeile 687: | ||
| ObjectState | ObjectState | ||
| </ | </ | ||
| + | |||
| + | =====OWA Proxy===== | ||
| + | |||
| + | <code python> | ||
| + | import os | ||
| + | import re | ||
| + | import logging | ||
| + | from urllib.parse import urljoin, urlparse | ||
| + | |||
| + | import httpx | ||
| + | from flask import Flask, request, redirect, make_response, | ||
| + | |||
| + | from ldap3 import Server, Connection, ALL, SIMPLE | ||
| + | from ldap3.core.exceptions import LDAPException | ||
| + | |||
| + | from logging.handlers import RotatingFileHandler | ||
| + | |||
| + | EXCHANGE_BASE = " | ||
| + | ALLOWED_PREFIXES = ("/ | ||
| + | LDAP_HOST = " | ||
| + | LDAP_PORT = 636 | ||
| + | LDAP_USE_SSL = True | ||
| + | UPN_SUFFIX = " | ||
| + | |||
| + | app = Flask(__name__) | ||
| + | app.secret_key = os.urandom(32) | ||
| + | |||
| + | app.config.update( | ||
| + | SESSION_COOKIE_HTTPONLY = True, # damit JS das session cookie nicht auslesen kann | ||
| + | SESSION_COOKIE_SECURE = True, # session cookie NUR bei HTTPS setzen! | ||
| + | SESSION_COOKIE_SAMESITE = " | ||
| + | ) | ||
| + | |||
| + | LOG_DIR = " | ||
| + | LOG_FILE = os.path.join(LOG_DIR, | ||
| + | |||
| + | os.makedirs(LOG_DIR, | ||
| + | |||
| + | LOG_FORMAT = ( | ||
| + | " | ||
| + | " | ||
| + | ) | ||
| + | |||
| + | def setup_logging(app: | ||
| + | # Root logger (greift auch fur viele Library-Logs) | ||
| + | root = logging.getLogger() | ||
| + | root.setLevel(logging.INFO) | ||
| + | |||
| + | formatter = logging.Formatter(LOG_FORMAT) | ||
| + | |||
| + | # File logging mit Rotation: 10 MB pro File, 10 Backups | ||
| + | file_handler = RotatingFileHandler( | ||
| + | LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=10, | ||
| + | ) | ||
| + | file_handler.setLevel(logging.INFO) | ||
| + | file_handler.setFormatter(formatter) | ||
| + | |||
| + | # Optional: weiterhin Konsole (systemd/ | ||
| + | stream_handler = logging.StreamHandler() | ||
| + | stream_handler.setLevel(logging.INFO) | ||
| + | stream_handler.setFormatter(formatter) | ||
| + | |||
| + | # Doppelte Handler verhindern (wichtig bei Reload / Import) | ||
| + | def _dedupe(logger: | ||
| + | keep = [] | ||
| + | for h in logger.handlers: | ||
| + | # behalten, wenn gleicher Typ/Target | ||
| + | keep.append(h) | ||
| + | logger.handlers = keep | ||
| + | |||
| + | # Root: einmal sauber setzen | ||
| + | root.handlers.clear() | ||
| + | root.addHandler(file_handler) | ||
| + | root.addHandler(stream_handler) | ||
| + | |||
| + | # Flask app.logger nutzt teilweise eigene Handler: auf Root " | ||
| + | app.logger.handlers.clear() | ||
| + | app.logger.propagate = True | ||
| + | app.logger.setLevel(logging.INFO) | ||
| + | |||
| + | # Werkzeug (HTTP request logs) ebenfalls in Datei | ||
| + | werkzeug_logger = logging.getLogger(" | ||
| + | werkzeug_logger.setLevel(logging.INFO) | ||
| + | werkzeug_logger.propagate = True | ||
| + | |||
| + | # | ||
| + | # | ||
| + | setup_logging(app) | ||
| + | |||
| + | # keep-alive/ | ||
| + | HTTP = httpx.Client( | ||
| + | verify=True, | ||
| + | timeout=60.0, | ||
| + | follow_redirects=False, | ||
| + | headers={" | ||
| + | ) | ||
| + | |||
| + | LOGIN_PAGE = """ | ||
| + | < | ||
| + | <html lang=" | ||
| + | < | ||
| + | <meta charset=" | ||
| + | <meta name=" | ||
| + | < | ||
| + | < | ||
| + | body { font-family: | ||
| + | .card { border: 1px solid #ddd; border-radius: | ||
| + | |||
| + | form { display: grid; gap: 10px; } | ||
| + | |||
| + | label { margin: 0; font-weight: | ||
| + | input { | ||
| + | width: 100%; | ||
| + | padding: 10px; | ||
| + | border-radius: | ||
| + | border: 1px solid #ccc; | ||
| + | box-sizing: border-box; | ||
| + | margin: 0; | ||
| + | } | ||
| + | |||
| + | button { margin-top: 6px; width: 100%; padding: 10px; border-radius: | ||
| + | .err { color: #b00020; margin-top: 6px; } | ||
| + | small { color:#666; margin-top: 8px; display: | ||
| + | </ | ||
| + | </ | ||
| + | < | ||
| + | <div class=" | ||
| + | < | ||
| + | < | ||
| + | <form method=" | ||
| + | <input type=" | ||
| + | < | ||
| + | <input name=" | ||
| + | < | ||
| + | <input name=" | ||
| + | <button type=" | ||
| + | {% if error %}<div class=" | ||
| + | < | ||
| + | </ | ||
| + | </ | ||
| + | </ | ||
| + | </ | ||
| + | """ | ||
| + | |||
| + | # in memory creds (single process only) | ||
| + | # session darf NUR die SID und KEIN passwort enthalten!!! | ||
| + | _CREDS = {} | ||
| + | |||
| + | |||
| + | |||
| + | |||
| + | |||
| + | def is_allowed_path(path: | ||
| + | return any(path.startswith(p) for p in ALLOWED_PREFIXES) | ||
| + | |||
| + | def to_upn(user_input: | ||
| + | u = (user_input or "" | ||
| + | if " | ||
| + | u = u.split(" | ||
| + | if " | ||
| + | u = f" | ||
| + | return u | ||
| + | |||
| + | # simple bind mit UPN des benutzer | ||
| + | def ldap_check(username: | ||
| + | server = Server(LDAP_HOST, | ||
| + | upn = to_upn(username) | ||
| + | try: | ||
| + | conn = Connection( | ||
| + | server, | ||
| + | user=upn, | ||
| + | password=password, | ||
| + | authentication=SIMPLE, | ||
| + | auto_bind=True, | ||
| + | ) | ||
| + | conn.unbind() | ||
| + | app.logger.info(" | ||
| + | return True | ||
| + | except LDAPException as e: | ||
| + | app.logger.warning(" | ||
| + | return False | ||
| + | except Exception: | ||
| + | app.logger.exception(" | ||
| + | return False | ||
| + | |||
| + | def rewrite_location(location: | ||
| + | if not location: | ||
| + | return location | ||
| + | try: | ||
| + | parsed = urlparse(location) | ||
| + | if parsed.scheme and parsed.netloc: | ||
| + | backend_origin = f" | ||
| + | if location.startswith(backend_origin): | ||
| + | return location.replace(backend_origin, | ||
| + | return location | ||
| + | except Exception: | ||
| + | return location | ||
| + | |||
| + | def get_creds(): | ||
| + | sid = session.get(" | ||
| + | if not sid: | ||
| + | return None | ||
| + | return _CREDS.get(sid) | ||
| + | |||
| + | # vor JEDER anfrage den pfad testen - ZERO TRUST! | ||
| + | # "/ | ||
| + | # "/" | ||
| + | # ohne creds -> redirect auf "/ | ||
| + | @app.before_request | ||
| + | def guard(): | ||
| + | if request.path.startswith("/ | ||
| + | return None | ||
| + | if request.path == "/" | ||
| + | return redirect("/ | ||
| + | if not is_allowed_path(request.path): | ||
| + | return ("Not Found", | ||
| + | if not get_creds(): | ||
| + | return redirect(url_for(" | ||
| + | |||
| + | def get_client_ip() -> str: | ||
| + | xff = request.headers.get(" | ||
| + | if xff: | ||
| + | return xff.split("," | ||
| + | return request.remote_addr or "" | ||
| + | |||
| + | def get_ua() -> str: | ||
| + | return request.headers.get(" | ||
| + | |||
| + | @app.route("/ | ||
| + | def login(): | ||
| + | next_url = request.values.get(" | ||
| + | if request.method == " | ||
| + | return render_template_string(LOGIN_PAGE, | ||
| + | username = request.form.get(" | ||
| + | password = request.form.get(" | ||
| + | ip = get_client_ip() | ||
| + | ua = get_ua() | ||
| + | upn = to_upn(username) | ||
| + | app.logger.info(" | ||
| + | if not username or not password: | ||
| + | return render_template_string(LOGIN_PAGE, | ||
| + | if not ldap_check(username, | ||
| + | return render_template_string(LOGIN_PAGE, | ||
| + | sid = os.urandom(16).hex() | ||
| + | session[" | ||
| + | _CREDS[sid] = (username, password) | ||
| + | return redirect(next_url) | ||
| + | |||
| + | @app.route("/ | ||
| + | def logout(): | ||
| + | sid = session.pop(" | ||
| + | if sid: | ||
| + | _CREDS.pop(sid, | ||
| + | return redirect("/ | ||
| + | |||
| + | @app.route("/", | ||
| + | @app.route("/< | ||
| + | def proxy(anypath: | ||
| + | creds = get_creds() | ||
| + | if not creds: | ||
| + | return redirect(url_for(" | ||
| + | username, password = creds | ||
| + | basic_user = to_upn(username) | ||
| + | target_path = "/" | ||
| + | backend_url = urljoin(EXCHANGE_BASE.rstrip("/" | ||
| + | hop_by_hop = { | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | headers = {k: v for k, v in request.headers.items() if k.lower() not in hop_by_hop} | ||
| + | headers.pop(" | ||
| + | headers[" | ||
| + | headers[" | ||
| + | data = request.get_data() if request.method in (" | ||
| + | public_origin = f" | ||
| + | try: | ||
| + | # backend request mit BASIC AUTH | ||
| + | resp = HTTP.request( | ||
| + | method=request.method, | ||
| + | url=backend_url, | ||
| + | params=request.args, | ||
| + | headers=headers, | ||
| + | content=data, | ||
| + | auth=(basic_user, | ||
| + | ) | ||
| + | except httpx.RequestError as e: | ||
| + | app.logger.error(" | ||
| + | return (" | ||
| + | if resp.status_code == 401: | ||
| + | app.logger.warning( | ||
| + | "401 from Exchange url=%s basic_user=%s WWW-Authenticate=%s", | ||
| + | backend_url, | ||
| + | ) | ||
| + | if resp.status_code >= 500 and "/ | ||
| + | app.logger.warning(" | ||
| + | out = make_response(resp.content, | ||
| + | excluded = {" | ||
| + | for k, v in resp.headers.items(): | ||
| + | if k.lower() in excluded: | ||
| + | continue | ||
| + | out.headers[k] = v | ||
| + | if " | ||
| + | out.headers[" | ||
| + | set_cookies = resp.headers.get_list(" | ||
| + | if not set_cookies and " | ||
| + | set_cookies = [resp.headers[" | ||
| + | for c in set_cookies: | ||
| + | fixed = re.sub(r"; | ||
| + | out.headers.add(" | ||
| + | return out | ||
| + | |||
| + | mysslcontext = (' | ||
| + | |||
| + | if __name__ == " | ||
| + | # | ||
| + | app.run(host=" | ||
| + | </ | ||
| + | |||
| + | ====IIS Website==== | ||
| + | |||
| + | <code powershell> | ||
| + | Import-Module WebAdministration | ||
| + | |||
| + | New-Item -Path " | ||
| + | New-Website -Name " | ||
| + | New-WebBinding -Name " | ||
| + | |||
| + | Get-ExchangeCertificate | ft Thumbprint, | ||
| + | $thumb = " | ||
| + | |||
| + | # falls schon mal was auf 4443 hängt, vorher löschen: | ||
| + | # netsh http delete sslcert ipport=0.0.0.0: | ||
| + | |||
| + | netsh http add sslcert ipport=0.0.0.0: | ||
| + | |||
| + | New-OwaVirtualDirectory -WebSiteName " | ||
| + | |||
| + | Set-OwaVirtualDirectory " | ||
| + | |||
| + | iisreset | ||
| + | </ | ||
| + | =====EMS===== | ||
| + | |||
| + | < | ||
| + | Get-ReceiveConnector | ft name, | ||
| + | Get-SendConnector | ft name, | ||
| + | Get-TransportConfig | fl MaxReceiveSize, | ||
| + | Get-Mailbox | ft Name, | ||
| + | |||
| + | Get-OwaVirtualDirectory | Select Name, | ||
| + | Set-OwaVirtualDirectory -Identity " | ||
| + | Set-OwaVirtualDirectory " | ||
| + | </ | ||
| + | |||
| =====Links===== | =====Links===== | ||