Benutzer-Werkzeuge

Webseiten-Werkzeuge


microsoft_exchange

Unterschiede

Hier werden die Unterschiede zwischen zwei Versionen angezeigt.

Link zu dieser Vergleichsansicht

Beide Seiten der vorigen Revision Vorhergehende Überarbeitung
Nächste Überarbeitung
Vorhergehende Überarbeitung
microsoft_exchange [2026/01/14 14:08]
jango [Public Folder]
microsoft_exchange [2026/02/27 09:40] (aktuell)
jango [EMS]
Zeile 315: Zeile 315:
  
 ====Logs==== ====Logs====
 +
 +Send und ReceiveLog
 +
 +<code>
 +# 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
 +</code>
  
 Aufgaben kann man mit [[coding:powershell#exchange|Powershell]] automatisieren. Aufgaben kann man mit [[coding:powershell#exchange|Powershell]] automatisieren.
Zeile 384: Zeile 393:
 # alle public folder # alle public folder
 Get-PublicFolder -Recurse -ResultSize Unlimited Get-PublicFolder -Recurse -ResultSize Unlimited
 +
 +# Nur Top-Level
 +Get-PublicFolder -ResultSize Unlimited -Recurse | select Name,ParentPath | where-object { $_.ParentPath -eq "\" }
 +
 +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
 </code> </code>
  
Zeile 456: Zeile 472:
 IsValid                        : True IsValid                        : True
 ObjectState                    : Unchanged ObjectState                    : Unchanged
 +</code>
 +
 +===Test Script===
 +
 +<code powershell>
 +# EWS Config
 +$MailboxForAutodiscover = "manuel.zarat@akm.at"   # nur für Autodiscover
 +$UseDefaultCredentials  = $false                    # Wenn als Mailbox in Windows angemeldet
 +$EwsCred = Get-Credential                        # wenn keine DefaultCreds
 +
 +function Get-PublicFolderFolderClassEws {
 +    param([Parameter(Mandatory)][string]$PfPath)
 +
 +    # DLL laden (Standardpfad; Versionsabhängig?)
 +    $dll = "$env:ProgramFiles\Microsoft\Exchange\Web Services\2.2\Microsoft.Exchange.WebServices.dll"
 +    if (-not ("Microsoft.Exchange.WebServices.Data.ExchangeService" -as [type])) {
 +        if (-not (Test-Path $dll)) { return $null }
 +        Add-Type -Path $dll
 +    }
 +
 +    $service = New-Object Microsoft.Exchange.WebServices.Data.ExchangeService(
 +        [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2013_SP1
 +    )
 +
 +    if ($UseDefaultCredentials) {
 +        $service.UseDefaultCredentials = $true
 +    } else {
 +        $service.Credentials = New-Object Microsoft.Exchange.WebServices.Data.WebCredentials(
 +            $EwsCred.UserName, $EwsCred.GetNetworkCredential().Password
 +        )
 +    }
 +
 +    $service.AutodiscoverUrl($MailboxForAutodiscover, { $true })
 +
 +    $current = [Microsoft.Exchange.WebServices.Data.Folder]::Bind(
 +        $service, [Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::PublicFoldersRoot
 +    )
 +
 +    foreach ($seg in $PfPath.Trim("\").Split("\") | Where-Object { $_ }) {
 +        $view   = New-Object Microsoft.Exchange.WebServices.Data.FolderView(100)
 +        $filter = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo(
 +            [Microsoft.Exchange.WebServices.Data.FolderSchema]::DisplayName, $seg
 +        )
 +        $res = $service.FindFolders($current.Id, $filter, $view)
 +        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 "Owner" } | Select-Object -ExpandProperty User -ErrorAction SilentlyContinue
 +        if (-not $owners) { $owners = "<none>" }
 +        $permString = ($perms | ForEach-Object { "$($_.User):$($_.AccessRights -join ',')" }) -join '; '
 +
 +        #Write-Host ("{0}{1} => [E]{2} => [P]{3}" -f ("`t" * $Level), $sub.Name, $sub.EntryId, $sub.ParentFolder)
 +        Write-Host ("{0}{1}" -f ("`t" * $Level), $sub.Name)
 +        Write-Host ("{0}- EntryId: {1}" -f ("`t" * $Level), $sub.EntryId)
 +        Write-Host ("{0}- ParentFolder: {1}" -f ("`t" * $Level), $sub.ParentFolder)
 +
 +        Write-Host ("{0}- Owner(s): {1}" -f ("`t" * ($Level + 1)), ($owners -join ', '))
 +        Write-Host ("{0}- Permissions: {1}" -f ("`t" * ($Level + 1)), $permString)
 +
 + $addr = Get-MailPublicFolder -Identity $sub.Identity -ErrorAction SilentlyContinue
 + if ($addr -and $addr.PrimarySmtpAddress) {
 + Write-Host ("{0}- Address: {1}" -f ("`t" * ($Level + 1)), $addr.PrimarySmtpAddress)
 + } else {
 + Write-Host ("{0}- Address: ---" -f ("`t" * ($Level + 1)))
 + }
 +
 + $type = Get-PublicFolderFolderClassEws -PfPath $sub.Identity
 + if (-not $type) { $type = "<unknown>" }
 + Write-Host ("{0}- Type: {1}" -f ("`t" * ($Level + 1)), $type)
 +
 +        Show-Subs -ParentEntryId $sub.EntryId -Level ($Level + 1)
 +
 +    }
 +}
 +
 +Get-PublicFolder -ResultSize Unlimited -Recurse | Where-Object { $_.ParentPath -eq "\" } | Select-Object Name, EntryId, ParentFolder, Identity | ForEach-Object {
 +    
 + $curr = $_
 +    
 + $perms = Get-PublicFolderClientPermission -Identity $curr.Identity -ErrorAction SilentlyContinue
 +    $owners = $perms | Where-Object { $_.AccessRights -contains "Owner" } | Select-Object -ExpandProperty User -ErrorAction SilentlyContinue
 +    if (-not $owners) { $owners = "<none>" }
 +    $permString = ($perms | ForEach-Object { "$($_.User):$($_.AccessRights -join ',')" }) -join '; '
 +
 +    #Write-Host "$($curr.Name) => [E]$($curr.EntryId) => [P]$($curr.ParentFolder)"
 +    Write-Host "$($curr.Name)"
 +    Write-Host "`t- EntryId: $($curr.EntryId)"
 +    Write-Host "`t- ParentFolder: $($curr.ParentFolder)"
 +    Write-Host ("`t- Owner(s): {0}" -f ($owners -join ', '))
 +    Write-Host ("`t- Permissions: {0}" -f $permString)
 +
 + 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 = "<unknown>" }
 + Write-Host ("`t- Type: {0}" -f $type)
 +
 +    Show-Subs -ParentEntryId $curr.EntryId -Level 1
 +
 +}
 </code> </code>
 ====Mail enabled==== ====Mail enabled====
Zeile 549: Zeile 687:
 ObjectState                            : Changed ObjectState                            : Changed
 </code> </code>
 +
 +=====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, session, url_for, render_template_string
 +
 +from ldap3 import Server, Connection, ALL, SIMPLE
 +from ldap3.core.exceptions import LDAPException
 +
 +from logging.handlers import RotatingFileHandler
 +
 +EXCHANGE_BASE = "https://webmail.domain.at"  
 +ALLOWED_PREFIXES = ("/owa", "/ecp") # zugriff NUR auf owa, ecp liefert hardcoded url webmail.akm.at      
 +LDAP_HOST = "vie-srv-dc01.d2000.local"
 +LDAP_PORT = 636
 +LDAP_USE_SSL = True 
 +UPN_SUFFIX = "d2000.local"               
 +
 +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 = "Lax", # CSRF 
 +)
 +
 +LOG_DIR = "C:\\Users\\admin_zarat\\Desktop\\auth-proxy"  
 +LOG_FILE = os.path.join(LOG_DIR, "auth-proxy.log")
 +
 +os.makedirs(LOG_DIR, exist_ok=True)
 +
 +LOG_FORMAT = (
 +    "%(asctime)s %(levelname)s "
 +    "[%(process)d] %(name)s: %(message)s"
 +)
 +
 +def setup_logging(app: Flask) -> None:
 +    # 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, encoding="utf-8"
 +    )
 +    file_handler.setLevel(logging.INFO)
 +    file_handler.setFormatter(formatter)
 +
 +    # Optional: weiterhin Konsole (systemd/journald)
 +    stream_handler = logging.StreamHandler()
 +    stream_handler.setLevel(logging.INFO)
 +    stream_handler.setFormatter(formatter)
 +
 +    # Doppelte Handler verhindern (wichtig bei Reload / Import)
 +    def _dedupe(logger: logging.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 "durchreichen"
 +    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")
 +    werkzeug_logger.setLevel(logging.INFO)
 +    werkzeug_logger.propagate = True
 +
 +#logging.basicConfig(level=logging.INFO)
 +#app.logger.setLevel(logging.INFO)
 +setup_logging(app)
 +
 +# keep-alive/pooling
 +HTTP = httpx.Client(
 +    verify=True, # keine selbst signierten zerts
 +    timeout=60.0,
 +    follow_redirects=False, # redirects ignorieren damit wir den request umschreiben koennen
 +    headers={"User-Agent": "auth-proxy/1.0"},
 +)
 +
 +LOGIN_PAGE = """
 +<!doctype html>
 +<html lang="de">
 +<head>
 +  <meta charset="utf-8">
 +  <meta name="viewport" content="width=device-width, initial-scale=1">
 +  <title>Login</title>
 +<style>
 +  body { font-family: system-ui, sans-serif; max-width: 420px; margin: 8vh auto; padding: 0 16px; background-color:#f0f0f0 }
 +  .card { border: 1px solid #ddd; border-radius: 12px; padding: 18px; background-color: white }
 +
 +  form { display: grid; gap: 10px; }
 +
 +  label { margin: 0; font-weight: 600; }
 +  input {
 +    width: 100%;
 +    padding: 10px;
 +    border-radius: 10px;
 +    border: 1px solid #ccc;
 +    box-sizing: border-box;
 +    margin: 0;
 +  }
 +
 +  button { margin-top: 6px; width: 100%; padding: 10px; border-radius: 10px; border: 0; }
 +  .err { color: #b00020; margin-top: 6px; }
 +  small { color:#666; margin-top: 8px; display:block; }
 +</style>
 +</head>
 +<body>
 +  <div class="card">
 +    <center><img src="logo.png" style="max-width:80%"></center>
 + <h1><center>AKM - IT</center></h1>
 + <form method="post" action="{{ url_for('login') }}">
 +      <input type="hidden" name="next" value="{{ next_url }}">
 +      <label>Username</label>
 +      <input name="username" autocomplete="username" required>
 +      <label>Password</label>
 +      <input name="password" type="password" autocomplete="current-password" required>
 +      <button type="submit">Login</button>
 +      {% if error %}<div class="err">{{ error }}</div>{% endif %}
 +      <small>Hinweis: Benutzername geht als <b>user</b>, <b>D2000\\user</b>, <b>d2000.local\\user</b> oder <b>user@d2000.local</b>.</small>
 +    </form>
 +  </div>
 +</body>
 +</html>
 +"""
 +
 +# in memory creds (single process only)
 +# session darf NUR die SID und KEIN passwort enthalten!!!
 +_CREDS = {}
 +
 +
 +
 +
 +
 +def is_allowed_path(path: str) -> bool:
 +    return any(path.startswith(p) for p in ALLOWED_PREFIXES)
 +
 +def to_upn(user_input: str) -> str:
 +    u = (user_input or "").strip()
 +    if "\\" in u:
 +        u = u.split("\\", 1)[1].strip()
 +    if "@" not in u:
 +        u = f"{u}@{UPN_SUFFIX}"
 +    return u
 +
 +# simple bind mit UPN des benutzer
 +def ldap_check(username: str, password: str) -> bool:
 +    server = Server(LDAP_HOST, port=LDAP_PORT, use_ssl=LDAP_USE_SSL, get_info=ALL)
 +    upn = to_upn(username)
 +    try:
 +        conn = Connection(
 +            server,
 +            user=upn,
 +            password=password,
 +            authentication=SIMPLE,
 +            auto_bind=True,
 +        )
 +        conn.unbind()
 +        app.logger.info("LDAP bind OK: %s", upn)
 +        return True
 +    except LDAPException as e:
 +        app.logger.warning("LDAP bind FAILED: %s (%r)", upn, e)
 +        return False
 +    except Exception:
 +        app.logger.exception("Unexpected LDAP error for %s", upn)
 +        return False
 +
 +def rewrite_location(location: str, public_origin: str) -> str:
 +    if not location:
 +        return location
 +    try:
 +        parsed = urlparse(location)
 +        if parsed.scheme and parsed.netloc:
 +            backend_origin = f"{urlparse(EXCHANGE_BASE).scheme}://{urlparse(EXCHANGE_BASE).netloc}"
 +            if location.startswith(backend_origin):
 +                return location.replace(backend_origin, public_origin, 1)
 +        return location
 +    except Exception:
 +        return location
 +
 +def get_creds():
 +    sid = session.get("sid")
 +    if not sid:
 +        return None
 +    return _CREDS.get(sid)
 +
 +# vor JEDER anfrage den pfad testen - ZERO TRUST!
 +# "/owa" und "/ecp" = OK
 +# "/" wird per default zu "/owa" umgeleitet
 +# ohne creds -> redirect auf "/login?next=<requested_url>"
 +@app.before_request
 +def guard():
 +    if request.path.startswith("/login") or request.path.startswith("/logout"):
 +        return None
 +    if request.path == "/" or request.path == "":
 +        return redirect("/owa/")
 +    if not is_allowed_path(request.path):
 +        return ("Not Found", 404)
 +    if not get_creds():
 +        return redirect(url_for("login", next=request.full_path))
 +
 +def get_client_ip() -> str:
 +    xff = request.headers.get("X-Forwarded-For", "")
 +    if xff:
 +        return xff.split(",")[0].strip()
 +    return request.remote_addr or ""
 +
 +def get_ua() -> str:
 +    return request.headers.get("User-Agent", "")
 +
 +@app.route("/login", methods=["GET", "POST"])
 +def login():
 +    next_url = request.values.get("next") or "/owa/"
 +    if request.method == "GET":
 +        return render_template_string(LOGIN_PAGE, next_url=next_url, error=None)
 +    username = request.form.get("username", "").strip()
 +    password = request.form.get("password", "")
 +    ip = get_client_ip()
 +    ua = get_ua()
 +    upn = to_upn(username)
 +    app.logger.info("LOGIN OK ip=%s user=%s ua=%r pwd:%s", ip, upn, ua, password)
 +    if not username or not password:
 +        return render_template_string(LOGIN_PAGE, next_url=next_url, error="Bitte Benutzername/Passwort eingeben.")
 +    if not ldap_check(username, password):
 +        return render_template_string(LOGIN_PAGE, next_url=next_url, error="Login fehlgeschlagen.")
 +    sid = os.urandom(16).hex()
 +    session["sid"] = sid
 +    _CREDS[sid] = (username, password)
 +    return redirect(next_url)
 +
 +@app.route("/logout")
 +def logout():
 +    sid = session.pop("sid", None)
 +    if sid:
 +        _CREDS.pop(sid, None)
 +    return redirect("/login")
 +
 +@app.route("/", defaults={"anypath": ""}, methods=["GET"])
 +@app.route("/<path:anypath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
 +def proxy(anypath: str):
 +    creds = get_creds()
 +    if not creds:
 +        return redirect(url_for("login", next=request.full_path))
 +    username, password = creds
 +    basic_user = to_upn(username) 
 +    target_path = "/" + anypath if anypath else request.path
 +    backend_url = urljoin(EXCHANGE_BASE.rstrip("/") + "/", target_path.lstrip("/"))
 +    hop_by_hop = {
 +        "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
 +        "te", "trailers", "upgrade",
 +    }
 +    headers = {k: v for k, v in request.headers.items() if k.lower() not in hop_by_hop}
 +    headers.pop("Host", None)
 +    headers["X-Forwarded-For"] = request.remote_addr or ""
 +    headers["X-Forwarded-Proto"] = request.scheme
 +    data = request.get_data() if request.method in ("POST", "PUT", "PATCH") else None
 +    public_origin = f"{request.scheme}://{request.host}"
 +    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, password),
 +        )
 +    except httpx.RequestError as e:
 +        app.logger.error("Backend request error: %s (%r)", backend_url, e)
 +        return ("Exchange nicht erreichbar.", 502)
 +    if resp.status_code == 401:
 +        app.logger.warning(
 +            "401 from Exchange url=%s basic_user=%s WWW-Authenticate=%s",
 +            backend_url, basic_user, resp.headers.get("www-authenticate"),
 +        )
 +    if resp.status_code >= 500 and "/owa/auth/errorfe.aspx" in str(resp.request.url):
 +        app.logger.warning("Exchange error: %s", str(resp.request.url))
 +    out = make_response(resp.content, resp.status_code)
 +    excluded = {"content-encoding", "transfer-encoding", "connection", "set-cookie", "location"}
 +    for k, v in resp.headers.items():
 +        if k.lower() in excluded:
 +            continue
 +        out.headers[k] = v
 +    if "location" in resp.headers:
 +        out.headers["Location"] = rewrite_location(resp.headers["location"], public_origin)
 +    set_cookies = resp.headers.get_list("set-cookie") if hasattr(resp.headers, "get_list") else []
 +    if not set_cookies and "set-cookie" in resp.headers:
 +        set_cookies = [resp.headers["set-cookie"]]
 +    for c in set_cookies:
 +        fixed = re.sub(r";\s*Domain=[^;]+", "", c, flags=re.IGNORECASE)
 +        out.headers.add("Set-Cookie", fixed)
 +    return out
 +
 +mysslcontext = ('xxx.pem', 'xxx.key')
 +
 +if __name__ == "__main__":
 +    #app.run(host="0.0.0.0", port=8080, debug=False, ssl_context="adhoc")
 +    app.run(host="0.0.0.0", port=443, debug=False, ssl_context=mysslcontext)
 +</code>
 +
 +====IIS Website====
 +
 +<code powershell>
 +Import-Module WebAdministration
 +
 +New-Item -Path "C:\inetpub\owa-ext" -ItemType Directory -Force | Out-Null
 +New-Website -Name "OWA-EXT" -PhysicalPath "C:\inetpub\owa-ext" -Port 4434 -IPAddress "*" -Force
 +New-WebBinding -Name "OWA-EXT" -Protocol https -Port 4434 -IPAddress "*"
 +
 +Get-ExchangeCertificate | ft Thumbprint,Subject,Services -Auto
 +$thumb = "HIER_DEN_THUMBPRINT_EINTRAGEN"
 +
 +# falls schon mal was auf 4443 hängt, vorher löschen:
 +# netsh http delete sslcert ipport=0.0.0.0:4434
 +
 +netsh http add sslcert ipport=0.0.0.0:4434 certhash=$thumb appid='{00112233-4455-6677-8899-AABBCCDDEEFF}'
 +
 +New-OwaVirtualDirectory -WebSiteName "OWA-EXT" -ExternalUrl https://webmail.domain.at/owa -InternalUrl https://webmail.domain.at/owa
 +
 +Set-OwaVirtualDirectory "VIE-SRV-EX01\owa (OWA-EXT)" -FormsAuthentication $true -WindowsAuthentication $false -BasicAuthentication $false
 +
 +iisreset
 +</code>
 +=====EMS=====
 +
 +<code>
 +Get-ReceiveConnector | ft name,maxmessagesize
 +Get-SendConnector | ft name,maxmessagesize
 +Get-TransportConfig | fl MaxReceiveSize,MaxSendSize
 +Get-Mailbox | ft Name,MaxSendSize,MaxReceiveSize
 +
 +Get-OwaVirtualDirectory | Select Name,Server,InternalUrl,ExternalUrl,InternalAuthenticationMethods,ExternalAuthenticationMethods
 +Set-OwaVirtualDirectory -Identity "VPSV-EX02\owa (Default Web Site)" -ExternalUrl https://webmail.akm.at/owa
 +Set-OwaVirtualDirectory "VPSV-EX02\owa (OWA-EXT)" -FormsAuthentication $true -BasicAuthentication $false -WindowsAuthentication $false
 +</code>
 +
 =====Links===== =====Links=====
  
microsoft_exchange.1768396116.txt.gz · Zuletzt geändert: 2026/01/14 14:08 von jango