Translate repository documentation and configuration added for ldap logging

This commit is contained in:
2026-08-03 20:57:48 +00:00
parent 8a9115af83
commit b54b180115
14 changed files with 170 additions and 118 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
"user_base_dn": "",
"user_filter": "(sAMAccountName={username})",
"display_name_attribute": "displayName",
"email_attribute": "mail"
"email_attribute": "mail",
"debug_logging": False
}
},
"software": {
+7 -4
View File
@@ -483,10 +483,10 @@ def seed_i18n(db: Session) -> None:
if not row:
db.add(Translation(translation_key=key,language_code=code,text_value=value))
# Frühere 0.3.7-Teststände konnten bei den Login-Schlüsseln deutsche
# Texte in der englischen Spalte hinterlassen. Da diese Schlüssel feste,
# geschützte Systemtexte sind, werden sie auf die kanonischen Werte
# zurückgesetzt. Eigene Übersetzungsschlüssel bleiben unangetastet.
# Earlier 0.3.7 test builds could leave German text in the English
# column for login keys. Because these keys are fixed protected
# system text, reset them to the canonical values.
# Custom translation keys remain unchanged.
for key in (
"login.title",
"login.username",
@@ -1190,4 +1190,7 @@ BASE_TRANSLATIONS.update({
"mesh.sync_status.created": ("Created", "Neu angelegt"),
"mesh.sync_status.conflict": ("Conflict", "Konflikt"),
"mesh.sync_status.missing": ("Missing in MeshCentral", "In MeshCentral nicht gefunden"),
"settings.ldap_debug_logging": ("Enable detailed LDAP debug logging", "Ausführliches LDAP-Debug-Protokoll aktivieren"),
"settings.ldap_debug_logging_help": ("When disabled, the LDAP log records only the searched username, whether it was found, and whether authentication succeeded. Enable this only temporarily for troubleshooting.", "Wenn deaktiviert, protokolliert LDAP nur den gesuchten Benutzernamen, ob er gefunden wurde und ob die Anmeldung erfolgreich war. Nur vorübergehend zur Fehlersuche aktivieren."),
})
+119 -83
View File
@@ -1053,12 +1053,17 @@ def _action_status(db: Session, action: str) -> StatusOption | None:
return db.query(StatusOption).filter(StatusOption.active.is_(True), column.is_(True)).first()
def _ldap_debug_enabled(auth_config: dict) -> bool:
return bool(auth_config.get("ldap", {}).get("debug_logging", False))
def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict | None:
from ldap3 import ALL, Connection, Server, SUBTREE
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
ldap = auth_config.get("ldap", {})
debug_logging = _ldap_debug_enabled(auth_config)
server_name = str(ldap.get("server", "")).strip()
base_dn = str(ldap.get("user_base_dn", "")).strip()
use_ssl = bool(ldap.get("use_ssl", False))
@@ -1070,22 +1075,26 @@ def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict
if not bind_password:
bind_password = str(ldap.get("bind_password", ""))
ldap_logger.info(
"Anmeldeversuch Benutzer=%r Server=%s Port=%s SSL=%s StartTLS=%s BaseDN=%r BindDN=%r Passwortvariable=%r gesetzt=%s",
username, server_name, port, use_ssl, start_tls, base_dn, bind_dn, env_name, bool(bind_password),
)
ldap_logger.info("LDAP login lookup started: username=%r", username)
if debug_logging:
ldap_logger.debug(
"LDAP connection settings: server=%s port=%s ssl=%s start_tls=%s base_dn=%r bind_dn=%r password_env=%r configured=%s",
server_name, port, use_ssl, start_tls, base_dn, bind_dn, env_name, bool(bind_password),
)
if not server_name:
ldap_logger.error("LDAP-Server ist nicht konfiguriert")
ldap_logger.error("LDAP authentication unavailable: server is not configured")
return None
if not base_dn:
ldap_logger.error("Benutzer-Basis-DN ist nicht konfiguriert")
ldap_logger.error("LDAP authentication unavailable: user base DN is not configured")
return None
if not password:
ldap_logger.warning("Leeres Benutzerpasswort für Benutzer=%r", username)
ldap_logger.warning("LDAP login rejected: username=%r reason=empty-password", username)
return None
if bind_dn and not bind_password:
ldap_logger.error("Bind-DN ist gesetzt, aber das Bind-Passwort fehlt. Erwartete Umgebungsvariable=%r", env_name)
ldap_logger.error("LDAP authentication unavailable: service-account password is missing")
if debug_logging:
ldap_logger.debug("Expected LDAP bind-password environment variable: %r", env_name)
return None
server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10)
@@ -1102,16 +1111,23 @@ def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict
)
if start_tls and not use_ssl:
search_connection.open()
ldap_logger.debug("TCP/LDAP-Verbindung geöffnet")
if debug_logging:
ldap_logger.debug("LDAP TCP connection opened")
if not search_connection.start_tls():
ldap_logger.error("StartTLS fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error)
ldap_logger.error("LDAP authentication unavailable: StartTLS failed")
if debug_logging:
ldap_logger.debug("StartTLS details: result=%r last_error=%r", search_connection.result, search_connection.last_error)
return None
ldap_logger.debug("StartTLS erfolgreich")
if debug_logging:
ldap_logger.debug("LDAP StartTLS completed")
if not search_connection.bind():
ldap_logger.error("Dienstkonto-Bind fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error)
ldap_logger.error("LDAP authentication unavailable: service-account bind failed")
if debug_logging:
ldap_logger.debug("Service-account bind details: result=%r last_error=%r", search_connection.result, search_connection.last_error)
return None
ldap_logger.info("Dienstkonto-Bind erfolgreich")
if debug_logging:
ldap_logger.debug("LDAP service-account bind completed")
raw_filter = str(ldap.get("user_filter", "(sAMAccountName={username})"))
escaped_username = escape_filter_chars(username)
@@ -1119,7 +1135,8 @@ def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict
display_attr = str(ldap.get("display_name_attribute", "displayName"))
email_attr = str(ldap.get("email_attribute", "mail"))
attributes = list(dict.fromkeys([display_attr, email_attr, "distinguishedName"]))
ldap_logger.info("Benutzersuche BaseDN=%r Filter=%r Attribute=%r", base_dn, user_filter, attributes)
if debug_logging:
ldap_logger.debug("LDAP user search: base_dn=%r filter=%r attributes=%r", base_dn, user_filter, attributes)
search_ok = search_connection.search(
base_dn,
@@ -1127,22 +1144,25 @@ def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict
search_scope=SUBTREE,
attributes=attributes,
)
ldap_logger.info(
"Benutzersuche beendet: success=%s Treffer=%s result=%r last_error=%r",
search_ok, len(search_connection.entries), search_connection.result, search_connection.last_error,
)
if len(search_connection.entries) != 1:
if len(search_connection.entries) == 0:
ldap_logger.warning("Kein LDAP-Benutzer für Benutzer=%r gefunden", username)
else:
ldap_logger.warning("Mehrere LDAP-Benutzer für Benutzer=%r gefunden: %s", username, len(search_connection.entries))
entry_count = len(search_connection.entries)
if debug_logging:
ldap_logger.debug(
"LDAP user search details: success=%s entries=%s result=%r last_error=%r",
search_ok, entry_count, search_connection.result, search_connection.last_error,
)
if entry_count != 1:
ldap_logger.info("LDAP user lookup completed: username=%r found=no", username)
if debug_logging and entry_count > 1:
ldap_logger.debug("LDAP user lookup returned multiple entries: username=%r entries=%s", username, entry_count)
return None
ldap_logger.info("LDAP user lookup completed: username=%r found=yes", username)
entry = search_connection.entries[0]
user_dn = entry.entry_dn
display_name = str(getattr(entry, display_attr, "") or username)
email = str(getattr(entry, email_attr, "") or "")
ldap_logger.info("Benutzer gefunden: DN=%r Anzeigename=%r E-Mail=%r", user_dn, display_name, email)
if debug_logging:
ldap_logger.debug("LDAP user details: dn=%r display_name=%r email=%r", user_dn, display_name, email)
user_connection = Connection(
server,
@@ -1155,19 +1175,27 @@ def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict
if start_tls and not use_ssl:
user_connection.open()
if not user_connection.start_tls():
ldap_logger.error("StartTLS beim Benutzer-Bind fehlgeschlagen: result=%r last_error=%r", user_connection.result, user_connection.last_error)
ldap_logger.info("LDAP login completed: username=%r success=no", username)
if debug_logging:
ldap_logger.debug("User StartTLS details: result=%r last_error=%r", user_connection.result, user_connection.last_error)
return None
if not user_connection.bind():
ldap_logger.warning("Benutzer-Bind fehlgeschlagen für DN=%r: result=%r last_error=%r", user_dn, user_connection.result, user_connection.last_error)
ldap_logger.info("LDAP login completed: username=%r success=no", username)
if debug_logging:
ldap_logger.debug("User bind details: dn=%r result=%r last_error=%r", user_dn, user_connection.result, user_connection.last_error)
return None
ldap_logger.info("LDAP-Anmeldung erfolgreich für Benutzer=%r DN=%r", username, user_dn)
ldap_logger.info("LDAP login completed: username=%r success=yes", username)
return {"username": username, "display_name": display_name, "email": email}
except LDAPException as exc:
ldap_logger.exception("LDAP-Ausnahme für Benutzer=%r: %s", username, exc)
ldap_logger.error("LDAP login failed because of an LDAP error: username=%r", username)
if debug_logging:
ldap_logger.exception("LDAP exception during login for username=%r: %s", username, exc)
return None
except Exception as exc:
ldap_logger.exception("Unerwarteter LDAP-Fehler für Benutzer=%r: %s", username, exc)
ldap_logger.error("LDAP login failed because of an unexpected error: username=%r", username)
if debug_logging:
ldap_logger.exception("Unexpected LDAP login error for username=%r: %s", username, exc)
return None
finally:
for connection in (user_connection, search_connection):
@@ -1690,10 +1718,10 @@ def startup():
db.commit()
_seed_field_definitions(db)
# Frühere Excel-Importe konnten Datumswerte als
# "YYYY-MM-DD 00:00:00" speichern. Browser zeigen solche Werte in
# <input type="date"> leer an. Bestehende System-Datumsfelder werden
# deshalb einmalig auf YYYY-MM-DD normalisiert.
# Earlier Excel imports could store date values as
# "YYYY-MM-DD 00:00:00". Browsers render such values as empty in
# <input type="date">. Existing system date fields are therefore
# normalized to YYYY-MM-DD once.
date_repairs = 0
for asset in db.query(Asset).filter(
(Asset.purchase_date.like("% %")) |
@@ -1756,7 +1784,7 @@ def stop_mesh_presence_service():
def delete_asset_image(image_path: str | None) -> None:
"""Löscht nur individuell hochgeladene Asset-Bilder aus dem Upload-Ordner."""
"""Delete only individually uploaded asset images from the upload directory."""
if not image_path or not image_path.startswith("/static/uploads/"):
return
filename = Path(image_path).name
@@ -1796,9 +1824,9 @@ def _record_asset_history(db: Session, asset: Asset, changes: dict, source: str
def _xlsx_response(filename: str, headers: list[str], rows: list[list]) -> Response:
"""Erzeugt eine echte XLSX-Datei und liefert vollständige Bytes statt eines Streams.
"""Create a real XLSX file and return complete bytes instead of a stream.
Das verhindert beschädigte Downloads bei einzelnen Reverse-Proxies und Browsern.
This prevents corrupted downloads with certain reverse proxies and browsers.
"""
workbook = Workbook()
sheet = workbook.active
@@ -1854,7 +1882,7 @@ def _excel_cell_value(value) -> str | None:
def _normalize_date_value(value: Any) -> str | None:
"""Normalisiert Excel-/Datenbankwerte für HTML-Datumsfelder auf YYYY-MM-DD."""
"""Normalize Excel and database values for HTML date fields to YYYY-MM-DD."""
if value in (None, ""):
return None
if isinstance(value, datetime):
@@ -1864,10 +1892,10 @@ def _normalize_date_value(value: Any) -> str | None:
text = str(value).strip()
if not text:
return None
# ISO-Datum oder ISO-Zeitstempel: Der Datumsteil sind die ersten 10 Zeichen.
# ISO date or timestamp: the date portion is the first 10 characters.
if re.match(r"^\d{4}-\d{2}-\d{2}(?:[ T].*)?$", text):
return text[:10]
# Deutsche Schreibweise aus manuellen Excel-Dateien.
# German date notation from manually maintained Excel files.
match = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+.*)?$", text)
if match:
day, month, year = match.groups()
@@ -1876,7 +1904,7 @@ def _normalize_date_value(value: Any) -> str | None:
def _normalize_datetime_value(value: Any) -> str | None:
"""Normalisiert Werte für <input type=datetime-local>."""
"""Normalize values for <input type=datetime-local>."""
if value in (None, ""):
return None
if isinstance(value, datetime):
@@ -1990,7 +2018,7 @@ def _would_create_parent_cycle(db: Session, asset_id: int, parent_id: int | None
def _list_fields_for_categories(categories: list[Category]) -> list[CategoryField]:
"""Bildet eine eindeutige, sortierte Vereinigungsmenge der konfigurierten Listenspalten."""
"""Build a unique, sorted union of the configured list columns."""
result: list[CategoryField] = []
seen: set[str] = set()
for category in categories:
@@ -2025,7 +2053,7 @@ CHART_TYPES = {
def _parse_value_mappings(text_value: str) -> list[dict[str, str]]:
"""Liest Regeln im Format Suchtext => Anzeigename (eine Regel je Zeile)."""
"""Read rules in the format search text => display name, one rule per line."""
mappings: list[dict[str, str]] = []
for line_number, raw_line in enumerate((text_value or "").splitlines(), start=1):
line = raw_line.strip()
@@ -2052,7 +2080,7 @@ def _value_mappings_text(chart: ChartDefinition | None) -> str:
def _automatic_chart_value(field: str, value: str) -> str:
"""Fasst technische Rohwerte für Diagramme zusammen, ohne die Assetdaten zu verändern."""
"""Summarize technical raw values for charts without modifying asset data."""
text = value.strip()
lower = text.casefold()
if field == "operating_system":
@@ -2090,7 +2118,7 @@ def _automatic_chart_value(field: str, value: str) -> str:
def _transform_chart_value(chart: ChartDefinition, field: str, value: str) -> str:
# Eigene Regeln haben Vorrang; gesucht wird ohne Beachtung der Groß-/Kleinschreibung.
# Custom rules take precedence; matching is case-insensitive.
lower = value.casefold()
for entry in chart.value_mappings or []:
if not isinstance(entry, dict):
@@ -2914,7 +2942,7 @@ async def assets_import(
else:
allowed_fields = set(definitions_by_name) | {"name"}
# Leere Zellen werden bewusst nicht in Updates übernommen.
# Empty cells are intentionally not applied to updates.
data = {
name: value
for name, value in raw_data.items()
@@ -3005,7 +3033,7 @@ async def assets_import(
})
continue
# Ohne Asset-ID wird immer ein neuer Datensatz erzeugt.
# Without an asset ID, always create a new record.
if not system_data.get("name"):
message = "Für ein neues Asset fehlt die Bezeichnung"
errors.append(f"Zeile {excel_row_number}: {message}")
@@ -3083,12 +3111,12 @@ def asset_tree(
hide_without_parent: str | None = None,
db: Session = Depends(get_db),
):
"""Zeigt die gefilterte Gerätehierarchie.
"""Display the filtered device hierarchy.
Mehrere Kategorien werden als wiederholte Query-Parameter ``category_id``
übergeben. Filter werden auf jedes Gerät angewendet, nicht nur auf die
Wurzelknoten. Wenn der direkte Elternknoten ausgefiltert ist, wird das
passende Gerät in der Darstellung als eigener Wurzelknoten angezeigt.
Multiple categories are passed as repeated ``category_id`` query parameters.
Filters apply to every device, not only to root nodes. If the immediate parent
node is filtered out, the device is rendered as a temporary root so it remains
visible in the hierarchy.
"""
try:
parsed_root_id = int(root_id) if root_id not in (None, "") else None
@@ -3104,10 +3132,10 @@ def asset_tree(
if category_id not in selected_category_ids:
selected_category_ids.append(category_id)
# Zuerst alle für den angemeldeten Benutzer sichtbaren Assets laden. Die
# Filterung erfolgt anschließend in Python, damit Treffer in beliebiger
# Hierarchietiefe nicht durch eine SQL-Abfrage von ihren Eltern getrennt
# oder versehentlich nur auf Wurzelknoten beschränkt werden.
# First load all assets visible to the signed-in user. Filtering
# is then performed in Python so matches at any hierarchy
# depth are not separated from their parents by a SQL query
# or accidentally restricted to root nodes only.
all_assets = (
_apply_asset_access(db.query(Asset), request)
.options(joinedload(Asset.category))
@@ -3120,7 +3148,7 @@ def asset_tree(
parent_key = asset.parent_asset_id if asset.parent_asset_id in all_by_id else None
all_children.setdefault(parent_key, []).append(asset)
# Bei ausgewähltem obersten Knoten nur dessen Teilbaum berücksichtigen.
# When a top-level node is selected, include only its subtree.
scope_ids: set[int]
selected_root = all_by_id.get(parsed_root_id) if parsed_root_id else None
if selected_root:
@@ -3146,8 +3174,8 @@ def asset_tree(
if category_matches and name_matches:
visible_assets.append(asset)
# Der explizit gewählte oberste Knoten bleibt als Orientierung sichtbar,
# auch wenn er selbst den Namens- oder Kategorienfilter nicht erfüllt.
# Keep the explicitly selected top-level node visible for orientation,
# even when it does not itself match the name or category filter.
if selected_root and selected_root not in visible_assets:
visible_assets.insert(0, selected_root)
@@ -3162,8 +3190,8 @@ def asset_tree(
hide_unassigned = str(hide_without_parent or "").lower() in {"1", "true", "on", "yes"}
if selected_root:
roots = [selected_root]
# Treffer, deren Zwischenknoten weggefiltert wurden, zusätzlich als
# eigene Wurzeln darstellen, damit kein Treffer verloren geht.
# Also render matches whose intermediate nodes were filtered out as
# separate roots so no match is lost.
roots.extend(
asset for asset in by_parent.get(None, [])
if asset.id != selected_root.id
@@ -3172,9 +3200,9 @@ def asset_tree(
roots = list(by_parent.get(None, []))
if hide_unassigned:
# Nur echte Einzelgeräte ohne hinterlegtes übergeordnetes Gerät
# ausblenden. Geräte, die lediglich wegen eines Filters als Wurzel
# erscheinen, bleiben sichtbar. Ein gewählter oberster Knoten bleibt.
# Hide only genuine standalone devices without a stored parent device.
# Devices that appear as roots only because of filtering
# remain visible. A selected top-level node also remains visible.
roots = [
asset for asset in roots
if asset.id == parsed_root_id
@@ -4505,8 +4533,8 @@ async def category_update(category_id: int, request: Request, name: str = Form(.
new_image = save_upload(image)
if new_image:
category.image_path = new_image
# Vorhandene Definitionen explizit löschen und flushen. Das verhindert
# UniqueConstraint-Verletzungen bei anschließend identischen Feldnamen.
# Explicitly delete and flush existing definitions. This prevents
# unique-constraint violations when identical field names are inserted afterwards.
db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False)
db.flush()
definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all()
@@ -6346,9 +6374,9 @@ def login_submit(request: Request, username: str = Form(...), password: str = Fo
user = db.query(User).filter(func.lower(User.username) == username.casefold()).first()
valid = False
# Ein geschütztes lokales Konto wird auch im LDAP-Modus ausschließlich
# gegen sein lokales Passwort geprüft. Bei falschem Passwort gibt es
# bewusst keinen LDAP-Fallback für denselben Benutzernamen.
# A protected local account is authenticated only against its local password,
# even in LDAP mode. If the password is incorrect, there is deliberately
# no LDAP fallback for the same username.
protected_local = bool(
user
and getattr(user, "is_protected", False)
@@ -6581,10 +6609,10 @@ def user_delete(user_id: int, request: Request, db: Session = Depends(get_db)):
def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]:
"""Listet AD-Benutzer über das konfigurierte LDAP-Dienstkonto auf.
"""List AD users through the configured LDAP service account.
Eine leere Suche liefert alle aktiven Benutzerkonten. Bei einem Suchtext
werden Benutzername, Anzeigename und E-Mail durchsucht.
An empty query returns all active user accounts. A search term matches
usernames, display names, and email addresses.
"""
from ldap3 import ALL, Connection, Server, SUBTREE
from ldap3.core.exceptions import LDAPException
@@ -6610,8 +6638,8 @@ def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]:
raw_query = query_text.strip()
def ldap_wildcard_pattern(value: str) -> str:
# Benutzer-Wildcards zulassen, sonstige LDAP-Sonderzeichen aber sicher maskieren.
# * = beliebig viele Zeichen.
# Allow user wildcards while safely escaping all other LDAP special characters.
# * matches any number of characters.
parts = []
literal = []
for char in value:
@@ -6625,7 +6653,7 @@ def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]:
if literal:
parts.append(escape_filter_chars("".join(literal)))
pattern = "".join(parts)
# Ohne explizite Wildcard bleibt die bisherige Teilstringsuche erhalten.
# Without an explicit wildcard, retain the existing substring search.
if "*" not in value:
pattern = f"*{pattern}*"
return pattern
@@ -6651,8 +6679,8 @@ def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]:
raise_exceptions=False,
)
try:
# ldap3.Connection.open() liefert je nach Version keinen verlässlichen
# booleschen Rückgabewert. Daher nur öffnen und anschließend binden.
# Depending on the version, ldap3.Connection.open() does not return a reliable
# Boolean result. Open the connection and then bind explicitly.
if start_tls and not use_ssl:
conn.open()
if not conn.start_tls():
@@ -6684,16 +6712,22 @@ def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]:
"dn": str(entry.get("dn") or attrs.get("distinguishedName") or ""),
})
result.sort(key=lambda item: ((item.get("display_name") or item["username"]).casefold(), item["username"].casefold()))
ldap_logger.info("LDAP-Benutzerabfrage erfolgreich: Filter=%r Treffer=%s", search_filter, len(result))
ldap_logger.info("LDAP directory search completed: query=%r matches=%s", raw_query, len(result))
if _ldap_debug_enabled(auth_config):
ldap_logger.debug("LDAP directory search details: filter=%r base_dn=%r", search_filter, base_dn)
return result
except HTTPException:
raise
except LDAPException as exc:
ldap_logger.exception("LDAP-Ausnahme bei Benutzerabfrage: %s", exc)
raise HTTPException(400, f"LDAP-Abfrage fehlgeschlagen: {exc}") from exc
ldap_logger.error("LDAP directory search failed because of an LDAP error")
if _ldap_debug_enabled(auth_config):
ldap_logger.exception("LDAP exception during directory search: %s", exc)
raise HTTPException(400, "LDAP query failed") from exc
except Exception as exc:
ldap_logger.exception("Unerwarteter LDAP-Fehler bei Benutzerabfrage: %s", exc)
raise HTTPException(400, f"LDAP-Abfrage fehlgeschlagen: {exc}") from exc
ldap_logger.error("LDAP directory search failed because of an unexpected error")
if _ldap_debug_enabled(auth_config):
ldap_logger.exception("Unexpected LDAP directory-search error: %s", exc)
raise HTTPException(400, "LDAP query failed") from exc
finally:
try:
conn.unbind()
@@ -6764,7 +6798,7 @@ def ldap_users_import(
continue
user = db.query(User).filter(func.lower(User.username) == item["username"].casefold()).first()
if user:
# Lokale Konten werden nicht automatisch in LDAP-Konten umgewandelt.
# Local accounts are not converted to LDAP accounts automatically.
if user.auth_source != "ldap":
skipped += 1
continue
@@ -7247,6 +7281,7 @@ async def settings_save(
ldap_user_filter: str = Form("(sAMAccountName={username})"),
ldap_display_name_attribute: str = Form("displayName"),
ldap_email_attribute: str = Form("mail"),
ldap_debug_logging: str | None = Form(None),
remove_logo: str | None = Form(None),
remove_favicon: str | None = Form(None),
logo: UploadFile | None = File(None),
@@ -7325,6 +7360,7 @@ async def settings_save(
"user_filter": ldap_user_filter.strip() or "(sAMAccountName={username})",
"display_name_attribute": ldap_display_name_attribute.strip() or "displayName",
"email_attribute": ldap_email_attribute.strip() or "mail",
"debug_logging": ldap_debug_logging == "on",
})
authentication["ldap"] = ldap
meshcentral = dict(current.get("meshcentral", {}))
@@ -7497,7 +7533,7 @@ async def meshcentral_settings_save(request: Request, db: Session = Depends(get_
'store_source_json': form.get('store_source_json') == 'on',
})
mesh['match_order'] = form.getlist('match_order') or ['node_id', 'serial_number']
# Klartextpasswort aus Altversionen nicht weiter speichern.
# Do not retain plaintext passwords from older versions.
mesh.pop('password', None)
mesh.pop('field_mappings', None)
mesh.pop('field_rules', None)
+20 -20
View File
@@ -77,7 +77,7 @@ def _normalize_mac(value: Any) -> str | None:
def _extract_devices(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
direct = [item for item in payload if isinstance(item, dict)]
# MeshCtrl kann mehrere vollständige JSON-Blöcke nacheinander ausgeben.
# MeshCtrl can emit multiple complete JSON blocks in sequence.
nested: list[dict[str, Any]] = []
for item in payload:
if isinstance(item, (list, dict)):
@@ -106,14 +106,14 @@ def _extract_devices(payload: Any) -> list[dict[str, Any]]:
def _strip_terminal_noise(output: str) -> str:
"""Entfernt BOM, ANSI-Steuersequenzen und NUL-Zeichen aus MeshCtrl-Ausgaben."""
"""Remove BOM markers, ANSI control sequences, and NUL bytes from MeshCtrl output."""
output = output.lstrip("\ufeff")
output = output.replace("\x00", "")
return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output)
def _parse_json_output(output: str) -> Any:
"""Liest auch JSON mit vor-/nachgelagerten Hinweisen oder mehreren JSON-Blöcken."""
"""Parse JSON even when surrounded by notices or when multiple JSON blocks are present."""
cleaned = _strip_terminal_noise(output).strip()
if not cleaned:
raise ValueError("MeshCtrl lieferte eine leere Ausgabe.")
@@ -137,7 +137,7 @@ def _parse_json_output(output: str) -> Any:
values.append(value)
position = end
except json.JSONDecodeError as exc:
# Ein nicht valider Detailblock darf nicht zu einer irreführenden Standardmeldung führen.
# An invalid detail block must not produce a misleading default message.
context_start = max(0, exc.pos - 120)
context_end = min(len(cleaned), exc.pos + 120)
context = cleaned[context_start:context_end].replace("\n", "\\n")
@@ -175,7 +175,7 @@ def _run_meshctrl(
command.append("--noverify")
# stdout/stderr bewusst direkt in Dateien schreiben. Dadurch umgehen wir
# mögliche Pipe-/Puffergrenzen bei sehr großen MeshCtrl-JSON-Ausgaben.
# possible pipe or buffer limits with very large MeshCtrl JSON output.
diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics"))
diagnostic_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
@@ -198,9 +198,9 @@ def _run_meshctrl(
stdout_text = stdout_bytes.decode("utf-8", errors="replace")
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
# Leere stderr-Dateien entfernen. Erfolgreiche, häufige Abfragen wie die
# Präsenzprüfung können ihre stdout-Rohdatei nach dem Einlesen verwerfen.
# Bei MeshCtrl-Fehlern bleibt die Datei unabhängig von retain_stdout erhalten.
# Remove empty stderr files. Successful high-frequency queries such as the
# presence check may discard their raw stdout file after it has been parsed.
# For MeshCtrl errors, the file is retained regardless of retain_stdout.
if not stderr_bytes:
stderr_path.unlink(missing_ok=True)
if not retain_stdout and completed.returncode == 0:
@@ -220,7 +220,7 @@ def _looks_truncated(output: str) -> bool:
cleaned = _strip_terminal_noise(output).rstrip()
if not cleaned:
return False
# Der beobachtete Fehler endet nahe 64 KiB mitten in einem JSON-Objekt.
# The observed failure ends near 64 KiB in the middle of a JSON object.
return len(cleaned) >= 65000 and cleaned[-1] not in "]}"
@@ -424,8 +424,8 @@ def fetch_devices(log: Callable[[str, str], None] | None = None) -> list[dict[st
except (ValueError, json.JSONDecodeError, RuntimeError) as exc:
attempts.append(("mit Details: " if include_details else "ohne Details: ") + str(exc))
log("warning", f"JSON-Auswertung {mode} fehlgeschlagen: {exc}")
# Manche MeshCentral-/MeshCtrl-Versionen liefern bei --details einen
# beschädigten sehr großen JSON-Block. Dann automatisch Basisdaten abrufen.
# Some MeshCentral or MeshCtrl versions return a
# corrupted, very large JSON block with --details. Fall back to basic data automatically.
continue
raise RuntimeError("MeshCentral-Import fehlgeschlagen. " + " | ".join(attempts))
@@ -443,7 +443,7 @@ def _bytes_to_gb(value: Any, binary: bool = False) -> str | None:
def _path_values(data: Any, path: str) -> list[Any]:
"""Liest Punktpfade; mehrere Alternativen können mit | getrennt werden."""
"""Read dotted paths; multiple alternatives may be separated with |."""
for alternative in [item.strip() for item in str(path or "").split("|") if item.strip()]:
values = [data]
for part in alternative.split('.'):
@@ -639,8 +639,8 @@ def _custom_value(row: AssetFieldValue | None, definition: FieldDefinition) -> A
def _coerce_system_value(target: str, value: Any) -> Any:
# Die meisten Asset-Spalten sind Textfelder. Integer-/Fremdschlüsselfelder
# werden typgerecht geschrieben, damit dynamische Mappings auch dort sicher sind.
# Most asset columns are text fields. Integer and foreign-key fields
# are written with the appropriate type so dynamic mappings remain safe there as well.
if target in {"mesh_mtype", "parent_asset_id", "category_id"}:
try:
return int(value) if value not in (None, "") else None
@@ -652,7 +652,7 @@ def _coerce_system_value(target: str, value: Any) -> Any:
def map_device(raw: dict[str, Any], field_mappings: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""Erzeugt Assetwerte ausschließlich aus den aktivierten Datenbank-Mappings."""
"""Build asset values exclusively from enabled database mappings."""
mapped: dict[str, Any] = {}
for mapping in field_mappings or []:
if not isinstance(mapping, dict) or not mapping.get('enabled', True):
@@ -662,7 +662,7 @@ def map_device(raw: dict[str, Any], field_mappings: list[dict[str, Any]] | None
continue
value = _mapped_value(raw, mapping)
if value not in (None, '', [], {}):
# Niedrigere Priorität wird zuerst verarbeitet; spätere Regeln dürfen überschreiben.
# Lower priorities are processed first; later rules may override earlier values.
mapped[target] = value
return mapped
@@ -949,8 +949,8 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> S
history_changes: dict[str, dict[str, str]] = {}
changed = False
# Ausschließlich die aktiven Mapping-Regeln bestimmen, welche
# System- und Custom-Felder synchronisiert werden. Es gibt keine
# Only enabled mapping rules determine which
# system and custom fields are synchronized. There is no
# feste Feld-Whitelist mehr.
for target, incoming in mapped.items():
if target == "mesh_device_type" or incoming in (None, "", [], {}):
@@ -958,8 +958,8 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> S
definition = field_definitions.get(target)
if definition is None:
# Das Zielfeld muss im zentralen Feldkatalog existieren.
# Die Prüfung wird zusätzlich vor der Geräteschleife protokolliert.
# The target field must exist in the central field catalog.
# This validation is also logged before the device loop starts.
continue
rule = mapping_rules.get(target, "fill_empty")
+1 -1
View File
@@ -194,7 +194,7 @@ def apply_lightweight_migrations() -> None:
for name, ddl in CHART_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE chart_definitions ADD COLUMN "{name}" {ddl}'))
# Das vorhandene Standarddiagramm wird nach dem Update sofort sinnvoll zusammengefasst.
# The existing default chart is summarized immediately after the update.
connection.execute(text("""
UPDATE chart_definitions
SET value_mode = 'automatic'
+3 -3
View File
@@ -74,9 +74,9 @@ def refresh_mesh_presence() -> dict[str, int]:
if not config:
return {"checked": 0, "online": 0, "offline": 0, "unknown": 0}
# Die Präsenzprüfung läuft standardmäßig alle 30 Sekunden. Ihre erfolgreiche
# ListDevices-Ausgabe wird nach dem Einlesen nicht als Zeitstempeldatei behalten.
# Bei einem Parsefehler speichern wir nur eine einzige überschreibbare Fehlerdatei.
# The presence check runs every 30 seconds by default. Its successful
# ListDevices output is not retained as a timestamped file after parsing.
# On a parse error, retain only one replaceable diagnostic file.
result = _run_meshctrl(
config,
"ListDevices",
+2 -2
View File
@@ -15,7 +15,7 @@ callback() {
}
trap 'code=$?; callback failed "$code" "Job failed" || true; exit "$code"' ERR
echo "$(date --iso-8601=seconds) job=${JOB_ID} started"
# --- Eigene Befehle hier einfügen ---
# --- Insert custom commands here ---
# --- Ende eigene Befehle ---
# --- End custom commands ---
callback success 0 'Job completed'
+2
View File
@@ -205,6 +205,8 @@
<label>{{ t('settings.user_base_dn') }}<input type="text" name="ldap_user_base_dn" value="{{ ldap.get('user_base_dn', '') }}" placeholder="DC=example,DC=org"></label>
<label>{{ t('settings.user_filter') }}<input type="text" name="ldap_user_filter" value="{{ ldap.get('user_filter', '(sAMAccountName={username})') }}"></label>
<div class="form-grid"><label>{{ t('settings.display_name_attribute') }}<input type="text" name="ldap_display_name_attribute" value="{{ ldap.get('display_name_attribute', 'displayName') }}"></label><label>{{ t('settings.email_attribute') }}<input type="text" name="ldap_email_attribute" value="{{ ldap.get('email_attribute', 'mail') }}"></label></div>
<div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="ldap_debug_logging" {% if ldap.get('debug_logging', false) %}checked{% endif %}> {{ t('settings.ldap_debug_logging') }}</label></div>
<p class="muted">{{ t('settings.ldap_debug_logging_help') }}</p>
<p class="muted">{{ t('settings.bind_password_help') }}</p><p><a class="button button-secondary" href="/settings/ldap-search">🔎 {{ t('ldap.title') }}</a></p>
</div>
</section>
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.28"
APP_VERSION = "0.5.5.29"
__version__ = APP_VERSION