from __future__ import annotations import ipaddress import shutil import subprocess import time from datetime import datetime from PySide6.QtCore import Qt, QThread, QUrl, Signal, QSize, QSettings, QTimer from PySide6.QtGui import QAction, QColor, QDesktopServices, QIcon from PySide6.QtWidgets import ( QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QFrame, QGroupBox, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSplitter, QStatusBar, QTableWidget, QTableWidgetItem, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from . import __version__ from .actions import send_magic_packet from .comparison import compare_hosts from .diagnostics import arp_scan_diagnostic from .fastscan import naabu_diagnostic from .exporters import export_csv, export_json from .identity import identity_key, normalize_mac, shared_macs from .intelligence import enrich_host from .models import Host, PortInfo from .online_vendor import ( PROVIDER_MACLOOKUP, PROVIDER_MACVENDORS, PROVIDERS, OnlineVendorError, OnlineVendorResult, is_locally_administered, lookup_online_vendor, provider_label, provider_min_interval, ) from .network import ( NetworkInterface, display_target, list_ipv4_interfaces, scan_identity_scope, target_address_count, target_is_on_interface, validate_target, ) from .privileges import privilege_diagnostic, privileged_command from .scanner import HostScanWorker, ScanRequest, ScanWorker, profile_signature from .storage import HistoryStore from .ui_layout import balanced_column_widths, compact_warning from .ui_icons import device_icon, os_icon, themed_icon ROLE_IP = Qt.UserRole ROLE_KIND = Qt.UserRole + 1 ROLE_PORT = Qt.UserRole + 2 KIND_HOST = "host" KIND_SERVICE = "service" class IPTreeWidgetItem(QTreeWidgetItem): def __lt__(self, other: QTreeWidgetItem) -> bool: tree = self.treeWidget() if tree is not None and tree.sortColumn() == 1: try: return ipaddress.ip_address(self.text(1)) < ipaddress.ip_address(other.text(1)) except ValueError: pass return super().__lt__(other) def _display_timestamp(value: str) -> str: if not value: return "" try: return datetime.fromisoformat(value).astimezone().strftime("%d/%m/%Y %H:%M:%S") except ValueError: return value def _port_url(host: Host, port: PortInfo | None = None) -> str | None: candidates = [port] if port else [p for p in host.ports if p.state == "open"] if not candidates: return None # Priorités adaptées aux interfaces d'administration usuelles. priorities = (8006, 8007, 5001, 443, 8443, 5000, 80, 8080) ordered = sorted(candidates, key=lambda p: priorities.index(p.port) if p.port in priorities else 999) for item in ordered: if item is None: continue if item.port in {443, 8443, 5001, 8006, 8007} or "https" in item.service.casefold(): suffix = "" if item.port == 443 else f":{item.port}" return f"https://{host.ip}{suffix}" if item.port in {80, 8080, 5000} or "http" in item.service.casefold(): suffix = "" if item.port == 80 else f":{item.port}" return f"http://{host.ip}{suffix}" return None class PrivilegeAuthWorker(QThread): result = Signal(bool, str) def run(self) -> None: try: proc = subprocess.run( privileged_command("authorize"), capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) except (OSError, RuntimeError) as exc: self.result.emit(False, str(exc)) return if proc.returncode == 0: self.result.emit(True, "Mode administrateur activé via Polkit") return detail = proc.stderr.strip() or proc.stdout.strip() if proc.returncode == 126: detail = "Authentification annulée ou refusée." elif not detail: detail = f"pkexec a quitté avec le code {proc.returncode}." self.result.emit(False, detail) class OnlineVendorLookupWorker(QThread): result = Signal(object) failed = Signal(str, str) # mac, message def __init__(self, macs: list[str], provider: str, parent=None) -> None: super().__init__(parent) self.macs = list(dict.fromkeys(macs)) self.provider = provider def run(self) -> None: interval = provider_min_interval(self.provider) for index, mac in enumerate(self.macs): if self.isInterruptionRequested(): return try: result = lookup_online_vendor(mac, self.provider) except OnlineVendorError as exc: self.failed.emit(mac, str(exc)) else: self.result.emit(result) if index + 1 < len(self.macs) and interval > 0: time.sleep(interval) class VendorSettingsDialog(QDialog): PROVIDER_LABELS = { PROVIDER_MACLOOKUP: "MACLookup.app — gratuit, sans clé", PROVIDER_MACVENDORS: "MACVendors.com — gratuit, sans clé", } def __init__(self, *, enabled: bool, provider: str, parent=None) -> None: super().__init__(parent) self.setWindowTitle("Identification des constructeurs") self.setMinimumWidth(520) layout = QVBoxLayout(self) title = QLabel("Recherche constructeur en ligne") title.setStyleSheet("font-size: 16px; font-weight: 600;") layout.addWidget(title) info = QLabel( "LibreNet utilise d'abord les bases OUI locales. Si cette option est activée, " "les adresses MAC encore inconnues sont envoyées au fournisseur sélectionné " "à la fin du scan. Les réponses sont mises en cache localement pendant 30 jours." ) info.setWordWrap(True) layout.addWidget(info) self.enabled_box = QCheckBox("Interroger automatiquement une base en ligne pour les MAC inconnues") self.enabled_box.setChecked(enabled) layout.addWidget(self.enabled_box) form = QFormLayout() self.provider_combo = QComboBox() for value in PROVIDERS: self.provider_combo.addItem(self.PROVIDER_LABELS[value], value) idx = self.provider_combo.findData(provider) self.provider_combo.setCurrentIndex(idx if idx >= 0 else 0) form.addRow("Fournisseur", self.provider_combo) layout.addLayout(form) privacy = QLabel( "Confidentialité : activer cette fonction transmet l'adresse MAC complète à un service Internet tiers. " "L'option est désactivée par défaut. Une MAC localement administrée (LAA) peut rester non identifiable, " "même avec une base en ligne." ) privacy.setWordWrap(True) privacy.setObjectName("mutedLabel") layout.addWidget(privacy) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @property def online_enabled(self) -> bool: return self.enabled_box.isChecked() @property def provider(self) -> str: value = self.provider_combo.currentData() return str(value) if value in PROVIDERS else PROVIDER_MACLOOKUP class HistoryDialog(QDialog): def __init__(self, store: HistoryStore, parent=None) -> None: super().__init__(parent) self.setWindowTitle("Historique des scans") self.resize(840, 440) layout = QVBoxLayout(self) info = QLabel("LibreNet compare un scan au précédent ayant la même cible, le même profil et le même mode de privilèges.") info.setWordWrap(True) layout.addWidget(info) table = QTableWidget(0, 5, self) table.setHorizontalHeaderLabels(["Date", "Cible", "Profil", "Hôtes", "ID"]) rows = store.recent_scans() table.setRowCount(len(rows)) for row_idx, row in enumerate(rows): values = [_display_timestamp(row["created_at"]), row["target"], row["profile"], str(row["host_count"]), str(row["id"])] for col, value in enumerate(values): table.setItem(row_idx, col, QTableWidgetItem(value)) table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) table.horizontalHeader().setStretchLastSection(True) layout.addWidget(table) class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle(f"LibreNet Scanner {__version__}") self.resize(1540, 860) self.setMinimumSize(1050, 650) self.hosts: dict[str, Host] = {} self.interfaces: list[NetworkInterface] = [] self.worker: ScanWorker | None = None self.host_worker: HostScanWorker | None = None self.store = HistoryStore() self.baseline_hosts: list[Host] | None = None self.baseline_date = "" self.scan_had_error = False self._routed_scan = False self.scan_warnings: list[str] = [] self.admin_mode = False self.auth_worker: PrivilegeAuthWorker | None = None self._pending_scan_after_auth = False self._pending_profile = "Standard" self.current_scan_privileged = False self.current_identity_scope = "" self.scan_started_at: datetime | None = None self._metadata_cache: dict[str, dict[str, object]] = {} self.settings = QSettings("LibreNet", "LibreNet Scanner") self._restoring_layout = True self._columns_user_customized = False self.vendor_lookup_worker: OnlineVendorLookupWorker | None = None self._vendor_lookup_manual = False self._build_ui() self._build_menu() self.refresh_interfaces() self._update_detail_panel(None) QTimer.singleShot(0, self._restore_ui_layout) # ---------- Construction de l'interface ---------- def _build_ui(self) -> None: central = QWidget(self) central.setObjectName("appRoot") outer = QVBoxLayout(central) outer.setContentsMargins(14, 12, 14, 10) outer.setSpacing(10) def section_title(text: str) -> QLabel: label = QLabel(text.upper()) label.setObjectName("sectionTitle") return label def separator() -> QFrame: line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setObjectName("separator") return line # Une feuille de style volontairement légère : elle s'appuie sur la palette # Qt/KDE afin de rester cohérente avec Breeze clair ou sombre. self.setStyleSheet( """ QWidget#appRoot { background: palette(window); } QFrame#scanCard, QFrame#detailPane { background: palette(base); border: 1px solid palette(midlight); border-radius: 10px; } QLabel#fieldLabel, QLabel#mutedLabel { color: palette(mid); } QLabel#fieldLabel { font-size: 10px; font-weight: 600; } QLabel#sectionTitle { color: palette(mid); font-size: 10px; font-weight: 700; } QLabel#detailName { font-size: 18px; font-weight: 600; } QLabel#detailType { color: palette(mid); } QLabel#valueLabel { font-weight: 500; } QFrame#separator { color: palette(midlight); background: palette(midlight); max-height: 1px; } QLineEdit, QPlainTextEdit { background: palette(base); border: 1px solid palette(midlight); border-radius: 7px; padding: 6px 8px; selection-background-color: palette(highlight); selection-color: palette(highlighted-text); } QLineEdit:focus, QPlainTextEdit:focus { border: 1px solid palette(highlight); } QPushButton#primaryScan { background: palette(highlight); color: palette(highlighted-text); border: none; border-radius: 8px; padding: 8px 16px; font-weight: 700; } QPushButton#primaryScan:disabled { background: palette(midlight); color: palette(mid); } QPushButton#stopButton, QToolButton#adminChip, QToolButton#filterChip, QToolButton#scanMenuButton, QPushButton#secondaryAction, QToolButton#moreAction, QPushButton#favoriteButton { background: palette(button); border: 1px solid palette(midlight); border-radius: 7px; padding: 6px 10px; } QToolButton#adminChip, QToolButton#filterChip { padding: 5px 10px; } QToolButton#scanMenuButton { padding: 0px; min-width: 36px; max-width: 36px; } QToolButton#scanMenuButton::menu-indicator { image: none; width: 0px; height: 0px; } QFrame#viewSwitch { background: palette(button); border: 1px solid palette(midlight); border-radius: 7px; } QToolButton#segmentButton { border: none; padding: 6px 10px; background: transparent; } QToolButton#segmentButton:checked { background: palette(highlight); color: palette(highlighted-text); border-radius: 5px; } QPushButton#primaryAction { background: palette(highlight); color: palette(highlighted-text); border: none; border-radius: 7px; padding: 7px 12px; font-weight: 600; } QPushButton#secondaryAction:hover, QToolButton#moreAction:hover, QPushButton#favoriteButton:hover, QToolButton#adminChip:hover, QToolButton#filterChip:hover, QToolButton#scanMenuButton:hover, QPushButton#stopButton:hover { border-color: palette(highlight); } QTreeWidget#deviceTree { background: palette(base); border: 1px solid palette(midlight); border-radius: 9px; outline: 0; alternate-background-color: palette(alternate-base); } QTreeWidget#deviceTree::item { min-height: 34px; padding: 4px 6px; border: 0; } QTreeWidget#deviceTree::item:selected { background: palette(highlight); color: palette(highlighted-text); } QTreeWidget#serviceList { border: 0; background: transparent; outline: 0; } QTreeWidget#serviceList::item { min-height: 26px; padding: 2px 3px; } QHeaderView::section { background: palette(window); border: none; border-bottom: 1px solid palette(midlight); padding: 7px 6px; font-weight: 600; } QFrame#noticeFrame { background: palette(alternate-base); border: 1px solid palette(midlight); border-radius: 7px; } QLabel#noticeLabel { background: transparent; border: none; } QProgressBar { border: 1px solid palette(midlight); background: palette(base); border-radius: 7px; min-height: 16px; max-height: 16px; text-align: center; font-size: 10px; } QProgressBar::chunk { background: palette(highlight); border-radius: 6px; } """ ) # --- Carte de scan ------------------------------------------------- scan_card = QFrame() scan_card.setObjectName("scanCard") scan_layout = QHBoxLayout(scan_card) scan_layout.setContentsMargins(12, 10, 12, 10) scan_layout.setSpacing(10) target_box = QVBoxLayout() target_box.setSpacing(3) target_label = QLabel("CIBLE") target_label.setObjectName("fieldLabel") self.target_edit = QLineEdit() self.target_edit.setMinimumHeight(34) self.target_edit.setPlaceholderText("192.168.1.0/24 ou 192.168.1.1 - 192.168.1.254") target_icon = themed_icon("network-server", "network-wired") if not target_icon.isNull(): self.target_edit.addAction(target_icon, QLineEdit.ActionPosition.LeadingPosition) target_box.addWidget(target_label) target_box.addWidget(self.target_edit) interface_box = QVBoxLayout() interface_box.setSpacing(3) interface_label = QLabel("INTERFACE RÉSEAU") interface_label.setObjectName("fieldLabel") self.interface_combo = QComboBox() self.interface_combo.setMinimumWidth(320) self.interface_combo.setMinimumHeight(34) self.interface_combo.currentIndexChanged.connect(self._interface_changed) interface_box.addWidget(interface_label) interface_box.addWidget(self.interface_combo) self.scan_btn = QPushButton("Scanner") self.scan_btn.setObjectName("primaryScan") self.scan_btn.setIcon(themed_icon("media-playback-start")) self.scan_btn.setIconSize(QSize(18, 18)) self.scan_btn.setFixedHeight(36) self.scan_btn.setMinimumWidth(118) self.scan_btn.clicked.connect(lambda: self.start_scan(profile="Standard")) # Bouton de menu volontairement indépendant du QMenu : sous Breeze, # associer setArrowType() + setMenu() fait dessiner deux indicateurs. # Ici Qt ne dessine qu'un seul chevron et nous ouvrons le menu nous-mêmes. self.scan_menu_btn = QToolButton() self.scan_menu_btn.setObjectName("scanMenuButton") self.scan_menu_btn.setArrowType(Qt.DownArrow) self.scan_menu_btn.setToolTip("Choisir un autre type de scan") self.scan_menu_btn.setFixedSize(36, 36) self.scan_menu = QMenu(self) standard = self.scan_menu.addAction(themed_icon("media-playback-start"), "Scan standard") standard.triggered.connect(lambda: self.start_scan(profile="Standard")) quick = self.scan_menu.addAction(themed_icon("system-run"), "Scan rapide — découverte uniquement") quick.triggered.connect(lambda: self.start_scan(profile="Rapide")) deep = self.scan_menu.addAction(themed_icon("system-search"), "Scan approfondi — services + OS") deep.triggered.connect(lambda: self.start_scan(profile="Approfondi")) self.scan_menu_btn.clicked.connect(self._show_scan_menu) self.stop_btn = QPushButton("Arrêter") self.stop_btn.setObjectName("stopButton") self.stop_btn.setIcon(themed_icon("process-stop")) self.stop_btn.setFixedHeight(36) self.stop_btn.setEnabled(False) self.stop_btn.clicked.connect(self.stop_scan) self.admin_btn = QToolButton() self.admin_btn.setObjectName("adminChip") self.admin_btn.setText("Standard") self.admin_btn.setIcon(themed_icon("object-locked")) self.admin_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.admin_btn.setFixedHeight(36) self.admin_btn.clicked.connect(self.toggle_admin_mode) action_box = QVBoxLayout() action_box.setSpacing(5) action_label = QLabel("ACTIONS") action_label.setObjectName("fieldLabel") action_row = QHBoxLayout() action_row.setSpacing(5) action_row.addWidget(self.scan_btn) action_row.addWidget(self.scan_menu_btn) action_row.addWidget(self.stop_btn) action_row.addWidget(self.admin_btn) action_box.addWidget(action_label) action_box.addLayout(action_row) scan_layout.addLayout(target_box, 3) scan_layout.addLayout(interface_box, 2) scan_layout.addLayout(action_box) outer.addWidget(scan_card) # --- Recherche / filtres ------------------------------------------ filter_bar = QHBoxLayout() filter_bar.setSpacing(8) self.filter_edit = QLineEdit() self.filter_edit.setClearButtonEnabled(True) self.filter_edit.setMinimumHeight(34) self.filter_edit.setPlaceholderText("Rechercher un nom, une IP, un service, une note…") search_icon = themed_icon("edit-find") if not search_icon.isNull(): self.filter_edit.addAction(search_icon, QLineEdit.ActionPosition.LeadingPosition) self.filter_edit.textChanged.connect(self._apply_filters) self.view_filter = QToolButton() self.view_filter.setObjectName("filterChip") self.view_filter.setText("Tous") self.view_filter.setIcon(themed_icon("view-filter")) self.view_filter.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.view_filter.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) self.view_filter.setMinimumHeight(34) self._view_filter_value = "Tous" view_menu = QMenu(self.view_filter) self._view_filter_actions = [] filter_icons = { "Tous": "view-list-details", "Actifs": "system-run", "Favoris": "rating", "Changements": "view-refresh", "Nouveaux": "list-add", "Modifiés": "document-edit", "Disparus": "list-remove", } for label in ("Tous", "Actifs", "Favoris", "Changements", "Nouveaux", "Modifiés", "Disparus"): action = view_menu.addAction(themed_icon(filter_icons[label]), label) action.setCheckable(True) action.setChecked(label == "Tous") action.triggered.connect(lambda _checked=False, value=label: self._set_view_filter(value)) self._view_filter_actions.append(action) self.view_filter.setMenu(view_menu) self.group_filter = QToolButton() self.group_filter.setObjectName("filterChip") self.group_filter.setText("Tous les groupes") self.group_filter.setIcon(themed_icon("folder")) self.group_filter.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.group_filter.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) self.group_filter.setMinimumHeight(34) self._group_filter_value = "Tous les groupes" self.group_filter.setVisible(False) view_switch = QFrame() view_switch.setObjectName("viewSwitch") view_switch_layout = QHBoxLayout(view_switch) view_switch_layout.setContentsMargins(2, 2, 2, 2) view_switch_layout.setSpacing(0) self.compact_view_btn = QToolButton() self.compact_view_btn.setObjectName("segmentButton") self.compact_view_btn.setText("Compact") self.compact_view_btn.setIcon(themed_icon("view-list-icons", "view-list-details")) self.compact_view_btn.setIconSize(QSize(16, 16)) self.compact_view_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.compact_view_btn.setCheckable(True) self.compact_view_btn.setToolTip("Une ligne par équipement") self.detail_view_btn = QToolButton() self.detail_view_btn.setObjectName("segmentButton") self.detail_view_btn.setText("Détaillé") self.detail_view_btn.setIcon(themed_icon("view-list-details", "view-list-icons")) self.detail_view_btn.setIconSize(QSize(16, 16)) self.detail_view_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.detail_view_btn.setCheckable(True) self.detail_view_btn.setChecked(True) self.detail_view_btn.setToolTip("Afficher les services sous chaque équipement") self.view_mode_group = QButtonGroup(self) self.view_mode_group.setExclusive(True) self.view_mode_group.addButton(self.compact_view_btn) self.view_mode_group.addButton(self.detail_view_btn) self.compact_view_btn.toggled.connect(lambda checked: checked and self._toggle_view_mode(False)) self.detail_view_btn.toggled.connect(lambda checked: checked and self._toggle_view_mode(True)) # Alias conservé pour les chemins existants (double-clic, reconstruction de l'arbre). self.view_mode_btn = self.detail_view_btn view_switch_layout.addWidget(self.compact_view_btn) view_switch_layout.addWidget(self.detail_view_btn) filter_bar.addWidget(self.filter_edit, 1) filter_bar.addWidget(self.view_filter) filter_bar.addWidget(self.group_filter) filter_bar.addWidget(view_switch) outer.addLayout(filter_bar) self.notice_frame = QFrame() self.notice_frame.setObjectName("noticeFrame") notice_layout = QHBoxLayout(self.notice_frame) notice_layout.setContentsMargins(10, 6, 8, 6) notice_layout.setSpacing(8) self.notice_icon = QLabel() warning_icon = themed_icon("dialog-warning") if not warning_icon.isNull(): self.notice_icon.setPixmap(warning_icon.pixmap(18, 18)) self.notice_label = QLabel() self.notice_label.setObjectName("noticeLabel") self.notice_label.setWordWrap(False) self.notice_details_btn = QToolButton() self.notice_details_btn.setText("Diagnostic") self.notice_details_btn.setIcon(themed_icon("tools-report-bug", "system-search")) self.notice_details_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.notice_details_btn.setObjectName("filterChip") self.notice_details_btn.clicked.connect(self.show_tools_diagnostic) self.notice_close_btn = QToolButton() self.notice_close_btn.setIcon(themed_icon("window-close")) self.notice_close_btn.setAutoRaise(True) self.notice_close_btn.setToolTip("Masquer cet avertissement") self.notice_close_btn.clicked.connect(self.notice_frame.hide) notice_layout.addWidget(self.notice_icon) notice_layout.addWidget(self.notice_label, 1) notice_layout.addWidget(self.notice_details_btn) notice_layout.addWidget(self.notice_close_btn) self.notice_frame.setVisible(False) outer.addWidget(self.notice_frame) # --- Zone principale --------------------------------------------- splitter = QSplitter(Qt.Horizontal) self.main_splitter = splitter splitter.setChildrenCollapsible(False) splitter.setHandleWidth(5) self.tree = QTreeWidget() self.tree.setObjectName("deviceTree") self.tree.setColumnCount(4) self.tree.setHeaderLabels(["Équipement / service", "IP / port", "Type / version", "Évolution"]) self.tree.setAlternatingRowColors(True) self.tree.setRootIsDecorated(True) self.tree.setUniformRowHeights(True) self.tree.setIconSize(QSize(26, 26)) self.tree.setSortingEnabled(True) self.tree.sortByColumn(1, Qt.AscendingOrder) self.tree.setContextMenuPolicy(Qt.CustomContextMenu) self.tree.customContextMenuRequested.connect(self._context_menu) self.tree.currentItemChanged.connect(lambda current, _previous: self._selection_changed(current)) self.tree.itemDoubleClicked.connect(self._tree_double_clicked) header = self.tree.header() # Les deux premières colonnes restent redimensionnables ; Type / version # absorbe tout l'espace restant. Cela supprime la grande zone blanche qui # apparaissait à droite des colonnes quand Évolution était masquée. header.setSectionResizeMode(0, QHeaderView.Interactive) header.setSectionResizeMode(1, QHeaderView.Interactive) header.setSectionResizeMode(2, QHeaderView.Stretch) header.setSectionResizeMode(3, QHeaderView.Interactive) header.setStretchLastSection(False) header.setMinimumSectionSize(88) header.setSectionsMovable(False) header.sectionResized.connect(self._column_resized) self.tree.setIndentation(20) splitter.addWidget(self.tree) # --- Panneau de détails ------------------------------------------ detail_pane = QFrame() detail_pane.setObjectName("detailPane") detail_pane.setMinimumWidth(380) detail_pane.setMaximumWidth(540) detail_outer = QVBoxLayout(detail_pane) detail_outer.setContentsMargins(0, 0, 0, 0) detail_scroll = QScrollArea() detail_scroll.setFrameShape(QFrame.Shape.NoFrame) detail_scroll.setWidgetResizable(True) detail_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) detail = QWidget() detail_layout = QVBoxLayout(detail) detail_layout.setContentsMargins(18, 16, 18, 16) detail_layout.setSpacing(10) hero = QHBoxLayout() hero.setSpacing(12) self.detail_icon = QLabel() self.detail_icon.setFixedSize(52, 52) self.detail_icon.setAlignment(Qt.AlignCenter) hero_text = QVBoxLayout() hero_text.setSpacing(2) self.detail_name = QLabel("Aucun équipement sélectionné") self.detail_name.setObjectName("detailName") self.detail_name.setWordWrap(True) self.detail_type = QLabel("") self.detail_type.setObjectName("detailType") self.detail_type.setWordWrap(True) hero_text.addWidget(self.detail_name) hero_text.addWidget(self.detail_type) hero.addWidget(self.detail_icon) hero.addLayout(hero_text, 1) detail_layout.addLayout(hero) # Actions : seules les actions pertinentes seront visibles. action_row = QHBoxLayout() action_row.setSpacing(6) self.web_btn = QPushButton("Ouvrir") self.web_btn.setObjectName("primaryAction") self.web_btn.setIcon(themed_icon("internet-web-browser")) self.web_btn.setIconSize(QSize(17, 17)) self.ssh_btn = QPushButton("SSH") self.ssh_btn.setObjectName("secondaryAction") self.ssh_btn.setIcon(themed_icon("utilities-terminal")) self.ssh_btn.setIconSize(QSize(17, 17)) self.smb_btn = QPushButton("Partages") self.smb_btn.setObjectName("secondaryAction") self.smb_btn.setIcon(themed_icon("folder-network")) self.smb_btn.setIconSize(QSize(17, 17)) self.rdp_btn = QPushButton("RDP") self.rdp_btn.setObjectName("secondaryAction") self.rdp_btn.setIcon(themed_icon("krdc", "computer")) self.rdp_btn.setIconSize(QSize(17, 17)) self.more_btn = QToolButton() self.more_btn.setObjectName("moreAction") self.more_btn.setText("Plus") self.more_btn.setIcon(themed_icon("overflow-menu", "application-menu")) self.more_btn.setIconSize(QSize(17, 17)) self.more_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) for btn in (self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn): action_row.addWidget(btn) action_row.addWidget(self.more_btn) action_row.addStretch(1) self.web_btn.clicked.connect(self._open_selected_web) self.ssh_btn.clicked.connect(lambda: self._run_selected_terminal(["ssh"])) self.smb_btn.clicked.connect(self._open_selected_smb) self.rdp_btn.clicked.connect(self._open_selected_rdp) detail_layout.addLayout(action_row) # Widgets gardés comme actions logiques ; leurs commandes sont aussi # accessibles depuis le menu "Plus". self.hostscan_btn = QPushButton("Scan détaillé") self.hostscan_btn.setIcon(themed_icon("system-search")) self.hostscan_btn.setVisible(False) self.hostscan_btn.clicked.connect(self.scan_selected_host) self.wol_btn = QPushButton("Wake-on-LAN") self.wol_btn.setIcon(themed_icon("system-run")) self.wol_btn.setVisible(False) self.wol_btn.clicked.connect(self._wake_selected) detail_layout.addWidget(separator()) detail_layout.addWidget(section_title("Informations")) info_grid = QGridLayout() info_grid.setHorizontalSpacing(12) info_grid.setVerticalSpacing(7) self.detail_ip = QLabel("—") self.detail_mac = QLabel("—") self.detail_vendor = QLabel("—") self.detail_os = QLabel("—") self.detail_os_icon = QLabel() self.detail_os_icon.setFixedSize(24, 24) self.detail_os_icon.setAlignment(Qt.AlignCenter) self.detail_latency = QLabel("—") self.detail_seen = QLabel("—") info_rows = [ ("Adresse IP", self.detail_ip), ("Adresse MAC", self.detail_mac), ("Constructeur", self.detail_vendor), ("Système", self.detail_os), ("Latence", self.detail_latency), ("Dernière vue", self.detail_seen), ] for row, (caption, value) in enumerate(info_rows): lab = QLabel(caption) lab.setObjectName("mutedLabel") value.setObjectName("valueLabel") value.setTextInteractionFlags(Qt.TextSelectableByMouse) value.setWordWrap(True) info_grid.addWidget(lab, row, 0, Qt.AlignTop) if caption == "Système": os_value = QWidget() os_value_layout = QHBoxLayout(os_value) os_value_layout.setContentsMargins(0, 0, 0, 0) os_value_layout.setSpacing(7) os_value_layout.addWidget(self.detail_os_icon, 0, Qt.AlignTop) os_value_layout.addWidget(value, 1, Qt.AlignTop) info_grid.addWidget(os_value, row, 1, Qt.AlignTop) else: info_grid.addWidget(value, row, 1, Qt.AlignTop) info_grid.setColumnStretch(1, 1) detail_layout.addLayout(info_grid) vendor_action_row = QHBoxLayout() vendor_action_row.addStretch(1) self.online_vendor_btn = QPushButton("Rechercher en ligne") self.online_vendor_btn.setObjectName("secondaryAction") self.online_vendor_btn.setIcon(themed_icon("edit-find")) self.online_vendor_btn.setToolTip("Interroger le fournisseur en ligne configuré pour cette adresse MAC") self.online_vendor_btn.clicked.connect(self._lookup_selected_vendor_online) vendor_action_row.addWidget(self.online_vendor_btn) detail_layout.addLayout(vendor_action_row) detail_layout.addWidget(separator()) detail_layout.addWidget(section_title("Services")) self.detail_services_tree = QTreeWidget() self.detail_services_tree.setObjectName("serviceList") self.detail_services_tree.setColumnCount(2) self.detail_services_tree.setHeaderHidden(True) self.detail_services_tree.setRootIsDecorated(False) self.detail_services_tree.setAlternatingRowColors(False) self.detail_services_tree.setIconSize(QSize(18, 18)) self.detail_services_tree.setMaximumHeight(210) self.detail_services_tree.header().setSectionResizeMode(0, QHeaderView.Stretch) self.detail_services_tree.header().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.detail_services_tree.itemDoubleClicked.connect(self._detail_service_double_clicked) self.detail_services = QLabel("Aucun service détecté") self.detail_services.setObjectName("mutedLabel") self.detail_services.setWordWrap(True) detail_layout.addWidget(self.detail_services_tree) detail_layout.addWidget(self.detail_services) detail_layout.addWidget(separator()) meta_header = QHBoxLayout() meta_header.addWidget(section_title("Classement local")) meta_header.addStretch(1) self.favorite_btn = QPushButton("Favori") self.favorite_btn.setObjectName("favoriteButton") self.favorite_btn.setCheckable(True) self.favorite_btn.setIcon(themed_icon("rating")) self.favorite_btn.clicked.connect(self._toggle_favorite) meta_header.addWidget(self.favorite_btn) detail_layout.addLayout(meta_header) group_label = QLabel("Groupe") group_label.setObjectName("mutedLabel") self.group_edit = QComboBox() self.group_edit.setEditable(True) self.group_edit.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.group_edit.setPlaceholderText("Infrastructure, Réseau…") detail_layout.addWidget(group_label) detail_layout.addWidget(self.group_edit) note_label = QLabel("Notes") note_label.setObjectName("mutedLabel") self.note_edit = QPlainTextEdit() self.note_edit.setPlaceholderText("Ajouter une note sur cet équipement…") self.note_edit.setMaximumHeight(100) self.save_meta_btn = QPushButton("Enregistrer") self.save_meta_btn.setObjectName("secondaryAction") self.save_meta_btn.setIcon(themed_icon("document-save")) self.save_meta_btn.clicked.connect(self._save_selected_metadata) detail_layout.addWidget(note_label) detail_layout.addWidget(self.note_edit) save_row = QHBoxLayout() save_row.addStretch(1) save_row.addWidget(self.save_meta_btn) detail_layout.addLayout(save_row) detail_layout.addStretch(1) detail_scroll.setWidget(detail) detail_outer.addWidget(detail_scroll) splitter.addWidget(detail_pane) splitter.setStretchFactor(0, 7) splitter.setStretchFactor(1, 3) splitter.setSizes([1080, 420]) outer.addWidget(splitter, 1) # --- Barre d'état synthétique ------------------------------------ bottom = QHBoxLayout() bottom.setSpacing(10) self.summary_label = QLabel("0 appareil") self.summary_label.setStyleSheet("font-weight: 600;") self.activity_label = QLabel("Prêt") self.activity_label.setObjectName("mutedLabel") self.progress = QProgressBar() self.progress.setMinimumWidth(240) self.progress.setMaximumWidth(340) self.progress.setRange(0, 100) self.progress.setValue(0) self.progress.setFormat("%p%") self.progress.setTextVisible(True) self.progress.setVisible(False) bottom.addWidget(self.summary_label) bottom.addStretch(1) bottom.addWidget(self.activity_label) bottom.addWidget(self.progress) outer.addLayout(bottom) self.setCentralWidget(central) self.setStatusBar(QStatusBar()) self._reload_group_choices() # ---------- Disposition UI ---------- def _restore_ui_layout(self) -> None: self._restoring_layout = True try: splitter_sizes = self.settings.value("ui/mainSplitter") if isinstance(splitter_sizes, (list, tuple)) and len(splitter_sizes) == 2: try: values = [int(v) for v in splitter_sizes] except (TypeError, ValueError): values = [] if values and all(v > 0 for v in values): self.main_splitter.setSizes(values) saved = self.settings.value("ui/columnWidths") widths: list[int] = [] if isinstance(saved, (list, tuple)) and len(saved) == 4: try: widths = [int(v) for v in saved] except (TypeError, ValueError): widths = [] if widths and all(v >= 80 for v in widths): # Type / version (colonne 2) est volontairement extensible : on ne # restaure pas une ancienne largeur fixe qui recréerait du vide. for column, width in enumerate(widths): if column == 2: continue self.tree.header().resizeSection(column, width) self._columns_user_customized = True else: self._columns_user_customized = False self._apply_balanced_columns(force=True) finally: self._restoring_layout = False def _save_ui_layout(self) -> None: if not hasattr(self, "tree"): return header = self.tree.header() self.settings.setValue("ui/columnWidths", [header.sectionSize(i) for i in range(self.tree.columnCount())]) if hasattr(self, "main_splitter"): self.settings.setValue("ui/mainSplitter", self.main_splitter.sizes()) def _column_resized(self, logical: int, _old_size: int, _new_size: int) -> None: if self._restoring_layout or logical == 2: return self._columns_user_customized = True self._save_ui_layout() def _apply_balanced_columns(self, *, force: bool = False) -> None: if self._columns_user_customized and not force: return viewport = max(720, self.tree.viewport().width()) show_evolution = not self.tree.isColumnHidden(3) widths = balanced_column_widths(viewport, show_evolution=show_evolution) previous = self._restoring_layout self._restoring_layout = True try: header = self.tree.header() header.setSectionResizeMode(2, QHeaderView.Stretch) for column, width in enumerate(widths): if column == 2: continue if column == 3 and not show_evolution: continue header.resizeSection(column, width) finally: self._restoring_layout = previous def _update_evolution_visibility(self) -> None: has_changes = any( host.change_status in {"Nouveau", "Modifié", "IP modifiée", "Disparu"} for host in self.hosts.values() ) was_hidden = self.tree.isColumnHidden(3) self.tree.setColumnHidden(3, not has_changes) # La dernière colonne visible doit toujours occuper l'espace utile : Type / # version reste donc en mode Stretch, avec ou sans colonne Évolution. self.tree.header().setSectionResizeMode(2, QHeaderView.Stretch) if was_hidden != (not has_changes) and not self._columns_user_customized: self._apply_balanced_columns(force=True) def reset_ui_layout(self) -> None: self.settings.remove("ui/columnWidths") self.settings.remove("ui/mainSplitter") self._columns_user_customized = False self.main_splitter.setSizes([1080, 420]) self._apply_balanced_columns(force=True) self.statusBar().showMessage("Disposition réinitialisée", 3000) def closeEvent(self, event) -> None: self._save_ui_layout() super().closeEvent(event) def _show_scan_menu(self) -> None: """Affiche le menu des profils sous son bouton, sans menu-indicator Qt additionnel.""" pos = self.scan_menu_btn.mapToGlobal(self.scan_menu_btn.rect().bottomLeft()) self.scan_menu.popup(pos) def _build_menu(self) -> None: file_menu = self.menuBar().addMenu("Fichier") export_csv_action = QAction(themed_icon("document-export", "document-save-as"), "Exporter CSV…", self) export_csv_action.triggered.connect(self.export_csv_dialog) export_json_action = QAction(themed_icon("document-export", "document-save-as"), "Exporter JSON…", self) export_json_action.triggered.connect(self.export_json_dialog) quit_action = QAction(themed_icon("application-exit"), "Quitter", self) quit_action.triggered.connect(self.close) file_menu.addActions([export_csv_action, export_json_action]) file_menu.addSeparator() file_menu.addAction(quit_action) scan_menu = self.menuBar().addMenu("Scan") std = QAction(themed_icon("media-playback-start"), "Scanner", self) std.setShortcut("F5") std.triggered.connect(lambda: self.start_scan(profile="Standard")) quick = QAction(themed_icon("system-run"), "Scan rapide", self) quick.setShortcut("Ctrl+F5") quick.triggered.connect(lambda: self.start_scan(profile="Rapide")) deep = QAction(themed_icon("system-search"), "Scan approfondi", self) deep.setShortcut("Shift+F5") deep.triggered.connect(lambda: self.start_scan(profile="Approfondi")) refresh = QAction(themed_icon("view-refresh"), "Rafraîchir les interfaces", self) refresh.triggered.connect(self.refresh_interfaces) history = QAction(themed_icon("view-history", "document-open-recent"), "Historique…", self) history.triggered.connect(self.show_history) clear = QAction(themed_icon("edit-clear"), "Effacer l’affichage", self) clear.setToolTip("Efface uniquement les résultats visibles ; l’historique et les identifications mémorisées sont conservés") clear.triggered.connect(self.clear_results) clear_history = QAction(themed_icon("edit-delete", "user-trash"), "Effacer l’historique des scans…", self) clear_history.triggered.connect(self.clear_scan_history) forget_menu = QMenu("Oublier les identifications", self) forget_menu.setIcon(themed_icon("edit-delete", "edit-clear-history", "user-trash")) forget_selected = QAction(themed_icon("edit-delete"), "Équipement sélectionné…", self) forget_selected.triggered.connect(self.forget_selected_identification) forget_scope = QAction(themed_icon("network-workgroup", "network-wired"), "Réseau courant…", self) forget_scope.triggered.connect(self.forget_current_scope_identifications) forget_all = QAction(themed_icon("edit-delete", "user-trash"), "Toutes les identifications…", self) forget_all.triggered.connect(self.forget_all_identifications) forget_menu.addActions([forget_selected, forget_scope]) forget_menu.addSeparator() forget_menu.addAction(forget_all) focus_search = QAction(themed_icon("edit-find"), "Rechercher", self) focus_search.setShortcut("Ctrl+F") focus_search.triggered.connect(self.filter_edit.setFocus) self.addAction(focus_search) scan_menu.addActions([std, quick, deep]) scan_menu.addSeparator() scan_menu.addAction(refresh) scan_menu.addAction(history) scan_menu.addSeparator() scan_menu.addAction(clear) scan_menu.addAction(clear_history) scan_menu.addMenu(forget_menu) view_menu = self.menuBar().addMenu("Affichage") compact_view = QAction(themed_icon("view-list-icons", "view-list-details"), "Vue compacte", self) compact_view.triggered.connect(lambda: self.compact_view_btn.setChecked(True)) detailed_view = QAction(themed_icon("view-list-details", "view-list-icons"), "Vue détaillée", self) detailed_view.triggered.connect(lambda: self.detail_view_btn.setChecked(True)) reset_layout = QAction(themed_icon("view-refresh"), "Réinitialiser la disposition", self) reset_layout.triggered.connect(self.reset_ui_layout) view_menu.addActions([compact_view, detailed_view]) view_menu.addSeparator() view_menu.addAction(reset_layout) settings_menu = self.menuBar().addMenu("Paramètres") vendor_settings = QAction(themed_icon("configure", "preferences-system"), "Identification des constructeurs…", self) vendor_settings.triggered.connect(self.show_vendor_settings) settings_menu.addAction(vendor_settings) help_menu = self.menuBar().addMenu("Aide") diagnostics = QAction(themed_icon("tools-report-bug", "system-search"), "Diagnostic des outils…", self) diagnostics.triggered.connect(self.show_tools_diagnostic) about = QAction(themed_icon("help-about", "help-contents"), "À propos", self) about.triggered.connect(self.show_about) help_menu.addAction(diagnostics) help_menu.addSeparator() help_menu.addAction(about) # ---------- Privilèges ---------- def toggle_admin_mode(self, _checked: bool = False) -> None: if self.admin_mode: self.admin_mode = False self._pending_scan_after_auth = False self._update_admin_button() self.statusBar().showMessage("Mode administrateur désactivé.", 4000) return self._request_admin_mode(show_confirmation=True) def _request_admin_mode(self, *, show_confirmation: bool) -> None: if self.auth_worker and self.auth_worker.isRunning(): return diag = privilege_diagnostic() if not diag.ready: self._pending_scan_after_auth = False QMessageBox.critical(self, "Mode administrateur indisponible", diag.detail + "\n\nRéinstalle le paquet LibreNet Scanner 1.0.0 si nécessaire.") return if show_confirmation: answer = QMessageBox.question( self, "Activer le mode administrateur", "LibreNet va demander une authentification via Polkit.\n\n" "Seul le helper réseau est élevé ; l'interface graphique reste avec votre utilisateur.\n\nContinuer ?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes, ) if answer != QMessageBox.Yes: self._pending_scan_after_auth = False return self.admin_btn.setEnabled(False) self.admin_btn.setText("Authentification…") self.activity_label.setText("Authentification Polkit…") self.auth_worker = PrivilegeAuthWorker(self) self.auth_worker.result.connect(self._admin_auth_result) self.auth_worker.finished.connect(lambda: self.admin_btn.setEnabled(True)) self.auth_worker.start() def _admin_auth_result(self, granted: bool, detail: str) -> None: pending = self._pending_scan_after_auth self._pending_scan_after_auth = False if granted: self.admin_mode = True self._update_admin_button() self.activity_label.setText("Mode administrateur actif") if pending: self.start_scan(profile=self._pending_profile, skip_admin_prompt=True) return self.admin_mode = False self._update_admin_button() self.activity_label.setText("Mode standard") if "annulée" not in detail.casefold() and "refusée" not in detail.casefold(): QMessageBox.warning(self, "Mode administrateur", detail) def _update_admin_button(self) -> None: self.admin_btn.setText("Admin" if self.admin_mode else "Standard") self.admin_btn.setIcon(themed_icon("object-unlocked" if self.admin_mode else "object-locked")) self.admin_btn.setToolTip( "Privilèges réseau actifs via Polkit. La GUI reste exécutée avec votre utilisateur." if self.admin_mode else "Activer les scans ARP/SYN/OS privilégiés via Polkit." ) # ---------- Interfaces et cible ---------- def refresh_interfaces(self) -> None: current_name = self.selected_interface().name if self.selected_interface() else "" self.interfaces = list_ipv4_interfaces() visible = [i for i in self.interfaces if not i.is_virtual] or self.interfaces self.interface_combo.blockSignals(True) self.interface_combo.clear() selected_idx = 0 for idx, iface in enumerate(visible): self.interface_combo.addItem(themed_icon("network-wired"), iface.label, iface) if iface.name == current_name: selected_idx = idx self.interface_combo.blockSignals(False) if self.interface_combo.count(): self.interface_combo.setCurrentIndex(selected_idx) self._interface_changed(selected_idx) elif not self.target_edit.text(): self.target_edit.setText("192.168.1.0/24") self.statusBar().showMessage(f"{len(visible)} interface(s) IPv4 détectée(s)", 3500) def _interface_changed(self, index: int) -> None: iface = self.interface_combo.itemData(index) if isinstance(iface, NetworkInterface): net = ipaddress.ip_network(iface.network, strict=False) if net.prefixlen == 24: first = net.network_address + 1 last = net.broadcast_address - 1 self.target_edit.setText(f"{first} - {last}") else: self.target_edit.setText(iface.network) def selected_interface(self) -> NetworkInterface | None: data = self.interface_combo.currentData() return data if isinstance(data, NetworkInterface) else None # ---------- Scan ---------- def start_scan(self, _checked: bool = False, *, profile: str = "Standard", skip_admin_prompt: bool = False) -> None: if self.worker and self.worker.isRunning(): return try: target = validate_target(self.target_edit.text()) count = target_address_count(target) except ValueError as exc: QMessageBox.warning(self, "Cible invalide", str(exc)) return if count > 4096: QMessageBox.warning(self, "Cible trop grande", "LibreNet limite un scan à 4096 adresses.") return if profile == "Approfondi" and not self.admin_mode and not skip_admin_prompt: answer = QMessageBox.question( self, "Scan approfondi", "Le mode administrateur permet le SYN scan et la détection OS Nmap.\n\nL'activer maintenant ?", QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes, ) if answer == QMessageBox.Cancel: return if answer == QMessageBox.Yes: self._pending_scan_after_auth = True self._pending_profile = profile self._request_admin_mode(show_confirmation=False) return self.current_scan_privileged = self.admin_mode signature = profile_signature(profile, self.current_scan_privileged) previous = self.store.latest_scan(target, profile, signature) if previous is None: self.baseline_hosts, self.baseline_date = None, "" else: self.baseline_hosts = self.store.load_scan_hosts(int(previous["id"])) self.baseline_date = previous["created_at"] self.hosts.clear() self._metadata_cache.clear() self.tree.clear() self._update_detail_panel(None) self.scan_had_error = False self.scan_warnings.clear() self.notice_label.clear() self.notice_frame.setVisible(False) self._routed_scan = not target_is_on_interface(target, self.selected_interface()) self.current_identity_scope = scan_identity_scope(target, self.selected_interface()) request = ScanRequest(target=target, profile=profile, interface=self.selected_interface(), privileged=self.current_scan_privileged) self.worker = ScanWorker(request, self) self.worker.progress.connect(self._progress) self.worker.progress_state.connect(self._progress_state) self.worker.warning.connect(self._scan_warning) self.worker.hosts_found.connect(self._merge_hosts) self.worker.failed.connect(self._scan_failed) self.worker.completed.connect(lambda: self._scan_finished(target, profile)) self.scan_started_at = datetime.now().astimezone() self.scan_btn.setEnabled(False) self.scan_menu_btn.setEnabled(False) self.scan_btn.setText("Scan en cours") self.stop_btn.setEnabled(True) self.progress.setRange(0, 100) self.progress.setValue(0) self.progress.setFormat("%p%") self.progress.setTextVisible(True) self.progress.setVisible(True) self.activity_label.setText(f"Préparation du scan {display_target(target)}…") self.statusBar().clearMessage() self.worker.start() def stop_scan(self) -> None: stopping = False if self.worker and self.worker.isRunning(): self.worker.stop() stopping = True if self.host_worker and self.host_worker.isRunning(): self.host_worker.stop() stopping = True if stopping: self.stop_btn.setEnabled(False) self.activity_label.setText("Arrêt du scan en cours…") self.progress.setRange(0, 0) self.progress.setTextVisible(False) def _progress(self, message: str) -> None: # La barre d'état système reste réservée aux notifications ponctuelles. # Le suivi du scan appartient à la ligne d'activité, sinon le dernier message # de phase restait affiché après la fin du scan. self.activity_label.setText(message) def _progress_state(self, value: int, message: str) -> None: self.activity_label.setText(message) self.progress.setVisible(True) if value < 0: # Phase de durée inconnue (Naabu/Nmap/arp-scan) : animation honnête plutôt # qu'un faux pourcentage figé. self.progress.setRange(0, 0) self.progress.setTextVisible(False) return self.progress.setRange(0, 100) self.progress.setTextVisible(True) self.progress.setFormat("%p%") self.progress.setValue(max(0, min(100, value))) def _scan_warning(self, message: str) -> None: if message not in self.scan_warnings: self.scan_warnings.append(message) summary = compact_warning(self.scan_warnings[0]) if len(self.scan_warnings) > 1: summary += f" (+{len(self.scan_warnings) - 1})" full = "\n".join(self.scan_warnings) self.notice_label.setText(summary) self.notice_label.setToolTip(full) self.notice_frame.setToolTip(full) self.notice_frame.setVisible(True) self.statusBar().showMessage(message, 9000) def _merge_hosts(self, incoming: list[Host]) -> None: for host in incoming: existing = self.hosts.get(host.ip) if existing: existing.merge(host) self._apply_cached_online_vendor(existing) enrich_host(existing) else: self._apply_cached_online_vendor(host) host = enrich_host(host) self.hosts[host.ip] = host # La découverte puis le scan de ports arrivent par étapes : la vue se remplit donc immédiatement. self._refresh_tree() def _scan_failed(self, message: str) -> None: self.scan_had_error = True self.scan_btn.setEnabled(True) self.scan_menu_btn.setEnabled(True) self.scan_btn.setText("Scanner") self.stop_btn.setEnabled(False) self.progress.setRange(0, 100) self.progress.setValue(0) self.progress.setFormat("Erreur") self.progress.setTextVisible(True) self.progress.setVisible(True) self.statusBar().clearMessage() QMessageBox.critical(self, "Erreur de scan", message) self.activity_label.setText("Erreur") def _scan_finished(self, target: str, profile: str) -> None: self.scan_btn.setEnabled(True) self.scan_menu_btn.setEnabled(True) self.scan_btn.setText("Scanner") self.stop_btn.setEnabled(False) self.statusBar().clearMessage() self.progress.setRange(0, 100) self.progress.setTextVisible(True) if self.worker and self.worker.isInterruptionRequested(): self.progress.setValue(0) self.progress.setFormat("Interrompu") self.progress.setVisible(True) self.activity_label.setText("Scan interrompu") return if self.scan_had_error: return active_hosts = list(self.hosts.values()) # V0.4.9 : mémorise l'identification riche séparément du scan courant. # Un futur Standard pourra donc afficher l'OS/type découvert ici en # Approfondi sans polluer l'historique des ports ni la comparaison. try: # Appliquer la mémoire AVANT d'actualiser l'observation de l'endpoint. # Sinon un changement d'IP/MAC LAA pourrait réécrire l'ancienne fiche # avec les indices du scan courant avant que la corrélation soit évaluée. self.store.apply_identifications(active_hosts, scope=self.current_identity_scope) self.store.remember_identifications(active_hosts, profile, scope=self.current_identity_scope) except (OSError, ValueError) as exc: self.statusBar().showMessage(f"Identification non mémorisée : {exc}", 5000) compared = compare_hosts(active_hosts, self.baseline_hosts) self.hosts = {host.ip: host for host in compared} self._refresh_tree() try: self.store.save_scan(target, profile, active_hosts, profile_signature(profile, self.current_scan_privileged)) except (OSError, ValueError) as exc: self.statusBar().showMessage(f"Historique non enregistré : {exc}", 5000) self.progress.setValue(100) self.progress.setFormat("100%") self.progress.setVisible(True) elapsed = None if self.scan_started_at is not None: elapsed = max(0.0, (datetime.now().astimezone() - self.scan_started_at).total_seconds()) suffix = f" en {elapsed:.1f} s" if elapsed is not None else "" if self.baseline_hosts is None: self.activity_label.setText(f"Terminé{suffix}") else: self.activity_label.setText(f"Terminé{suffix} — comparaison effectuée") if self._routed_scan: self.statusBar().showMessage("Réseau routé : les adresses MAC ne sont généralement pas visibles au-delà du routeur.", 7000) elif active_hosts and not any(host.mac for host in active_hosts): self._scan_warning( "Aucune MAC récupérée sur ce réseau local. Active le mode Administrateur ou ouvre Aide → Diagnostic des outils." ) self._start_automatic_online_vendor_lookup() # ---------- Constructeurs en ligne ---------- def _online_vendor_enabled(self) -> bool: return bool(self.settings.value("privacy/onlineMacLookupEnabled", False, type=bool)) def _online_vendor_provider(self) -> str: value = str(self.settings.value("privacy/onlineMacLookupProvider", PROVIDER_MACLOOKUP)) return value if value in PROVIDERS else PROVIDER_MACLOOKUP def show_vendor_settings(self) -> None: dialog = VendorSettingsDialog( enabled=self._online_vendor_enabled(), provider=self._online_vendor_provider(), parent=self, ) if dialog.exec() != QDialog.Accepted: return was_enabled = self._online_vendor_enabled() self.settings.setValue("privacy/onlineMacLookupEnabled", dialog.online_enabled) self.settings.setValue("privacy/onlineMacLookupProvider", dialog.provider) self.settings.sync() if dialog.online_enabled and not was_enabled: self.statusBar().showMessage( f"Recherche en ligne activée — fournisseur : {provider_label(dialog.provider)}", 5000 ) self._start_automatic_online_vendor_lookup() def _apply_cached_online_vendor(self, host: Host) -> bool: if host.vendor or not host.mac: return False provider = self._online_vendor_provider() cached = self.store.online_vendor_cache(host.mac, provider) if not cached: return False if bool(cached.get("found")) and cached.get("vendor"): host.vendor = str(cached["vendor"]) return True return False def _lookup_selected_vendor_online(self) -> None: host = self._selected_host() if not host or not host.mac: return self._start_online_vendor_lookup([host], manual=True) def _start_automatic_online_vendor_lookup(self) -> None: if not self._online_vendor_enabled(): return unknown = [host for host in self.hosts.values() if host.status == "up" and host.mac and not host.vendor] if unknown: self._start_online_vendor_lookup(unknown, manual=False) def _start_online_vendor_lookup(self, hosts: list[Host], *, manual: bool) -> None: if self.vendor_lookup_worker and self.vendor_lookup_worker.isRunning(): if manual: self.statusBar().showMessage("Une recherche constructeur en ligne est déjà en cours.", 4000) return provider = self._online_vendor_provider() pending: list[str] = [] for host in hosts: if not host.mac: continue cached = None if manual else self.store.online_vendor_cache(host.mac, provider) if cached: if bool(cached.get("found")) and cached.get("vendor"): host.vendor = str(cached["vendor"]) enrich_host(host) continue pending.append(host.mac) self._refresh_tree() selected = self._selected_host() if selected: self._update_detail_panel(selected) if not pending: if manual: self.statusBar().showMessage("Résultat constructeur chargé depuis le cache local.", 4000) return self._vendor_lookup_manual = manual self.online_vendor_btn.setEnabled(False) self.statusBar().showMessage( f"Recherche constructeur via {provider_label(provider)}…", 0 if len(pending) > 1 else 6000 ) self.vendor_lookup_worker = OnlineVendorLookupWorker(pending, provider, self) self.vendor_lookup_worker.result.connect(self._online_vendor_result) self.vendor_lookup_worker.failed.connect(self._online_vendor_failed) self.vendor_lookup_worker.finished.connect(self._online_vendor_finished) self.vendor_lookup_worker.start() def _online_vendor_result(self, result: OnlineVendorResult) -> None: self.store.save_online_vendor_cache( result.mac, result.provider, vendor=result.vendor, found=result.found, block_type=result.block_type, is_randomized=result.is_randomized, is_private=result.is_private, checked_at=result.checked_at, ) for host in self.hosts.values(): if host.mac.upper() != result.mac.upper(): continue if result.found and result.vendor: host.vendor = result.vendor enrich_host(host) self._refresh_tree() selected = self._selected_host() if selected: self._update_detail_panel(selected) def _online_vendor_failed(self, mac: str, message: str) -> None: if self._vendor_lookup_manual: QMessageBox.warning(self, "Recherche constructeur", f"{mac} : {message}") else: self.statusBar().showMessage(f"Recherche constructeur : {message}", 6000) def _online_vendor_finished(self) -> None: self.online_vendor_btn.setEnabled(bool(self._selected_host() and self._selected_host().mac)) provider = self._online_vendor_provider() if self._vendor_lookup_manual: host = self._selected_host() if host and host.vendor: self.statusBar().showMessage(f"Constructeur trouvé : {host.vendor}", 5000) elif host and is_locally_administered(host.mac): self.statusBar().showMessage( "Adresse MAC locale (LAA) : aucun constructeur fiable ne peut être déduit.", 6000 ) else: self.statusBar().showMessage(f"Aucun constructeur trouvé via {provider_label(provider)}.", 5000) else: self.statusBar().showMessage("Recherche des constructeurs en ligne terminée.", 4000) self._vendor_lookup_manual = False # ---------- Arbre et filtrage ---------- def _host_has_shared_mac(self, host: Host) -> bool: mac = normalize_mac(host.mac) return bool(mac and mac in shared_macs(self.hosts.values())) def _metadata_key(self, host: Host) -> str: return identity_key(host, shared_mac=self._host_has_shared_mac(host)) def _metadata(self, host: Host) -> dict[str, object]: key = self._metadata_key(host) if key not in self._metadata_cache: self._metadata_cache[key] = self.store.host_metadata( host, shared_mac=self._host_has_shared_mac(host) ) return self._metadata_cache[key] def _host_title(self, host: Host) -> str: return host.hostname or host.ip def _type_version_text(self, host: Host) -> str: """Compact but useful summary for the main list.""" device = host.effective_device_type or "Hôte" os_name = (host.effective_os_name or "").strip() suffix = "" if host.type_is_remembered or host.os_is_remembered: suffix = " · mémorisé" if not os_name: return device + suffix # Avoid repeating obvious generic family names twice. if os_name.casefold() in device.casefold() or device.casefold() in os_name.casefold(): return device + suffix if len(os_name) > 38: os_name = os_name[:35].rstrip() + "…" return f"{device} · {os_name}{suffix}" def _identification_tooltip(self, host: Host) -> str: lines: list[str] = [] if host.effective_device_type: lines.append(host.effective_device_type) if host.effective_os_name: lines.append(host.effective_os_name) if host.remembered_match_score: lines.append( f"Corrélation historique : {host.remembered_match_score}%" + (f" — {host.remembered_match_reason}" if host.remembered_match_reason else "") ) if host.type_is_remembered: detail = f"Type mémorisé depuis un scan {host.remembered_type_source or 'antérieur'}" if host.remembered_type_seen_at: detail += f" du {_display_timestamp(host.remembered_type_seen_at)}" lines.append(detail) if host.os_is_remembered: detail = f"OS mémorisé depuis un scan {host.remembered_os_source or 'antérieur'}" if host.remembered_os_seen_at: detail += f" du {_display_timestamp(host.remembered_os_seen_at)}" lines.append(detail) if not lines and host.vendor: lines.append(host.vendor) return "\n".join(lines) def _remembered_os_tooltip(self, host: Host) -> str: if not host.os_is_remembered: if not host.os_name: return "" if host.os_accuracy is None: return "Détecté lors du scan courant" nature = "estimation Nmap" if host.os_accuracy < 100 else "correspondance Nmap" return f"Détecté lors du scan courant — {nature} {host.os_accuracy}%" source = host.remembered_os_source or "scan antérieur" when = _display_timestamp(host.remembered_os_seen_at) if host.remembered_os_seen_at else "" suffix = f" du {when}" if when else "" os_accuracy = ( f" — précision Nmap {host.remembered_os_accuracy}%" if host.remembered_os_accuracy is not None else "" ) confidence = f" — corrélation équipement {host.remembered_match_score}%" if host.remembered_match_score else "" reason = f" ({host.remembered_match_reason})" if host.remembered_match_reason else "" return f"Dernière identification connue — {source}{suffix}{os_accuracy}{confidence}{reason}" def _device_icon(self, host: Host) -> QIcon: # V0.4.12 : l'icône de la première colonne représente uniquement # le TYPE D'ÉQUIPEMENT. L'OS dispose de sa propre icône séparée. return device_icon(host) def _os_icon(self, host: Host) -> QIcon: return os_icon(host) def _service_icon(self, port: PortInfo) -> QIcon: if _port_url(Host("0.0.0.0", ports=[port]), port): return themed_icon("internet-web-browser") if port.port == 22: return themed_icon("utilities-terminal") if port.port == 445: return themed_icon("folder-network") if port.port == 3389: return themed_icon("krdc") if port.port in {53, 67, 68, 161, 162}: return themed_icon("network-wired") return themed_icon("network-server") def _service_title(self, port: PortInfo) -> str: return port.service or "Service réseau" def _toggle_view_mode(self, detailed: bool) -> None: if detailed and not self.detail_view_btn.isChecked(): self.detail_view_btn.setChecked(True) return if not detailed and not self.compact_view_btn.isChecked(): self.compact_view_btn.setChecked(True) return self.tree.setRootIsDecorated(detailed) self._refresh_tree() def _set_view_filter(self, value: str) -> None: self._view_filter_value = value self.view_filter.setText(value) for action in self._view_filter_actions: action.setChecked(action.text() == value) self._apply_filters() def _set_group_filter(self, value: str) -> None: self._group_filter_value = value self.group_filter.setText(value) menu = self.group_filter.menu() if menu is not None: for action in menu.actions(): action.setChecked(action.text() == value) self._apply_filters() def _refresh_tree(self) -> None: selected = self._selected_host() selected_ip = selected.ip if selected else None expanded = { self.tree.topLevelItem(i).data(0, ROLE_IP) for i in range(self.tree.topLevelItemCount()) if self.tree.topLevelItem(i).isExpanded() } detailed = self.view_mode_btn.isChecked() if hasattr(self, "view_mode_btn") else True self.tree.setUpdatesEnabled(False) self.tree.setSortingEnabled(False) self.tree.clear() self.tree.setRootIsDecorated(detailed) selected_item = None palette = self.tree.palette() muted = palette.color(self.tree.foregroundRole()) muted.setAlpha(165) for host in sorted(self.hosts.values(), key=lambda h: ipaddress.ip_address(h.ip)): item = IPTreeWidgetItem(self.tree) item.setData(0, ROLE_IP, host.ip) item.setData(0, ROLE_KIND, KIND_HOST) item.setText(0, self._host_title(host)) if self._metadata(host).get("favorite"): font = item.font(0) font.setBold(True) item.setFont(0, font) item.setToolTip(0, "Favori • " + (host.change_detail or host.effective_device_type)) item.setText(1, host.ip) item.setText(2, self._type_version_text(host)) item.setText(3, "" if host.change_status in {"", "Inchangé"} else host.change_status) icon = self._device_icon(host) if not icon.isNull(): item.setIcon(0, icon) os_family_icon = self._os_icon(host) if host.effective_os_name and not os_family_icon.isNull(): item.setIcon(2, os_family_icon) item.setToolTip(0, host.change_detail or host.effective_device_type) item.setToolTip(1, f"Adresse IP : {host.ip}") item.setToolTip(2, self._identification_tooltip(host)) item.setToolTip(3, host.change_detail) if host.status != "up": for col in range(self.tree.columnCount()): item.setForeground(col, muted) elif host.change_status == "Nouveau": item.setIcon(3, themed_icon("list-add")) elif host.change_status in {"Modifié", "IP modifiée"}: item.setIcon(3, themed_icon("document-edit")) elif host.change_status == "Disparu": item.setIcon(3, themed_icon("list-remove")) if detailed: for port in sorted((p for p in host.ports if p.state == "open"), key=lambda p: (p.protocol, p.port)): child = QTreeWidgetItem(item) child.setData(0, ROLE_IP, host.ip) child.setData(0, ROLE_KIND, KIND_SERVICE) child.setData(0, ROLE_PORT, port.port) child.setText(0, self._service_title(port)) child.setText(1, f"{port.port}/{port.protocol}") product = " ".join(v for v in (port.product, port.version) if v).strip() child.setText(2, product) icon = self._service_icon(port) if not icon.isNull(): child.setIcon(0, icon) child.setToolTip(0, port.details) child.setToolTip(1, "Double-clique pour ouvrir le service lorsqu'une action est disponible.") child.setForeground(0, muted) child.setForeground(1, muted) child.setForeground(2, muted) if detailed and host.ip in expanded: item.setExpanded(True) if selected_ip == host.ip: selected_item = item self.tree.setSortingEnabled(True) self.tree.sortByColumn(1, Qt.AscendingOrder) self._update_evolution_visibility() self.tree.setUpdatesEnabled(True) self._apply_filters() if selected_item: self.tree.setCurrentItem(selected_item) self._update_summary() def _apply_filters(self) -> None: query = self.filter_edit.text().strip().casefold() mode = self._view_filter_value group = self._group_filter_value visible_count = 0 for i in range(self.tree.topLevelItemCount()): item = self.tree.topLevelItem(i) host = self.hosts.get(str(item.data(0, ROLE_IP))) hide = host is None if host: meta = self._metadata(host) extra = f"{meta.get('group_name','')} {meta.get('note','')}".casefold() if query and query not in (host.searchable_text + " " + extra): hide = True if not hide: if mode == "Actifs" and host.status != "up": hide = True elif mode == "Favoris" and not meta.get("favorite"): hide = True elif mode == "Changements" and host.change_status not in {"Nouveau", "Modifié", "IP modifiée", "Disparu"}: hide = True elif mode == "Nouveaux" and host.change_status != "Nouveau": hide = True elif mode == "Modifiés" and host.change_status not in {"Modifié", "IP modifiée"}: hide = True elif mode == "Disparus" and host.change_status != "Disparu": hide = True if not hide and group != "Tous les groupes" and str(meta.get("group_name", "")) != group: hide = True item.setHidden(hide) if not hide: visible_count += 1 self._update_summary(visible_count) def _update_summary(self, visible_count: int | None = None) -> None: values = list(self.hosts.values()) active = sum(h.status == "up" for h in values) services = sum(sum(p.state == "open" for p in h.ports) for h in values) favorites = sum(bool(self._metadata(h).get("favorite")) for h in values) if values else 0 changes = sum(h.change_status in {"Nouveau", "Modifié", "IP modifiée", "Disparu"} for h in values) if visible_count is None: visible_count = sum(not self.tree.topLevelItem(i).isHidden() for i in range(self.tree.topLevelItemCount())) parts = [f"{active} appareil(s)", f"{services} service(s)"] if favorites: parts.append(f"{favorites} favori(s)") if changes: parts.append(f"{changes} évolution(s)") if visible_count != len(values): parts.append(f"{visible_count} affiché(s)") self.summary_label.setText(" • ".join(parts)) def _reload_group_choices(self) -> None: groups = self.store.known_groups() current_filter = self._group_filter_value if hasattr(self, "_group_filter_value") else "Tous les groupes" current_edit = self.group_edit.currentText() if hasattr(self, "group_edit") else "" if hasattr(self, "group_filter"): if current_filter != "Tous les groupes" and current_filter not in groups: current_filter = "Tous les groupes" self._group_filter_value = current_filter menu = QMenu(self.group_filter) for label in ["Tous les groupes", *groups]: action = menu.addAction(themed_icon("folder"), label) action.setCheckable(True) action.setChecked(label == current_filter) action.triggered.connect(lambda _checked=False, value=label: self._set_group_filter(value)) self.group_filter.setMenu(menu) self.group_filter.setText(current_filter) self.group_filter.setVisible(bool(groups)) if hasattr(self, "group_edit"): self.group_edit.clear() self.group_edit.addItems(groups) self.group_edit.setEditText(current_edit) # ---------- Sélection, détails, favoris ---------- def _host_from_item(self, item: QTreeWidgetItem | None) -> Host | None: if item is None: return None ip = item.data(0, ROLE_IP) return self.hosts.get(str(ip)) if ip else None def _selected_host(self) -> Host | None: return self._host_from_item(self.tree.currentItem()) def _selection_changed(self, item: QTreeWidgetItem | None) -> None: self._update_detail_panel(self._host_from_item(item)) def _detail_service_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None: host = self._selected_host() if not host: return port_no = item.data(0, ROLE_PORT) if port_no is None: return port = next((p for p in host.ports if p.port == int(port_no) and p.state == "open"), None) self._open_service(host, port) def _web_action_label(self, host: Host) -> str: if host.effective_device_type == "Hyperviseur Proxmox": return "Ouvrir Proxmox" if host.effective_device_type == "Proxmox Backup Server": return "Ouvrir PBS" if host.effective_device_type == "NAS Synology": return "Ouvrir DSM" if host.effective_device_type == "Pare-feu / routeur": return "Ouvrir l'interface" return "Ouvrir le Web" def _rebuild_more_menu(self, host: Host | None) -> None: menu = QMenu(self.more_btn) if not host: self.more_btn.setMenu(menu) self.more_btn.setEnabled(False) return open_ports = {p.port for p in host.ports if p.state == "open"} if _port_url(host) is not None: web = menu.addAction(themed_icon("internet-web-browser"), self._web_action_label(host)) web.triggered.connect(self._open_selected_web) if 22 in open_ports: ssh = menu.addAction(themed_icon("utilities-terminal"), "Ouvrir en SSH") ssh.triggered.connect(lambda: self._run_terminal(["ssh", host.ip])) if 445 in open_ports: smb = menu.addAction(themed_icon("folder-network"), "Parcourir les partages") smb.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/"))) if 3389 in open_ports and shutil.which("remmina") is not None: rdp = menu.addAction(themed_icon("krdc"), "Ouvrir en RDP") rdp.triggered.connect(self._open_selected_rdp) if menu.actions(): menu.addSeparator() ping = menu.addAction(themed_icon("network-transmit-receive"), "Ping") ping.triggered.connect(lambda: self._run_terminal(["ping", host.ip])) traceroute = menu.addAction(themed_icon("network-wired"), "Traceroute") traceroute.setEnabled(shutil.which("traceroute") is not None) traceroute.triggered.connect(lambda: self._run_terminal(["traceroute", host.ip])) scan = menu.addAction(themed_icon("system-search"), "Scan détaillé — 1000 ports") scan.setEnabled(host.status == "up") scan.triggered.connect(self.scan_selected_host) wol = menu.addAction(themed_icon("system-run"), "Wake-on-LAN") wol.setEnabled(bool(host.mac)) wol.triggered.connect(self._wake_selected) menu.addSeparator() copy_ip = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse IP") copy_ip.triggered.connect(lambda: QApplication.clipboard().setText(host.ip)) copy_mac = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse MAC") copy_mac.setEnabled(bool(host.mac)) copy_mac.triggered.connect(lambda: QApplication.clipboard().setText(host.mac)) self.more_btn.setMenu(menu) self.more_btn.setEnabled(True) def _update_detail_panel(self, host: Host | None) -> None: enabled = host is not None for widget in ( self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn, self.hostscan_btn, self.wol_btn, self.favorite_btn, self.group_edit, self.note_edit, self.save_meta_btn, self.online_vendor_btn, ): widget.setEnabled(enabled) self.detail_services_tree.clear() self._rebuild_more_menu(host) if not host: icon = themed_icon("network-wired") self.detail_icon.setPixmap(icon.pixmap(46, 46) if not icon.isNull() else QIcon().pixmap(36, 36)) self.detail_name.setText("Aucun équipement sélectionné") self.detail_type.setText("Sélectionne un appareil pour afficher ses détails et ses actions.") for label in ( self.detail_ip, self.detail_mac, self.detail_vendor, self.detail_os, self.detail_latency, self.detail_seen, ): label.setText("—") self.detail_os_icon.clear() self.detail_services_tree.setVisible(False) self.detail_services.setVisible(True) self.detail_services.setText("Aucun service à afficher") for button in (self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn): button.setVisible(False) self.more_btn.setVisible(False) self.favorite_btn.blockSignals(True) self.favorite_btn.setChecked(False) self.favorite_btn.setText("Ajouter aux favoris") self.favorite_btn.blockSignals(False) self.group_edit.setEditText("") self.note_edit.clear() self.online_vendor_btn.setVisible(False) return meta = self._metadata(host) icon = self._device_icon(host) self.detail_icon.setPixmap(icon.pixmap(46, 46) if not icon.isNull() else QIcon().pixmap(36, 36)) self.detail_name.setText(host.hostname or host.ip) status_parts = [host.effective_device_type + (" (mémorisé)" if host.type_is_remembered else "")] if host.change_status and host.change_status != "Inchangé": status_parts.append(host.change_status) if host.status != "up": status_parts.append("hors ligne") self.detail_type.setText(" • ".join(status_parts)) self.detail_ip.setText(host.ip + (f" (avant : {host.previous_ip})" if host.previous_ip else "")) self.detail_mac.setText(host.mac or "—") if host.vendor: self.detail_vendor.setText(host.vendor) elif host.mac and is_locally_administered(host.mac): self.detail_vendor.setText("Non déterminable — adresse locale (LAA)") else: self.detail_vendor.setText("—") self.online_vendor_btn.setVisible(bool(host.mac)) self.online_vendor_btn.setEnabled(bool(host.mac) and not (self.vendor_lookup_worker and self.vendor_lookup_worker.isRunning())) self.online_vendor_btn.setText("Actualiser en ligne" if host.vendor else "Rechercher en ligne") self.online_vendor_btn.setToolTip( f"Envoyer cette MAC à {provider_label(self._online_vendor_provider())} et mettre le résultat en cache localement" ) effective_os = host.effective_os_name if effective_os: if host.os_is_remembered: os_text = effective_os + " (mémorisé)" elif host.os_accuracy is not None and host.os_accuracy < 100: os_text = f"{effective_os} (estimation {host.os_accuracy} %)" else: os_text = effective_os else: os_text = "—" self.detail_os.setText(os_text) self.detail_os.setToolTip(self._remembered_os_tooltip(host)) os_family_icon = self._os_icon(host) self.detail_os_icon.setPixmap( os_family_icon.pixmap(24, 24) if effective_os and not os_family_icon.isNull() else QIcon().pixmap(22, 22) ) self.detail_os_icon.setToolTip("Famille de système d'exploitation") self.detail_latency.setText(f"{host.latency_ms:.1f} ms" if host.latency_ms is not None else "—") self.detail_seen.setText(_display_timestamp(host.last_seen) or "—") open_services = [p for p in host.ports if p.state == "open"] self.detail_services_tree.setVisible(bool(open_services)) self.detail_services.setVisible(not bool(open_services)) self.detail_services.setText("Aucun service détecté") for port in sorted(open_services, key=lambda p: (p.protocol, p.port)): item = QTreeWidgetItem(self.detail_services_tree) item.setData(0, ROLE_PORT, port.port) item.setText(0, self._service_title(port)) item.setText(1, f"{port.port}/{port.protocol}") icon = self._service_icon(port) if not icon.isNull(): item.setIcon(0, icon) item.setToolTip(0, port.details) fav = bool(meta.get("favorite")) self.favorite_btn.blockSignals(True) self.favorite_btn.setChecked(fav) self.favorite_btn.setText("Favori" if fav else "Ajouter aux favoris") fav_icon = themed_icon("rating" if fav else "rating-unrated") if not fav_icon.isNull(): self.favorite_btn.setIcon(fav_icon) self.favorite_btn.blockSignals(False) self.group_edit.setEditText(str(meta.get("group_name", ""))) self.note_edit.setPlainText(str(meta.get("note", ""))) open_ports = {p.port for p in open_services} is_up = host.status == "up" web_available = is_up and _port_url(host) is not None ssh_available = is_up and 22 in open_ports smb_available = is_up and 445 in open_ports rdp_available = is_up and 3389 in open_ports and shutil.which("remmina") is not None self.web_btn.setEnabled(web_available) self.web_btn.setText(self._web_action_label(host)) self.ssh_btn.setEnabled(ssh_available) self.smb_btn.setEnabled(smb_available) self.rdp_btn.setEnabled(rdp_available) # Deux actions directes maximum : le reste reste disponible via « Plus ». direct = [ (self.web_btn, web_available), (self.ssh_btn, ssh_available), (self.rdp_btn, rdp_available), (self.smb_btn, smb_available), ] shown = 0 for button, available in direct: show = bool(available and shown < 2) button.setVisible(show) if show: shown += 1 self.more_btn.setVisible(True) self.hostscan_btn.setEnabled(is_up) self.wol_btn.setEnabled(bool(host.mac)) def _toggle_favorite(self, checked: bool) -> None: host = self._selected_host() if not host: return self.store.save_host_metadata(host, favorite=checked, shared_mac=self._host_has_shared_mac(host)) self._metadata_cache.pop(self._metadata_key(host), None) self._reload_group_choices() self._refresh_tree() self.statusBar().showMessage("Ajouté aux favoris" if checked else "Retiré des favoris", 3000) def _save_selected_metadata(self) -> None: host = self._selected_host() if not host: return self.store.save_host_metadata( host, group_name=self.group_edit.currentText(), note=self.note_edit.toPlainText(), shared_mac=self._host_has_shared_mac(host), ) self._metadata_cache.pop(self._metadata_key(host), None) self._reload_group_choices() self._refresh_tree() self.statusBar().showMessage("Groupe et note enregistrés", 3000) # ---------- Actions ---------- def _tree_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None: host = self._host_from_item(item) if not host: return if item.data(0, ROLE_KIND) == KIND_SERVICE: port_no = int(item.data(0, ROLE_PORT)) port = next((p for p in host.ports if p.port == port_no and p.state == "open"), None) self._open_service(host, port) return if not self.view_mode_btn.isChecked(): self.view_mode_btn.setChecked(True) # _toggle_view_mode reconstruit l'arbre ; on retrouve ensuite l'hôte. for idx in range(self.tree.topLevelItemCount()): candidate = self.tree.topLevelItem(idx) if candidate.data(0, ROLE_IP) == host.ip: candidate.setExpanded(True) self.tree.setCurrentItem(candidate) break else: item.setExpanded(not item.isExpanded()) def _open_service(self, host: Host, port: PortInfo | None) -> None: if not port: return url = _port_url(host, port) if url: QDesktopServices.openUrl(QUrl(url)) elif port.port == 22: self._run_terminal(["ssh", host.ip]) elif port.port == 445: QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/")) elif port.port == 3389 and shutil.which("remmina"): subprocess.Popen(["remmina", "-c", f"rdp://{host.ip}"]) else: self.statusBar().showMessage(f"{port.details} — aucune action directe associée", 4000) def _context_menu(self, pos) -> None: item = self.tree.itemAt(pos) if item is None: return self.tree.setCurrentItem(item) host = self._host_from_item(item) if not host: return menu = QMenu(self) if item.data(0, ROLE_KIND) == KIND_SERVICE: open_action = menu.addAction(themed_icon("document-open"), "Ouvrir ce service") menu.addSeparator() else: open_action = None web = menu.addAction(themed_icon("internet-web-browser"), self._web_action_label(host)) ssh = menu.addAction(themed_icon("utilities-terminal"), "Ouvrir en SSH") smb = menu.addAction(themed_icon("folder-network"), "Parcourir les partages") rdp = menu.addAction(themed_icon("krdc"), "Ouvrir en RDP") menu.addSeparator() ping = menu.addAction(themed_icon("network-transmit-receive"), "Ping") scan = menu.addAction(themed_icon("system-search"), "Scan détaillé — 1000 ports") wol = menu.addAction(themed_icon("system-run"), "Wake-on-LAN") menu.addSeparator() meta = self._metadata(host) favorite = menu.addAction( themed_icon("rating" if not meta.get("favorite") else "rating-unrated"), "Retirer des favoris" if meta.get("favorite") else "Ajouter aux favoris", ) menu.addSeparator() copy_ip = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse IP") copy_mac = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse MAC") copy_mac.setEnabled(bool(host.mac)) web.setEnabled(_port_url(host) is not None) ssh.setEnabled(any(p.port == 22 and p.state == "open" for p in host.ports)) smb.setEnabled(any(p.port == 445 and p.state == "open" for p in host.ports)) rdp.setEnabled(any(p.port == 3389 and p.state == "open" for p in host.ports) and shutil.which("remmina") is not None) wol.setEnabled(bool(host.mac)) chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) if chosen == open_action: port_no = int(item.data(0, ROLE_PORT)) self._open_service(host, next((p for p in host.ports if p.port == port_no), None)) elif chosen == web: self._open_selected_web() elif chosen == ssh: self._run_terminal(["ssh", host.ip]) elif chosen == smb: QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/")) elif chosen == rdp: self._open_selected_rdp() elif chosen == ping: self._run_terminal(["ping", host.ip]) elif chosen == scan: self.scan_selected_host() elif chosen == wol: self._wake_host(host) elif chosen == favorite: self._toggle_favorite(not bool(meta.get("favorite"))) elif chosen == copy_ip: QApplication.clipboard().setText(host.ip) elif chosen == copy_mac: QApplication.clipboard().setText(host.mac) def _run_terminal(self, command: list[str]) -> None: terminal = shutil.which("konsole") if not terminal: QMessageBox.warning(self, "Konsole absent", "Konsole n'est pas installé ou n'est pas dans le PATH.") return try: subprocess.Popen([terminal, "-e", *command]) except OSError as exc: QMessageBox.critical(self, "Erreur", str(exc)) def _run_selected_terminal(self, prefix: list[str]) -> None: host = self._selected_host() if host: self._run_terminal([*prefix, host.ip]) def _open_selected_web(self) -> None: host = self._selected_host() if not host: return url = _port_url(host) if url: QDesktopServices.openUrl(QUrl(url)) def _open_selected_smb(self) -> None: host = self._selected_host() if host: QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/")) def _open_selected_rdp(self) -> None: host = self._selected_host() if not host or not shutil.which("remmina"): return try: subprocess.Popen(["remmina", "-c", f"rdp://{host.ip}"]) except OSError as exc: QMessageBox.critical(self, "Erreur Remmina", str(exc)) def _wake_selected(self) -> None: host = self._selected_host() if host: self._wake_host(host) def _wake_host(self, host: Host) -> None: iface = self.selected_interface() broadcast = "255.255.255.255" if iface and target_is_on_interface(host.ip, iface): broadcast = str(ipaddress.ip_network(iface.network, strict=False).broadcast_address) try: send_magic_packet(host.mac, broadcast=broadcast) except (OSError, ValueError) as exc: QMessageBox.critical(self, "Wake-on-LAN", str(exc)) return self.statusBar().showMessage(f"Paquet Wake-on-LAN envoyé à {host.mac}", 4500) def scan_selected_host(self) -> None: host = self._selected_host() if not host or host.status != "up": return if self.host_worker and self.host_worker.isRunning(): QMessageBox.information(self, "Scan en cours", "Un scan détaillé est déjà en cours.") return self.host_worker = HostScanWorker(host.ip, self, privileged=self.admin_mode) self.host_worker.progress.connect(self._progress) self.host_worker.result.connect(self._host_scan_result) self.host_worker.failed.connect(lambda m: QMessageBox.critical(self, "Erreur Nmap", m)) self.host_worker.completed.connect(self._host_scan_finished) self.progress.setRange(0, 0) self.progress.setTextVisible(False) self.progress.setVisible(True) self.statusBar().clearMessage() self.host_worker.start() def _host_scan_finished(self) -> None: self.progress.setRange(0, 100) self.progress.setValue(100) self.progress.setFormat("100%") self.progress.setTextVisible(True) self.progress.setVisible(True) self.activity_label.setText("Scan détaillé terminé") self.statusBar().clearMessage() def _host_scan_result(self, result: Host) -> None: if result.ip in self.hosts: self.hosts[result.ip].merge(result) enrich_host(self.hosts[result.ip]) host = self.hosts[result.ip] else: host = enrich_host(result) self.hosts[result.ip] = host try: shared = shared_macs(self.hosts.values()) self.store.remember_host_identification( host, "Détaillé", shared_mac=normalize_mac(host.mac) in shared, scope=self.current_identity_scope, ) self.store.apply_identifications(list(self.hosts.values()), scope=self.current_identity_scope) except (OSError, ValueError): pass self._refresh_tree() # ---------- Export / historique / diagnostic ---------- def export_csv_dialog(self) -> None: if not self.hosts: QMessageBox.information(self, "Export", "Aucun résultat à exporter.") return filename, _ = QFileDialog.getSaveFileName(self, "Exporter en CSV", "librenet-scan.csv", "CSV (*.csv)") if filename: export_csv(filename, list(self.hosts.values())) self.statusBar().showMessage(f"Export CSV : {filename}", 5000) def export_json_dialog(self) -> None: if not self.hosts: QMessageBox.information(self, "Export", "Aucun résultat à exporter.") return filename, _ = QFileDialog.getSaveFileName(self, "Exporter en JSON", "librenet-scan.json", "JSON (*.json)") if filename: export_json(filename, list(self.hosts.values())) self.statusBar().showMessage(f"Export JSON : {filename}", 5000) def show_history(self) -> None: HistoryDialog(self.store, self).exec() def clear_results(self) -> None: """Efface seulement la vue courante, jamais les données persistantes.""" self.hosts.clear() self.baseline_hosts = None self.baseline_date = "" self._metadata_cache.clear() self.tree.clear() self._update_detail_panel(None) self.summary_label.setText("0 appareil") self.activity_label.setText("Prêt") self.statusBar().showMessage( "Affichage effacé — historique et identifications mémorisées conservés", 4500 ) def clear_scan_history(self) -> None: answer = QMessageBox.question( self, "Effacer l’historique des scans", "Supprimer tous les anciens scans ?\n\n" "Les favoris, groupes, notes et identifications mémorisées seront conservés.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: return try: count = self.store.clear_scan_history() except (OSError, ValueError) as exc: QMessageBox.critical(self, "Historique", str(exc)) return self.baseline_hosts = None self.baseline_date = "" self.statusBar().showMessage(f"Historique effacé : {count} scan(s)", 5000) def _identity_scope_for_current_target(self) -> str: try: return scan_identity_scope(self.target_edit.text().strip(), self.selected_interface()) except ValueError: return self.current_identity_scope @staticmethod def _clear_remembered_fields(host: Host) -> None: host.remembered_os_name = "" host.remembered_os_accuracy = None host.remembered_device_type = "" host.remembered_os_source = "" host.remembered_type_source = "" host.remembered_os_seen_at = "" host.remembered_type_seen_at = "" host.remembered_match_score = 0 host.remembered_match_reason = "" host.remembered_identity_kind = "" def forget_selected_identification(self) -> None: host = self._selected_host() if host is None: QMessageBox.information(self, "Identification", "Sélectionne d’abord un équipement.") return answer = QMessageBox.question( self, "Oublier l’identification", f"Oublier l’OS/type mémorisé pour {host.hostname or host.ip} ?\n\n" "Les résultats du scan, favoris, groupe et notes ne seront pas supprimés.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: return shared = normalize_mac(host.mac) in shared_macs(self.hosts.values()) scope = self._identity_scope_for_current_target() count = self.store.forget_identification_for_host(host, shared_mac=shared, scope=scope) self._clear_remembered_fields(host) self._refresh_tree() self._update_detail_panel(host) self.statusBar().showMessage(f"Identification oubliée ({count} entrée(s))", 5000) def forget_current_scope_identifications(self) -> None: scope = self._identity_scope_for_current_target() if not scope: QMessageBox.information(self, "Identification", "Impossible de déterminer le réseau courant.") return answer = QMessageBox.question( self, "Oublier les identifications du réseau", f"Oublier toutes les identifications mémorisées pour :\n{scope} ?\n\n" "L’historique des scans, les favoris, groupes et notes sont conservés.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: return count = self.store.forget_identifications_for_scope(scope) for host in self.hosts.values(): if not host.is_local: self._clear_remembered_fields(host) self._refresh_tree() self._update_detail_panel(self._selected_host()) self.statusBar().showMessage(f"{count} identification(s) oubliée(s) pour le réseau courant", 5000) def forget_all_identifications(self) -> None: answer = QMessageBox.question( self, "Oublier toutes les identifications", "Oublier tous les OS/types mémorisés ?\n\n" "Cette action ne supprime ni l’historique des scans, ni les favoris, groupes ou notes.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: return count = self.store.forget_all_identifications() for host in self.hosts.values(): self._clear_remembered_fields(host) self._refresh_tree() self._update_detail_panel(self._selected_host()) self.statusBar().showMessage(f"Toutes les identifications ont été oubliées ({count} entrée(s))", 5000) def show_tools_diagnostic(self) -> None: diag = arp_scan_diagnostic() ndiag = naabu_diagnostic() pdiag = privilege_diagnostic() cap = "OK — CAP_NET_RAW détectée" if diag.cap_net_raw is True else "ABSENTE / non détectée" if diag.cap_net_raw is False else "INCONNUE" recommendation = "" if diag.path and diag.cap_net_raw is False: recommendation = f"

Alternative permanente :
sudo setcap cap_net_raw+p {diag.path}" QMessageBox.information( self, "Diagnostic des outils", "Outils réseau

" f"Nmap : {shutil.which('nmap') or 'introuvable'}
" f"Naabu utilisateur : {ndiag.user_path or 'introuvable'}
" f"  ↳ {ndiag.user_detail}
" f"Naabu Admin SYN : {ndiag.admin_path or 'indisponible'}
" f"  ↳ {ndiag.admin_detail}
" f"iproute2 : {shutil.which('ip') or 'introuvable'}
" f"arp-scan : {diag.path or 'introuvable'}
" f"CAP_NET_RAW : {cap}

" "Élévation Polkit
" f"Mode : {'ACTIF' if self.admin_mode else 'inactif'}
" f"pkexec : {pdiag.pkexec_path or 'introuvable'}
" f"helper : {pdiag.helper_path or 'introuvable'}
" f"politique : {pdiag.policy_path or 'introuvable'}
" f"État : {'OK' if pdiag.ready else 'INCOMPLET'} — {pdiag.detail}{recommendation}", ) def show_about(self) -> None: QMessageBox.about( self, "À propos de LibreNet Scanner", f"LibreNet Scanner {__version__}

" "Scanner réseau graphique libre pour Debian 13/KDE.
" "Python + PySide6/Qt6, arp-scan, Naabu 2.6.1 provisionné et Nmap.

" "V0.4 : interface KDE/Breeze épurée, vue Compacte/Détaillée, fiche équipement et actions contextuelles.

" "Licence : GPL-3.0-or-later.", )