Translate repository documentation and configuration added for ldap logging
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# AssetManager 0.5.5.28
|
# AssetManager 0.5.5.29
|
||||||
|
|
||||||
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. The project is released under the **Apache License 2.0** and may be used, modified, and redistributed for private and commercial purposes.
|
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. The project is released under the **Apache License 2.0** and may be used, modified, and redistributed for private and commercial purposes.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"user_base_dn": "",
|
"user_base_dn": "",
|
||||||
"user_filter": "(sAMAccountName={username})",
|
"user_filter": "(sAMAccountName={username})",
|
||||||
"display_name_attribute": "displayName",
|
"display_name_attribute": "displayName",
|
||||||
"email_attribute": "mail"
|
"email_attribute": "mail",
|
||||||
|
"debug_logging": False
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"software": {
|
"software": {
|
||||||
|
|||||||
+7
-4
@@ -483,10 +483,10 @@ def seed_i18n(db: Session) -> None:
|
|||||||
if not row:
|
if not row:
|
||||||
db.add(Translation(translation_key=key,language_code=code,text_value=value))
|
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
|
# Earlier 0.3.7 test builds could leave German text in the English
|
||||||
# Texte in der englischen Spalte hinterlassen. Da diese Schlüssel feste,
|
# column for login keys. Because these keys are fixed protected
|
||||||
# geschützte Systemtexte sind, werden sie auf die kanonischen Werte
|
# system text, reset them to the canonical values.
|
||||||
# zurückgesetzt. Eigene Übersetzungsschlüssel bleiben unangetastet.
|
# Custom translation keys remain unchanged.
|
||||||
for key in (
|
for key in (
|
||||||
"login.title",
|
"login.title",
|
||||||
"login.username",
|
"login.username",
|
||||||
@@ -1190,4 +1190,7 @@ BASE_TRANSLATIONS.update({
|
|||||||
"mesh.sync_status.created": ("Created", "Neu angelegt"),
|
"mesh.sync_status.created": ("Created", "Neu angelegt"),
|
||||||
"mesh.sync_status.conflict": ("Conflict", "Konflikt"),
|
"mesh.sync_status.conflict": ("Conflict", "Konflikt"),
|
||||||
"mesh.sync_status.missing": ("Missing in MeshCentral", "In MeshCentral nicht gefunden"),
|
"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."),
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
+117
-81
@@ -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()
|
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:
|
def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict | None:
|
||||||
from ldap3 import ALL, Connection, Server, SUBTREE
|
from ldap3 import ALL, Connection, Server, SUBTREE
|
||||||
from ldap3.core.exceptions import LDAPException
|
from ldap3.core.exceptions import LDAPException
|
||||||
from ldap3.utils.conv import escape_filter_chars
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
|
|
||||||
ldap = auth_config.get("ldap", {})
|
ldap = auth_config.get("ldap", {})
|
||||||
|
debug_logging = _ldap_debug_enabled(auth_config)
|
||||||
server_name = str(ldap.get("server", "")).strip()
|
server_name = str(ldap.get("server", "")).strip()
|
||||||
base_dn = str(ldap.get("user_base_dn", "")).strip()
|
base_dn = str(ldap.get("user_base_dn", "")).strip()
|
||||||
use_ssl = bool(ldap.get("use_ssl", False))
|
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:
|
if not bind_password:
|
||||||
bind_password = str(ldap.get("bind_password", ""))
|
bind_password = str(ldap.get("bind_password", ""))
|
||||||
|
|
||||||
ldap_logger.info(
|
ldap_logger.info("LDAP login lookup started: username=%r", username)
|
||||||
"Anmeldeversuch Benutzer=%r Server=%s Port=%s SSL=%s StartTLS=%s BaseDN=%r BindDN=%r Passwortvariable=%r gesetzt=%s",
|
if debug_logging:
|
||||||
username, server_name, port, use_ssl, start_tls, base_dn, bind_dn, env_name, bool(bind_password),
|
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:
|
if not server_name:
|
||||||
ldap_logger.error("LDAP-Server ist nicht konfiguriert")
|
ldap_logger.error("LDAP authentication unavailable: server is not configured")
|
||||||
return None
|
return None
|
||||||
if not base_dn:
|
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
|
return None
|
||||||
if not password:
|
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
|
return None
|
||||||
if bind_dn and not bind_password:
|
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
|
return None
|
||||||
|
|
||||||
server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10)
|
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:
|
if start_tls and not use_ssl:
|
||||||
search_connection.open()
|
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():
|
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
|
return None
|
||||||
ldap_logger.debug("StartTLS erfolgreich")
|
if debug_logging:
|
||||||
|
ldap_logger.debug("LDAP StartTLS completed")
|
||||||
|
|
||||||
if not search_connection.bind():
|
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
|
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})"))
|
raw_filter = str(ldap.get("user_filter", "(sAMAccountName={username})"))
|
||||||
escaped_username = escape_filter_chars(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"))
|
display_attr = str(ldap.get("display_name_attribute", "displayName"))
|
||||||
email_attr = str(ldap.get("email_attribute", "mail"))
|
email_attr = str(ldap.get("email_attribute", "mail"))
|
||||||
attributes = list(dict.fromkeys([display_attr, email_attr, "distinguishedName"]))
|
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(
|
search_ok = search_connection.search(
|
||||||
base_dn,
|
base_dn,
|
||||||
@@ -1127,22 +1144,25 @@ def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict
|
|||||||
search_scope=SUBTREE,
|
search_scope=SUBTREE,
|
||||||
attributes=attributes,
|
attributes=attributes,
|
||||||
)
|
)
|
||||||
ldap_logger.info(
|
entry_count = len(search_connection.entries)
|
||||||
"Benutzersuche beendet: success=%s Treffer=%s result=%r last_error=%r",
|
if debug_logging:
|
||||||
search_ok, len(search_connection.entries), search_connection.result, search_connection.last_error,
|
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 len(search_connection.entries) != 1:
|
if entry_count != 1:
|
||||||
if len(search_connection.entries) == 0:
|
ldap_logger.info("LDAP user lookup completed: username=%r found=no", username)
|
||||||
ldap_logger.warning("Kein LDAP-Benutzer für Benutzer=%r gefunden", username)
|
if debug_logging and entry_count > 1:
|
||||||
else:
|
ldap_logger.debug("LDAP user lookup returned multiple entries: username=%r entries=%s", username, entry_count)
|
||||||
ldap_logger.warning("Mehrere LDAP-Benutzer für Benutzer=%r gefunden: %s", username, len(search_connection.entries))
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
ldap_logger.info("LDAP user lookup completed: username=%r found=yes", username)
|
||||||
entry = search_connection.entries[0]
|
entry = search_connection.entries[0]
|
||||||
user_dn = entry.entry_dn
|
user_dn = entry.entry_dn
|
||||||
display_name = str(getattr(entry, display_attr, "") or username)
|
display_name = str(getattr(entry, display_attr, "") or username)
|
||||||
email = str(getattr(entry, email_attr, "") or "")
|
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(
|
user_connection = Connection(
|
||||||
server,
|
server,
|
||||||
@@ -1155,19 +1175,27 @@ def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict
|
|||||||
if start_tls and not use_ssl:
|
if start_tls and not use_ssl:
|
||||||
user_connection.open()
|
user_connection.open()
|
||||||
if not user_connection.start_tls():
|
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
|
return None
|
||||||
if not user_connection.bind():
|
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
|
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}
|
return {"username": username, "display_name": display_name, "email": email}
|
||||||
except LDAPException as exc:
|
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
|
return None
|
||||||
except Exception as exc:
|
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
|
return None
|
||||||
finally:
|
finally:
|
||||||
for connection in (user_connection, search_connection):
|
for connection in (user_connection, search_connection):
|
||||||
@@ -1690,10 +1718,10 @@ def startup():
|
|||||||
db.commit()
|
db.commit()
|
||||||
_seed_field_definitions(db)
|
_seed_field_definitions(db)
|
||||||
|
|
||||||
# Frühere Excel-Importe konnten Datumswerte als
|
# Earlier Excel imports could store date values as
|
||||||
# "YYYY-MM-DD 00:00:00" speichern. Browser zeigen solche Werte in
|
# "YYYY-MM-DD 00:00:00". Browsers render such values as empty in
|
||||||
# <input type="date"> leer an. Bestehende System-Datumsfelder werden
|
# <input type="date">. Existing system date fields are therefore
|
||||||
# deshalb einmalig auf YYYY-MM-DD normalisiert.
|
# normalized to YYYY-MM-DD once.
|
||||||
date_repairs = 0
|
date_repairs = 0
|
||||||
for asset in db.query(Asset).filter(
|
for asset in db.query(Asset).filter(
|
||||||
(Asset.purchase_date.like("% %")) |
|
(Asset.purchase_date.like("% %")) |
|
||||||
@@ -1756,7 +1784,7 @@ def stop_mesh_presence_service():
|
|||||||
|
|
||||||
|
|
||||||
def delete_asset_image(image_path: str | None) -> None:
|
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/"):
|
if not image_path or not image_path.startswith("/static/uploads/"):
|
||||||
return
|
return
|
||||||
filename = Path(image_path).name
|
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:
|
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()
|
workbook = Workbook()
|
||||||
sheet = workbook.active
|
sheet = workbook.active
|
||||||
@@ -1854,7 +1882,7 @@ def _excel_cell_value(value) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_date_value(value: Any) -> 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, ""):
|
if value in (None, ""):
|
||||||
return None
|
return None
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
@@ -1864,10 +1892,10 @@ def _normalize_date_value(value: Any) -> str | None:
|
|||||||
text = str(value).strip()
|
text = str(value).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
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):
|
if re.match(r"^\d{4}-\d{2}-\d{2}(?:[ T].*)?$", text):
|
||||||
return text[:10]
|
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)
|
match = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+.*)?$", text)
|
||||||
if match:
|
if match:
|
||||||
day, month, year = match.groups()
|
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:
|
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, ""):
|
if value in (None, ""):
|
||||||
return None
|
return None
|
||||||
if isinstance(value, datetime):
|
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]:
|
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] = []
|
result: list[CategoryField] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for category in categories:
|
for category in categories:
|
||||||
@@ -2025,7 +2053,7 @@ CHART_TYPES = {
|
|||||||
|
|
||||||
|
|
||||||
def _parse_value_mappings(text_value: str) -> list[dict[str, str]]:
|
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]] = []
|
mappings: list[dict[str, str]] = []
|
||||||
for line_number, raw_line in enumerate((text_value or "").splitlines(), start=1):
|
for line_number, raw_line in enumerate((text_value or "").splitlines(), start=1):
|
||||||
line = raw_line.strip()
|
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:
|
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()
|
text = value.strip()
|
||||||
lower = text.casefold()
|
lower = text.casefold()
|
||||||
if field == "operating_system":
|
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:
|
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()
|
lower = value.casefold()
|
||||||
for entry in chart.value_mappings or []:
|
for entry in chart.value_mappings or []:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
@@ -2914,7 +2942,7 @@ async def assets_import(
|
|||||||
else:
|
else:
|
||||||
allowed_fields = set(definitions_by_name) | {"name"}
|
allowed_fields = set(definitions_by_name) | {"name"}
|
||||||
|
|
||||||
# Leere Zellen werden bewusst nicht in Updates übernommen.
|
# Empty cells are intentionally not applied to updates.
|
||||||
data = {
|
data = {
|
||||||
name: value
|
name: value
|
||||||
for name, value in raw_data.items()
|
for name, value in raw_data.items()
|
||||||
@@ -3005,7 +3033,7 @@ async def assets_import(
|
|||||||
})
|
})
|
||||||
continue
|
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"):
|
if not system_data.get("name"):
|
||||||
message = "Für ein neues Asset fehlt die Bezeichnung"
|
message = "Für ein neues Asset fehlt die Bezeichnung"
|
||||||
errors.append(f"Zeile {excel_row_number}: {message}")
|
errors.append(f"Zeile {excel_row_number}: {message}")
|
||||||
@@ -3083,12 +3111,12 @@ def asset_tree(
|
|||||||
hide_without_parent: str | None = None,
|
hide_without_parent: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Zeigt die gefilterte Gerätehierarchie.
|
"""Display the filtered device hierarchy.
|
||||||
|
|
||||||
Mehrere Kategorien werden als wiederholte Query-Parameter ``category_id``
|
Multiple categories are passed as repeated ``category_id`` query parameters.
|
||||||
übergeben. Filter werden auf jedes Gerät angewendet, nicht nur auf die
|
Filters apply to every device, not only to root nodes. If the immediate parent
|
||||||
Wurzelknoten. Wenn der direkte Elternknoten ausgefiltert ist, wird das
|
node is filtered out, the device is rendered as a temporary root so it remains
|
||||||
passende Gerät in der Darstellung als eigener Wurzelknoten angezeigt.
|
visible in the hierarchy.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
parsed_root_id = int(root_id) if root_id not in (None, "") else None
|
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:
|
if category_id not in selected_category_ids:
|
||||||
selected_category_ids.append(category_id)
|
selected_category_ids.append(category_id)
|
||||||
|
|
||||||
# Zuerst alle für den angemeldeten Benutzer sichtbaren Assets laden. Die
|
# First load all assets visible to the signed-in user. Filtering
|
||||||
# Filterung erfolgt anschließend in Python, damit Treffer in beliebiger
|
# is then performed in Python so matches at any hierarchy
|
||||||
# Hierarchietiefe nicht durch eine SQL-Abfrage von ihren Eltern getrennt
|
# depth are not separated from their parents by a SQL query
|
||||||
# oder versehentlich nur auf Wurzelknoten beschränkt werden.
|
# or accidentally restricted to root nodes only.
|
||||||
all_assets = (
|
all_assets = (
|
||||||
_apply_asset_access(db.query(Asset), request)
|
_apply_asset_access(db.query(Asset), request)
|
||||||
.options(joinedload(Asset.category))
|
.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
|
parent_key = asset.parent_asset_id if asset.parent_asset_id in all_by_id else None
|
||||||
all_children.setdefault(parent_key, []).append(asset)
|
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]
|
scope_ids: set[int]
|
||||||
selected_root = all_by_id.get(parsed_root_id) if parsed_root_id else None
|
selected_root = all_by_id.get(parsed_root_id) if parsed_root_id else None
|
||||||
if selected_root:
|
if selected_root:
|
||||||
@@ -3146,8 +3174,8 @@ def asset_tree(
|
|||||||
if category_matches and name_matches:
|
if category_matches and name_matches:
|
||||||
visible_assets.append(asset)
|
visible_assets.append(asset)
|
||||||
|
|
||||||
# Der explizit gewählte oberste Knoten bleibt als Orientierung sichtbar,
|
# Keep the explicitly selected top-level node visible for orientation,
|
||||||
# auch wenn er selbst den Namens- oder Kategorienfilter nicht erfüllt.
|
# even when it does not itself match the name or category filter.
|
||||||
if selected_root and selected_root not in visible_assets:
|
if selected_root and selected_root not in visible_assets:
|
||||||
visible_assets.insert(0, selected_root)
|
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"}
|
hide_unassigned = str(hide_without_parent or "").lower() in {"1", "true", "on", "yes"}
|
||||||
if selected_root:
|
if selected_root:
|
||||||
roots = [selected_root]
|
roots = [selected_root]
|
||||||
# Treffer, deren Zwischenknoten weggefiltert wurden, zusätzlich als
|
# Also render matches whose intermediate nodes were filtered out as
|
||||||
# eigene Wurzeln darstellen, damit kein Treffer verloren geht.
|
# separate roots so no match is lost.
|
||||||
roots.extend(
|
roots.extend(
|
||||||
asset for asset in by_parent.get(None, [])
|
asset for asset in by_parent.get(None, [])
|
||||||
if asset.id != selected_root.id
|
if asset.id != selected_root.id
|
||||||
@@ -3172,9 +3200,9 @@ def asset_tree(
|
|||||||
roots = list(by_parent.get(None, []))
|
roots = list(by_parent.get(None, []))
|
||||||
|
|
||||||
if hide_unassigned:
|
if hide_unassigned:
|
||||||
# Nur echte Einzelgeräte ohne hinterlegtes übergeordnetes Gerät
|
# Hide only genuine standalone devices without a stored parent device.
|
||||||
# ausblenden. Geräte, die lediglich wegen eines Filters als Wurzel
|
# Devices that appear as roots only because of filtering
|
||||||
# erscheinen, bleiben sichtbar. Ein gewählter oberster Knoten bleibt.
|
# remain visible. A selected top-level node also remains visible.
|
||||||
roots = [
|
roots = [
|
||||||
asset for asset in roots
|
asset for asset in roots
|
||||||
if asset.id == parsed_root_id
|
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)
|
new_image = save_upload(image)
|
||||||
if new_image:
|
if new_image:
|
||||||
category.image_path = new_image
|
category.image_path = new_image
|
||||||
# Vorhandene Definitionen explizit löschen und flushen. Das verhindert
|
# Explicitly delete and flush existing definitions. This prevents
|
||||||
# UniqueConstraint-Verletzungen bei anschließend identischen Feldnamen.
|
# unique-constraint violations when identical field names are inserted afterwards.
|
||||||
db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False)
|
db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False)
|
||||||
db.flush()
|
db.flush()
|
||||||
definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all()
|
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()
|
user = db.query(User).filter(func.lower(User.username) == username.casefold()).first()
|
||||||
valid = False
|
valid = False
|
||||||
|
|
||||||
# Ein geschütztes lokales Konto wird auch im LDAP-Modus ausschließlich
|
# A protected local account is authenticated only against its local password,
|
||||||
# gegen sein lokales Passwort geprüft. Bei falschem Passwort gibt es
|
# even in LDAP mode. If the password is incorrect, there is deliberately
|
||||||
# bewusst keinen LDAP-Fallback für denselben Benutzernamen.
|
# no LDAP fallback for the same username.
|
||||||
protected_local = bool(
|
protected_local = bool(
|
||||||
user
|
user
|
||||||
and getattr(user, "is_protected", False)
|
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]:
|
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
|
An empty query returns all active user accounts. A search term matches
|
||||||
werden Benutzername, Anzeigename und E-Mail durchsucht.
|
usernames, display names, and email addresses.
|
||||||
"""
|
"""
|
||||||
from ldap3 import ALL, Connection, Server, SUBTREE
|
from ldap3 import ALL, Connection, Server, SUBTREE
|
||||||
from ldap3.core.exceptions import LDAPException
|
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()
|
raw_query = query_text.strip()
|
||||||
|
|
||||||
def ldap_wildcard_pattern(value: str) -> str:
|
def ldap_wildcard_pattern(value: str) -> str:
|
||||||
# Benutzer-Wildcards zulassen, sonstige LDAP-Sonderzeichen aber sicher maskieren.
|
# Allow user wildcards while safely escaping all other LDAP special characters.
|
||||||
# * = beliebig viele Zeichen.
|
# * matches any number of characters.
|
||||||
parts = []
|
parts = []
|
||||||
literal = []
|
literal = []
|
||||||
for char in value:
|
for char in value:
|
||||||
@@ -6625,7 +6653,7 @@ def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]:
|
|||||||
if literal:
|
if literal:
|
||||||
parts.append(escape_filter_chars("".join(literal)))
|
parts.append(escape_filter_chars("".join(literal)))
|
||||||
pattern = "".join(parts)
|
pattern = "".join(parts)
|
||||||
# Ohne explizite Wildcard bleibt die bisherige Teilstringsuche erhalten.
|
# Without an explicit wildcard, retain the existing substring search.
|
||||||
if "*" not in value:
|
if "*" not in value:
|
||||||
pattern = f"*{pattern}*"
|
pattern = f"*{pattern}*"
|
||||||
return pattern
|
return pattern
|
||||||
@@ -6651,8 +6679,8 @@ def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]:
|
|||||||
raise_exceptions=False,
|
raise_exceptions=False,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# ldap3.Connection.open() liefert je nach Version keinen verlässlichen
|
# Depending on the version, ldap3.Connection.open() does not return a reliable
|
||||||
# booleschen Rückgabewert. Daher nur öffnen und anschließend binden.
|
# Boolean result. Open the connection and then bind explicitly.
|
||||||
if start_tls and not use_ssl:
|
if start_tls and not use_ssl:
|
||||||
conn.open()
|
conn.open()
|
||||||
if not conn.start_tls():
|
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 ""),
|
"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()))
|
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
|
return result
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except LDAPException as exc:
|
except LDAPException as exc:
|
||||||
ldap_logger.exception("LDAP-Ausnahme bei Benutzerabfrage: %s", exc)
|
ldap_logger.error("LDAP directory search failed because of an LDAP error")
|
||||||
raise HTTPException(400, f"LDAP-Abfrage fehlgeschlagen: {exc}") from exc
|
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:
|
except Exception as exc:
|
||||||
ldap_logger.exception("Unerwarteter LDAP-Fehler bei Benutzerabfrage: %s", exc)
|
ldap_logger.error("LDAP directory search failed because of an unexpected error")
|
||||||
raise HTTPException(400, f"LDAP-Abfrage fehlgeschlagen: {exc}") from exc
|
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:
|
finally:
|
||||||
try:
|
try:
|
||||||
conn.unbind()
|
conn.unbind()
|
||||||
@@ -6764,7 +6798,7 @@ def ldap_users_import(
|
|||||||
continue
|
continue
|
||||||
user = db.query(User).filter(func.lower(User.username) == item["username"].casefold()).first()
|
user = db.query(User).filter(func.lower(User.username) == item["username"].casefold()).first()
|
||||||
if user:
|
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":
|
if user.auth_source != "ldap":
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
@@ -7247,6 +7281,7 @@ async def settings_save(
|
|||||||
ldap_user_filter: str = Form("(sAMAccountName={username})"),
|
ldap_user_filter: str = Form("(sAMAccountName={username})"),
|
||||||
ldap_display_name_attribute: str = Form("displayName"),
|
ldap_display_name_attribute: str = Form("displayName"),
|
||||||
ldap_email_attribute: str = Form("mail"),
|
ldap_email_attribute: str = Form("mail"),
|
||||||
|
ldap_debug_logging: str | None = Form(None),
|
||||||
remove_logo: str | None = Form(None),
|
remove_logo: str | None = Form(None),
|
||||||
remove_favicon: str | None = Form(None),
|
remove_favicon: str | None = Form(None),
|
||||||
logo: UploadFile | None = File(None),
|
logo: UploadFile | None = File(None),
|
||||||
@@ -7325,6 +7360,7 @@ async def settings_save(
|
|||||||
"user_filter": ldap_user_filter.strip() or "(sAMAccountName={username})",
|
"user_filter": ldap_user_filter.strip() or "(sAMAccountName={username})",
|
||||||
"display_name_attribute": ldap_display_name_attribute.strip() or "displayName",
|
"display_name_attribute": ldap_display_name_attribute.strip() or "displayName",
|
||||||
"email_attribute": ldap_email_attribute.strip() or "mail",
|
"email_attribute": ldap_email_attribute.strip() or "mail",
|
||||||
|
"debug_logging": ldap_debug_logging == "on",
|
||||||
})
|
})
|
||||||
authentication["ldap"] = ldap
|
authentication["ldap"] = ldap
|
||||||
meshcentral = dict(current.get("meshcentral", {}))
|
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',
|
'store_source_json': form.get('store_source_json') == 'on',
|
||||||
})
|
})
|
||||||
mesh['match_order'] = form.getlist('match_order') or ['node_id', 'serial_number']
|
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('password', None)
|
||||||
mesh.pop('field_mappings', None)
|
mesh.pop('field_mappings', None)
|
||||||
mesh.pop('field_rules', None)
|
mesh.pop('field_rules', None)
|
||||||
|
|||||||
+20
-20
@@ -77,7 +77,7 @@ def _normalize_mac(value: Any) -> str | None:
|
|||||||
def _extract_devices(payload: Any) -> list[dict[str, Any]]:
|
def _extract_devices(payload: Any) -> list[dict[str, Any]]:
|
||||||
if isinstance(payload, list):
|
if isinstance(payload, list):
|
||||||
direct = [item for item in payload if isinstance(item, dict)]
|
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]] = []
|
nested: list[dict[str, Any]] = []
|
||||||
for item in payload:
|
for item in payload:
|
||||||
if isinstance(item, (list, dict)):
|
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:
|
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.lstrip("\ufeff")
|
||||||
output = output.replace("\x00", "")
|
output = output.replace("\x00", "")
|
||||||
return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output)
|
return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output)
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_output(output: str) -> Any:
|
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()
|
cleaned = _strip_terminal_noise(output).strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
raise ValueError("MeshCtrl lieferte eine leere Ausgabe.")
|
raise ValueError("MeshCtrl lieferte eine leere Ausgabe.")
|
||||||
@@ -137,7 +137,7 @@ def _parse_json_output(output: str) -> Any:
|
|||||||
values.append(value)
|
values.append(value)
|
||||||
position = end
|
position = end
|
||||||
except json.JSONDecodeError as exc:
|
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_start = max(0, exc.pos - 120)
|
||||||
context_end = min(len(cleaned), exc.pos + 120)
|
context_end = min(len(cleaned), exc.pos + 120)
|
||||||
context = cleaned[context_start:context_end].replace("\n", "\\n")
|
context = cleaned[context_start:context_end].replace("\n", "\\n")
|
||||||
@@ -175,7 +175,7 @@ def _run_meshctrl(
|
|||||||
command.append("--noverify")
|
command.append("--noverify")
|
||||||
|
|
||||||
# stdout/stderr bewusst direkt in Dateien schreiben. Dadurch umgehen wir
|
# 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 = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics"))
|
||||||
diagnostic_dir.mkdir(parents=True, exist_ok=True)
|
diagnostic_dir.mkdir(parents=True, exist_ok=True)
|
||||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
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")
|
stdout_text = stdout_bytes.decode("utf-8", errors="replace")
|
||||||
stderr_text = stderr_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
|
# Remove empty stderr files. Successful high-frequency queries such as the
|
||||||
# Präsenzprüfung können ihre stdout-Rohdatei nach dem Einlesen verwerfen.
|
# presence check may discard their raw stdout file after it has been parsed.
|
||||||
# Bei MeshCtrl-Fehlern bleibt die Datei unabhängig von retain_stdout erhalten.
|
# For MeshCtrl errors, the file is retained regardless of retain_stdout.
|
||||||
if not stderr_bytes:
|
if not stderr_bytes:
|
||||||
stderr_path.unlink(missing_ok=True)
|
stderr_path.unlink(missing_ok=True)
|
||||||
if not retain_stdout and completed.returncode == 0:
|
if not retain_stdout and completed.returncode == 0:
|
||||||
@@ -220,7 +220,7 @@ def _looks_truncated(output: str) -> bool:
|
|||||||
cleaned = _strip_terminal_noise(output).rstrip()
|
cleaned = _strip_terminal_noise(output).rstrip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return False
|
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 "]}"
|
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:
|
except (ValueError, json.JSONDecodeError, RuntimeError) as exc:
|
||||||
attempts.append(("mit Details: " if include_details else "ohne Details: ") + str(exc))
|
attempts.append(("mit Details: " if include_details else "ohne Details: ") + str(exc))
|
||||||
log("warning", f"JSON-Auswertung {mode} fehlgeschlagen: {exc}")
|
log("warning", f"JSON-Auswertung {mode} fehlgeschlagen: {exc}")
|
||||||
# Manche MeshCentral-/MeshCtrl-Versionen liefern bei --details einen
|
# Some MeshCentral or MeshCtrl versions return a
|
||||||
# beschädigten sehr großen JSON-Block. Dann automatisch Basisdaten abrufen.
|
# corrupted, very large JSON block with --details. Fall back to basic data automatically.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raise RuntimeError("MeshCentral-Import fehlgeschlagen. " + " | ".join(attempts))
|
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]:
|
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()]:
|
for alternative in [item.strip() for item in str(path or "").split("|") if item.strip()]:
|
||||||
values = [data]
|
values = [data]
|
||||||
for part in alternative.split('.'):
|
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:
|
def _coerce_system_value(target: str, value: Any) -> Any:
|
||||||
# Die meisten Asset-Spalten sind Textfelder. Integer-/Fremdschlüsselfelder
|
# Most asset columns are text fields. Integer and foreign-key fields
|
||||||
# werden typgerecht geschrieben, damit dynamische Mappings auch dort sicher sind.
|
# are written with the appropriate type so dynamic mappings remain safe there as well.
|
||||||
if target in {"mesh_mtype", "parent_asset_id", "category_id"}:
|
if target in {"mesh_mtype", "parent_asset_id", "category_id"}:
|
||||||
try:
|
try:
|
||||||
return int(value) if value not in (None, "") else None
|
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]:
|
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] = {}
|
mapped: dict[str, Any] = {}
|
||||||
for mapping in field_mappings or []:
|
for mapping in field_mappings or []:
|
||||||
if not isinstance(mapping, dict) or not mapping.get('enabled', True):
|
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
|
continue
|
||||||
value = _mapped_value(raw, mapping)
|
value = _mapped_value(raw, mapping)
|
||||||
if value not in (None, '', [], {}):
|
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
|
mapped[target] = value
|
||||||
return mapped
|
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]] = {}
|
history_changes: dict[str, dict[str, str]] = {}
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
# Ausschließlich die aktiven Mapping-Regeln bestimmen, welche
|
# Only enabled mapping rules determine which
|
||||||
# System- und Custom-Felder synchronisiert werden. Es gibt keine
|
# system and custom fields are synchronized. There is no
|
||||||
# feste Feld-Whitelist mehr.
|
# feste Feld-Whitelist mehr.
|
||||||
for target, incoming in mapped.items():
|
for target, incoming in mapped.items():
|
||||||
if target == "mesh_device_type" or incoming in (None, "", [], {}):
|
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)
|
definition = field_definitions.get(target)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
# Das Zielfeld muss im zentralen Feldkatalog existieren.
|
# The target field must exist in the central field catalog.
|
||||||
# Die Prüfung wird zusätzlich vor der Geräteschleife protokolliert.
|
# This validation is also logged before the device loop starts.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rule = mapping_rules.get(target, "fill_empty")
|
rule = mapping_rules.get(target, "fill_empty")
|
||||||
|
|||||||
+1
-1
@@ -194,7 +194,7 @@ def apply_lightweight_migrations() -> None:
|
|||||||
for name, ddl in CHART_COLUMNS.items():
|
for name, ddl in CHART_COLUMNS.items():
|
||||||
if name not in existing:
|
if name not in existing:
|
||||||
connection.execute(text(f'ALTER TABLE chart_definitions ADD COLUMN "{name}" {ddl}'))
|
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("""
|
connection.execute(text("""
|
||||||
UPDATE chart_definitions
|
UPDATE chart_definitions
|
||||||
SET value_mode = 'automatic'
|
SET value_mode = 'automatic'
|
||||||
|
|||||||
+3
-3
@@ -74,9 +74,9 @@ def refresh_mesh_presence() -> dict[str, int]:
|
|||||||
if not config:
|
if not config:
|
||||||
return {"checked": 0, "online": 0, "offline": 0, "unknown": 0}
|
return {"checked": 0, "online": 0, "offline": 0, "unknown": 0}
|
||||||
|
|
||||||
# Die Präsenzprüfung läuft standardmäßig alle 30 Sekunden. Ihre erfolgreiche
|
# The presence check runs every 30 seconds by default. Its successful
|
||||||
# ListDevices-Ausgabe wird nach dem Einlesen nicht als Zeitstempeldatei behalten.
|
# ListDevices output is not retained as a timestamped file after parsing.
|
||||||
# Bei einem Parsefehler speichern wir nur eine einzige überschreibbare Fehlerdatei.
|
# On a parse error, retain only one replaceable diagnostic file.
|
||||||
result = _run_meshctrl(
|
result = _run_meshctrl(
|
||||||
config,
|
config,
|
||||||
"ListDevices",
|
"ListDevices",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ callback() {
|
|||||||
}
|
}
|
||||||
trap 'code=$?; callback failed "$code" "Job failed" || true; exit "$code"' ERR
|
trap 'code=$?; callback failed "$code" "Job failed" || true; exit "$code"' ERR
|
||||||
echo "$(date --iso-8601=seconds) job=${JOB_ID} started"
|
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'
|
callback success 0 'Job completed'
|
||||||
|
|||||||
@@ -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_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>
|
<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="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>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = "0.5.5.28"
|
APP_VERSION = "0.5.5.29"
|
||||||
__version__ = APP_VERSION
|
__version__ = APP_VERSION
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
Release notes are stored outside the project root to keep the repository overview compact.
|
Release notes are stored outside the project root to keep the repository overview compact.
|
||||||
|
|
||||||
The current release is **0.5.5.28**.
|
The current release is **0.5.5.29**.
|
||||||
|
|
||||||
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
|
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [0.5.5.29](UPDATE-0.5.5.29.md)
|
||||||
- [0.5.5.28](UPDATE-0.5.5.28.md)
|
- [0.5.5.28](UPDATE-0.5.5.28.md)
|
||||||
- [0.5.5.27](UPDATE-0.5.5.27.md)
|
- [0.5.5.27](UPDATE-0.5.5.27.md)
|
||||||
- [0.5.5.26](UPDATE-0.5.5.26.md)
|
- [0.5.5.26](UPDATE-0.5.5.26.md)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# AssetManager 0.5.5.29
|
||||||
|
|
||||||
|
- Added a configurable LDAP debug-logging option under authentication settings.
|
||||||
|
- Reduced normal LDAP logs to the searched username, lookup result, and authentication result.
|
||||||
|
- Detailed LDAP connection settings, search filters, distinguished names, attributes, provider results, and stack traces are written only while LDAP debug logging is enabled.
|
||||||
|
- Set LDAP debug logging to disabled by default for new configurations.
|
||||||
|
- Translated remaining German source-code comments and developer docstrings into English.
|
||||||
|
- Kept localized user-interface text and translations unchanged.
|
||||||
|
- No database schema changes were introduced.
|
||||||
Reference in New Issue
Block a user