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.",
)