fmeshsync fix with synology,vm's and lxc
This commit is contained in:
+212
-13
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user