fmeshsync fix with synology,vm's and lxc

This commit is contained in:
root
2026-08-08 19:44:27 +02:00
parent 2786f12ab6
commit 5e32be7d0c
41 changed files with 1335 additions and 1674 deletions
+212 -13
View File
@@ -466,29 +466,126 @@ def _path_values(data: Any, path: str) -> list[Any]:
def _network_entries(value: Any) -> list[dict[str, Any]]:
"""Flatten MeshCentral network entries while retaining the interface name.
``net.netif2`` is keyed by interface name. Keeping that name allows us to
prefer real/primary interfaces over loopback, container and tunnel entries.
"""
entries: list[dict[str, Any]] = []
if isinstance(value, dict):
for group in value.values():
if isinstance(group, list):
entries.extend(item for item in group if isinstance(item, dict))
for adapter_name, group in value.items():
if not isinstance(group, list):
continue
for item in group:
if not isinstance(item, dict):
continue
entry = dict(item)
entry.setdefault('_adapter_name', str(adapter_name or ''))
entries.append(entry)
elif isinstance(value, list):
entries.extend(item for item in value if isinstance(item, dict))
entries.extend(dict(item) for item in value if isinstance(item, dict))
return entries
def _network_interface_rank(name: Any) -> int:
"""Return a stable preference rank for common physical/virtual NIC names."""
value = str(name or '').strip().casefold()
if not value:
return 80
# Explicitly de-prioritize interfaces that are usually local/container/tunnel
# plumbing and should not become the AssetManager management address.
low_priority = (
'loopback', 'docker', 'veth', 'virbr', 'tun', 'tap', 'tailscale',
'wg', 'zt', 'br-',
)
if value == 'lo' or value.startswith(low_priority):
return 200
# Common Linux, Windows and hypervisor interface names. Comparison is
# intentionally case-insensitive.
if value.startswith(('ethernet', 'eth')):
return 10
if value.startswith(('eno', 'ens', 'enp', 'enx')):
return 15
if value == 'vmbr0':
return 20
if value.startswith('vmbr'):
return 22
if value.startswith(('bond', 'team')):
return 25
if value.startswith(('wlan', 'wlp', 'wifi', 'wi-fi', 'wireless')):
return 30
if value.startswith(('vethernet', 'hyper-v', 'vmnet')):
return 35
if value.startswith('br'):
return 40
return 60
def _network_entry_active(entry: dict[str, Any]) -> bool:
status = str(entry.get('status') or '').strip().casefold()
return status not in {'down', 'inactive', 'disabled', 'disconnected', 'notpresent'}
def _valid_ipv4(value: Any) -> bool:
try:
import ipaddress
address = ipaddress.ip_address(str(value or '').strip())
except ValueError:
return False
return address.version == 4 and not address.is_loopback and not address.is_unspecified
def _valid_network_mac(value: Any) -> str | None:
mac = _normalize_mac(value)
if not mac:
return None
raw = re.sub(r'[^0-9A-Fa-f]', '', mac)
# Reject malformed, all-zero and broadcast placeholders. Locally administered
# addresses remain valid because they are common for virtual interfaces.
if len(raw) != 12 or set(raw.casefold()) == {'0'} or set(raw.casefold()) == {'f'}:
return None
return ':'.join(raw[index:index + 2] for index in range(0, 12, 2)).upper()
def _preferred_network_entries(value: Any) -> list[dict[str, Any]]:
"""Order usable network records by interface suitability and source order."""
candidates: list[tuple[int, int, dict[str, Any]]] = []
for index, entry in enumerate(_network_entries(value)):
if entry.get('type') == 'loopback' or not _network_entry_active(entry):
continue
rank = _network_interface_rank(entry.get('_adapter_name'))
candidates.append((rank, index, entry))
candidates.sort(key=lambda item: (item[0], item[1]))
return [entry for _, _, entry in candidates]
def _convert_mapping_value(values: list[Any], transform: str, separator: str) -> Any:
if not values:
return None
source = values[0]
if transform == 'active_ipv4_address':
for entry in _network_entries(source):
if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback':
return entry.get('address')
entries = _preferred_network_entries(source)
# Prefer a usable IPv4 address on well-known management interfaces.
for entry in entries:
if entry.get('family') == 'IPv4' and _valid_ipv4(entry.get('address')):
return str(entry.get('address')).strip()
return None
if transform == 'active_ipv4_mac':
for entry in _network_entries(source):
if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback':
return _normalize_mac(entry.get('mac'))
entries = _preferred_network_entries(source)
# Prefer the MAC from an interface that also has usable IPv4 data. This
# keeps IP and MAC aligned in the common case and avoids all-zero values.
for entry in entries:
if entry.get('family') == 'IPv4' and _valid_ipv4(entry.get('address')):
mac = _valid_network_mac(entry.get('mac'))
if mac:
return mac
# Some MeshCentral inventories expose MAC data in a separate record.
for entry in entries:
mac = _valid_network_mac(entry.get('mac'))
if mac:
return mac
return None
if transform == 'normalize_serial':
return _normalize_serial(source)
@@ -683,17 +780,72 @@ def _normalize_manufacturer(value: Any) -> str:
return "".join(tokens)
def _normalized_serial_value(value: Any) -> str:
if value in (None, ""):
return ""
return str(value).replace("\x00", "").strip().casefold()
def _invalid_serial_reason(mapped: dict[str, Any], cfg: dict[str, Any]) -> str | None:
"""Return a human-readable reason when a reported serial number is a configured placeholder."""
serial = _normalized_serial_value(mapped.get("serial_number"))
if not serial:
return None
global_values = {
_normalized_serial_value(value)
for value in (cfg.get("invalid_serial_values") or [])
if _normalized_serial_value(value)
}
if serial in global_values:
return "global configured placeholder"
manufacturer = _normalize_manufacturer(mapped.get("manufacturer"))
if manufacturer:
for rule in cfg.get("manufacturer_invalid_serial_rules") or []:
if not isinstance(rule, dict):
continue
if _normalize_manufacturer(rule.get("manufacturer")) != manufacturer:
continue
values = {
_normalized_serial_value(value)
for value in (rule.get("values") or [])
if _normalized_serial_value(value)
}
if serial in values:
return f"configured placeholder for {rule.get('manufacturer') or mapped.get('manufacturer')}"
return None
def _serial_identity_key(mapped: dict[str, Any], *, include_manufacturer: bool) -> tuple[str, str] | None:
serial = _normalized_serial_value(mapped.get("serial_number"))
if not serial:
return None
manufacturer = _normalize_manufacturer(mapped.get("manufacturer")) if include_manufacturer else ""
return serial, manufacturer
def _normalized_mac_identity(value: Any) -> str:
mac = _valid_network_mac(value)
return mac.casefold() if mac else ""
def _find_asset(
db: Session,
mapped: dict[str, Any],
order: list[str],
*,
serial_match_manufacturer: bool = False,
serial_is_invalid: bool = False,
serial_is_ambiguous: bool = False,
) -> tuple[Asset | None, str | None, str | None]:
incoming_mac = _normalized_mac_identity(mapped.get("mac_address"))
for method in order:
if method == "node_id" and mapped.get("mesh_node_id"):
matches = db.query(Asset).filter(Asset.mesh_node_id == mapped["mesh_node_id"]).all()
elif method == "serial_number" and mapped.get("serial_number"):
elif method == "mac_address" and incoming_mac:
matches = db.query(Asset).filter(func.lower(Asset.mac_address) == str(mapped.get("mac_address")).lower()).all()
elif method == "serial_number" and mapped.get("serial_number") and not serial_is_invalid and not serial_is_ambiguous:
matches = db.query(Asset).filter(func.lower(Asset.serial_number) == mapped["serial_number"].lower()).all()
if serial_match_manufacturer:
incoming_manufacturer = _normalize_manufacturer(mapped.get("manufacturer"))
@@ -703,6 +855,16 @@ def _find_asset(
asset for asset in matches
if _normalize_manufacturer(asset.manufacturer) == incoming_manufacturer
]
# A shared DMI/mainboard serial is common for containers and VMs.
# Never accept such a serial match when both sides have a valid but
# different MAC address. This avoids reassigning another guest's
# MeshCentral node ID.
if incoming_mac:
matches = [
asset for asset in matches
if not _normalized_mac_identity(asset.mac_address)
or _normalized_mac_identity(asset.mac_address) == incoming_mac
]
else:
continue
if len(matches) == 1:
@@ -928,12 +1090,34 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
log("error", f"Systemfeld '{target}' besitzt keine Asset-Datenbankspalte und wird übersprungen.")
run.error_count += 1
log("info", "Dry-Run gestartet; es werden keine Datenbankänderungen gespeichert." if dry_run else "Synchronisierung gestartet.")
log("info", "Asset-Zuordnung: " + "".join(cfg.get("match_order", ["node_id", "serial_number"])))
log("info", "Asset-Zuordnung: " + "".join(cfg.get("match_order", ["node_id", "mac_address", "serial_number"])))
if cfg.get("serial_match_manufacturer", False):
log("info", "Seriennummern-Abgleich erfordert zusätzlich einen normalisierten Herstellervergleich.")
invalid_global_count = len(cfg.get("invalid_serial_values") or [])
invalid_manufacturer_count = len(cfg.get("manufacturer_invalid_serial_rules") or [])
if invalid_global_count or invalid_manufacturer_count:
log("info", f"Dummy-Seriennummernfilter aktiv: {invalid_global_count} globale Werte, {invalid_manufacturer_count} Herstellerregel(n). Node-ID bleibt die stabile Geräteidentität.")
devices = fetch_devices(log)
run.devices_found = len(devices)
log("info", f"Verarbeite {len(devices)} Gerät(e).")
# Determine serial numbers that are already ambiguous within the current
# MeshCentral inventory. This is essential for containers/VMs where the
# host DMI/mainboard serial may be exposed identically to several guests.
serial_identity_counts: dict[tuple[str, str], int] = {}
for raw_for_identity in devices:
mapped_for_identity = map_device(raw_for_identity, field_mappings)
if _invalid_serial_reason(mapped_for_identity, cfg):
continue
identity_key = _serial_identity_key(
mapped_for_identity,
include_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
)
if identity_key:
serial_identity_counts[identity_key] = serial_identity_counts.get(identity_key, 0) + 1
ambiguous_serial_keys = {key for key, count in serial_identity_counts.items() if count > 1}
if ambiguous_serial_keys:
log("warning", f"{len(ambiguous_serial_keys)} Seriennummern-Identität(en) sind im aktuellen MeshCentral-Bestand mehrfach vorhanden und werden nicht zum Matching verwendet.")
fallback_id = int(cfg.get("fallback_category_id") or 1)
fallback_category = db.query(Category).filter(Category.id == fallback_id).first()
if fallback_category is None and fallback_id != 1:
@@ -954,11 +1138,24 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
log("error", "Gerät ohne Node-ID wurde übersprungen.")
continue
seen_node_ids.add(node_id)
invalid_serial_reason = _invalid_serial_reason(mapped, cfg)
serial_identity_key = _serial_identity_key(
mapped,
include_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
)
serial_is_ambiguous = bool(serial_identity_key and serial_identity_key in ambiguous_serial_keys)
if serial_is_ambiguous:
log("warning", f"{mapped.get('name', node_id)}: Seriennummer '{mapped.get('serial_number')}' ist im aktuellen MeshCentral-Bestand nicht eindeutig und wird nicht zum Matching verwendet. Node-ID/MAC haben Vorrang.")
if invalid_serial_reason:
reported_serial = mapped.get("serial_number")
log("warning", f"{mapped.get('name', node_id)}: gemeldete Seriennummer '{reported_serial}' als Dummy erkannt ({invalid_serial_reason}); Seriennummer wird weder zum Matching noch zum Überschreiben verwendet. Node-ID: {node_id}.")
asset, match_method, match_error = _find_asset(
db,
mapped,
cfg.get("match_order", ["node_id", "serial_number"]),
cfg.get("match_order", ["node_id", "mac_address", "serial_number"]),
serial_match_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
serial_is_invalid=bool(invalid_serial_reason),
serial_is_ambiguous=serial_is_ambiguous,
)
if match_error:
run.conflict_count += 1
@@ -997,6 +1194,8 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
for target, incoming in mapped.items():
if target == "mesh_device_type" or incoming in (None, "", [], {}):
continue
if target == "serial_number" and invalid_serial_reason:
continue
definition = field_definitions.get(target)
if definition is None: