from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone @dataclass(slots=True) class PortInfo: port: int protocol: str = "tcp" state: str = "open" service: str = "" product: str = "" version: str = "" @property def label(self) -> str: base = f"{self.port}/{self.protocol}" if self.service: base += f" ({self.service})" return base @property def details(self) -> str: parts = [self.label] product = " ".join(part for part in (self.product, self.version) if part).strip() if product: parts.append(product) return " — ".join(parts) @dataclass(slots=True) class Host: ip: str hostname: str = "" mac: str = "" vendor: str = "" status: str = "up" os_name: str = "" os_accuracy: int | None = None latency_ms: float | None = None ports: list[PortInfo] = field(default_factory=list) last_seen: str = field(default_factory=lambda: datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")) device_type: str = "Hôte" change_status: str = "" change_detail: str = "" previous_ip: str = "" is_local: bool = False # V0.4.9 : la meilleure identification connue est volontairement séparée # des données du scan courant. Ainsi un scan Standard peut afficher un OS # découvert précédemment en Approfondi sans prétendre l'avoir redétecté. remembered_os_name: str = "" remembered_os_accuracy: int | None = None remembered_device_type: str = "" remembered_os_source: str = "" remembered_type_source: str = "" remembered_os_seen_at: str = "" remembered_type_seen_at: str = "" remembered_match_score: int = 0 remembered_match_reason: str = "" remembered_identity_kind: str = "" def merge(self, other: "Host") -> "Host": if other.hostname: self.hostname = other.hostname if other.mac: self.mac = other.mac.upper() if other.vendor: self.vendor = other.vendor if other.os_name: self.os_name = other.os_name if other.os_accuracy is not None: self.os_accuracy = other.os_accuracy if other.latency_ms is not None: self.latency_ms = other.latency_ms if other.ports: known = {(p.port, p.protocol): p for p in self.ports} for port in other.ports: known[(port.port, port.protocol)] = port self.ports = sorted(known.values(), key=lambda p: (p.protocol, p.port)) if other.device_type and other.device_type != "Hôte": self.device_type = other.device_type self.is_local = self.is_local or other.is_local self.status = other.status or self.status self.last_seen = other.last_seen or self.last_seen # Les informations mémorisées peuvent arriver avant ou après les données # Nmap du scan courant. On les fusionne sans jamais écraser une valeur déjà # plus complète portée par l'objet courant. if other.remembered_os_name: self.remembered_os_name = other.remembered_os_name self.remembered_os_accuracy = other.remembered_os_accuracy self.remembered_os_source = other.remembered_os_source self.remembered_os_seen_at = other.remembered_os_seen_at if other.remembered_device_type: self.remembered_device_type = other.remembered_device_type self.remembered_type_source = other.remembered_type_source self.remembered_type_seen_at = other.remembered_type_seen_at if other.remembered_match_score: self.remembered_match_score = other.remembered_match_score self.remembered_match_reason = other.remembered_match_reason self.remembered_identity_kind = other.remembered_identity_kind return self @staticmethod def _os_family(value: str) -> str: folded = (value or "").casefold() if "android" in folded: return "android" if any(token in folded for token in ( "macos", "mac os x", "darwin", "iphone os", "apple ios", "ipados", "apple tv", "tvos", )): return "apple" for token, family in ( ("openwrt", "linux"), ("proxmox", "linux"), ("synology", "linux"), ("debian", "linux"), ("ubuntu", "linux"), ("fedora", "linux"), ("centos", "linux"), ("red hat", "linux"), ("linux", "linux"), ("opnsense", "freebsd"), ("pfsense", "freebsd"), ("freebsd", "freebsd"), ("openbsd", "openbsd"), ("netbsd", "netbsd"), ("windows", "windows"), ("routeros", "routeros"), ("vmware", "vmware"), ("esxi", "vmware"), ("fortios", "fortios"), ("junos", "junos"), ("ios xe", "iosxe"), ("cisco ios", "ios"), ): if token in folded: return family return "" @property def effective_os_name(self) -> str: """OS affiché : le courant gagne en cas de contradiction, sinon le plus précis.""" current = (self.os_name or "").strip() remembered = (self.remembered_os_name or "").strip() if not current: return remembered if not remembered: return current current_family = self._os_family(current) remembered_family = self._os_family(remembered) if current_family and remembered_family and current_family != remembered_family: return current # Un scan Standard peut seulement redonner "Linux" alors qu'un ancien # Approfondi avait identifié OpenWrt/Debian/etc. Dans la même famille, on # garde la description la plus informative. if remembered_family and current_family == remembered_family and len(remembered) > len(current): return remembered return current @property def effective_device_type(self) -> str: """Type à afficher sans laisser un scan léger dégrader une identification riche.""" if self.is_local: return "Ce poste" if self.remembered_device_type and self.device_type in { "", "Hôte", "Hôte Linux", "Serveur SSH", "Serveur / appliance", "Équipement réseau", "Appliance Web" }: return self.remembered_device_type return self.device_type or self.remembered_device_type or "Hôte" @property def os_is_estimated(self) -> bool: """True quand Nmap a fourni une correspondance OS non exacte (< 100 %).""" return bool(self.os_name and self.os_accuracy is not None and self.os_accuracy < 100) @property def effective_os_accuracy(self) -> int | None: if self.os_is_remembered: return self.remembered_os_accuracy return self.os_accuracy @property def os_is_remembered(self) -> bool: return bool(self.remembered_os_name and self.effective_os_name == self.remembered_os_name and self.os_name != self.remembered_os_name) @property def type_is_remembered(self) -> bool: return bool( self.remembered_device_type and self.effective_device_type == self.remembered_device_type and self.device_type != self.remembered_device_type ) @property def ports_summary(self) -> str: return ", ".join(p.label for p in self.ports if p.state == "open") @property def ports_details(self) -> str: return "\n".join(p.details for p in self.ports if p.state == "open") @property def searchable_text(self) -> str: values = ( self.status, self.change_status, self.change_detail, self.device_type, self.effective_device_type, self.hostname, self.ip, self.previous_ip, self.mac, self.vendor, self.ports_summary, self.os_name, self.effective_os_name, ) return " ".join(v for v in values if v).casefold()