diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2c4389..c54b692 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,16 +1,33 @@
-# Changelog
+# LibreNet Scanner — Release notes
-## 0.1.0 — 2026-08-21
+## 1.0.0 — Stable
-Première version Debian 13 / KDE :
+LibreNet Scanner 1.0.0 constitue la version stable de référence.
-- interface Qt 6 / PySide6 ;
-- détection du réseau local ;
-- profils Rapide, Standard et Approfondi ;
-- intégration Nmap et arp-scan ;
-- repli Nmap si arp-scan n'est pas autorisé pour l'utilisateur ;
-- inventaire IP/MAC/constructeur/ports/services ;
-- actions KDE (Konsole, Dolphin, navigateur, Remmina optionnel) ;
-- export CSV/JSON ;
-- historique SQLite ;
-- paquet Debian `.deb` architecture `all`.
+### Moteur réseau
+
+- découverte LAN combinant poste local, `arp-scan`, Nmap et voisinage Linux ;
+- moteur Standard adaptatif : Nmap borné sur les petits ensembles d'hôtes actifs, Naabu optionnel sur les ensembles importants ;
+- aucun scan Standard de ports sur l'intégralité d'un `/24` après découverte ;
+- délais maximaux explicites et repli Nmap limité aux hôtes actifs ;
+- scan Approfondi avec services, versions et détection OS en mode privilégié.
+
+### Stabilité
+
+- arrêt supervisé des processus utilisateur et privilégiés ;
+- annulation sans enregistrement d'un scan incomplet ;
+- séparation entre affichage, historique, identifications et métadonnées utilisateur ;
+- moteur d'identité robuste aux MAC virtuelles, partagées, clonées et localement administrées ;
+- package Debian sans téléchargement réseau obligatoire à l'installation.
+
+### Interface
+
+- interface Qt6/KDE orientée équipements ;
+- vues Compacte et Détaillée ;
+- actions contextuelles Web, SSH, SMB, RDP, Ping, Traceroute et Wake-on-LAN ;
+- icônes dédiées pour types d'équipements et familles de systèmes ;
+- favoris, groupes, notes, recherche, historique et comparaison de scans.
+
+### Validation
+
+- 191 tests automatisés couvrant le moteur réseau, le helper privilégié, les timeouts, l'annulation, l'identité, la persistance, l'interface et le packaging.
diff --git a/NAABU-LICENSE.txt b/NAABU-LICENSE.txt
new file mode 100644
index 0000000..b22968b
--- /dev/null
+++ b/NAABU-LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 ProjectDiscovery, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 171e3b0..c69b475 100644
--- a/README.md
+++ b/README.md
@@ -1,103 +1,308 @@
-# LibreNet Scanner 0.1.0
+
+
+
-LibreNet Scanner est un scanner réseau graphique pour Linux, pensé comme une alternative libre et simple à Advanced IP Scanner.
+
LibreNet Scanner
-Cette première version cible **Debian 13 (Trixie) + KDE Plasma** et utilise uniquement des composants disponibles dans les dépôts Debian.
+
+ Scanner réseau graphique libre, rapide et orienté équipements pour Linux.
+ Découverte, inventaire, services, identification et diagnostic dans une interface Qt pensée pour KDE Plasma.
+
-## Fonctionnalités V0.1
+
+
+
+
+
+
+
+
-- détection automatique des interfaces IPv4 et du CIDR local ;
-- filtrage par défaut des interfaces Docker/Podman/virbr/veth ;
-- 3 profils de scan : Rapide, Standard et Approfondi ;
-- découverte ARP avec `arp-scan` sur le LAN local ;
-- découverte et scan TCP avec `nmap` ;
-- affichage IP, nom DNS, MAC, constructeur, ports/services, OS si disponible, latence ;
-- scan détaillé des 1000 ports principaux d'une machine par double-clic/clic droit ;
-- actions KDE : HTTP/HTTPS, SSH et ping dans Konsole, SMB dans Dolphin, RDP avec Remmina si installé ;
-- export CSV et JSON ;
-- historique SQLite des scans ;
-- interface non lancée en root ;
-- limite de sécurité à 4096 adresses par scan dans cette V0.1.
+
+
+
+
+
+
+
+
+
+
+
+
+
-## Installation sur Debian 13
+> **LibreNet Scanner 1.0.0 est la version stable de référence.** Le moteur Standard privilégie la rapidité et la prédictibilité sur les petits réseaux, tout en pouvant accélérer les grands ensembles d'hôtes avec Naabu.
-Le plus simple est d'utiliser le paquet `.deb` fourni :
+---
+
+## ✨ Points forts
+
+- **Découverte rapide du LAN** avec `arp-scan`, Nmap et la table de voisinage Linux.
+- **Moteur Standard adaptatif** : Nmap sur les petits ensembles d'hôtes actifs, Naabu sur les ensembles importants lorsqu'il apporte un réel gain.
+- **Scan approfondi** avec versions de services et estimation du système d'exploitation.
+- **Mode Administrateur via Polkit** : la GUI reste non-root ; seul un helper strictement contrôlé reçoit les privilèges nécessaires.
+- **Identification orientée équipements** : poste, serveur, hyperviseur, NAS, pare-feu, routeur, switch, point d'accès, imprimante…
+- **Identification OS** avec pictogrammes Linux, BSD, Windows, Apple et Android.
+- **Résultats progressifs et scan interruptible** avec terminaison propre des processus.
+- **Historique local SQLite**, comparaison entre scans, favoris, groupes et notes.
+- **Actions contextuelles** : Web, SSH, SMB, RDP, Ping, Traceroute, Wake-on-LAN, copie IP/MAC.
+- **Interface compacte ou détaillée**, compatible Breeze clair/sombre sans thème graphique imposé.
+
+## 🚀 Installation
+
+### Paquet Debian 13
```bash
-sudo apt install ./librenet-scanner_0.1.0_all.deb
+sudo apt install ./librenet-scanner_1.0.0_amd64.deb
```
-APT installera automatiquement les dépendances (`python3-pyside6.qtwidgets`, `nmap`, `arp-scan`, etc.).
-
-Puis lancer :
+Puis lance LibreNet Scanner depuis le menu KDE ou avec :
```bash
librenet-scanner
```
-ou chercher **LibreNet Scanner** dans le menu KDE.
+Le paquet cible **Debian 13 (Trixie) amd64** et installe les dépendances principales via APT.
-## Dépendances
+### Exécution depuis les sources
-Obligatoires :
+```bash
+./run-from-source.sh
+```
-- Python 3
-- PySide6 / Qt 6
-- Nmap
-- arp-scan
-- iproute2
-- xdg-utils
-- iputils-ping
+Le projet nécessite Python 3.11+ et PySide6/Qt6.
-Recommandées :
+## 🧭 Les trois modes de scan
-- Konsole
-- Remmina
+| Mode | Objectif | Moteur principal |
+|---|---|---|
+| ⚡ **Rapide** | Trouver les machines présentes | ARP + découverte Nmap |
+| 🔎 **Standard** | Trouver les machines et leurs ports usuels | Moteur adaptatif Nmap / Naabu |
+| 🧬 **Approfondi** | Enrichir services, versions et OS | Socle Standard + Nmap `-sV` / OS |
-## Profils
+Le bouton principal **Scanner** lance le mode Standard. Le menu attenant permet de choisir Rapide ou Approfondi.
-### Rapide
+### Standard : moteur adaptatif
-Sur le réseau directement connecté : `arp-scan`. Pour une cible distante : découverte `nmap -sn`.
+LibreNet commence par identifier les **hôtes réellement actifs**, puis ne scanne les ports que sur ceux-ci.
-### Standard
+```text
+Cible réseau
+ │
+ ├── poste local
+ ├── arp-scan
+ └── découverte Nmap courte
+ │
+ ▼
+ hôtes actifs
+ │
+ ┌──────┴────────┐
+ │ │
+ < 32 hôtes ≥ 32 hôtes
+ │ │
+ Nmap borné Naabu par lots
+ │ │
+ └──────┬────────┘
+ ▼
+ ports ouverts
+```
-Découverte des hôtes puis scan d'une liste de ports d'administration courants : 22, 23, 53, 80, 139, 443, 445, 3389, 5900, 8006, 8080, 8443 et 9100.
+Pour un `/24` avec seulement quelques machines actives, LibreNet **ne lance pas un scan de ports sur les 254 adresses** : les hôtes sont d'abord découverts, puis seuls ceux qui répondent sont analysés.
-### Approfondi
+#### Garde-fous de performance
-Scan TCP des 100 ports les plus courants avec détection légère de version (`nmap -sT -sV --version-light`).
+- `arp-scan` est borné à **8 secondes** ;
+- découverte Nmap sans DNS (`-n`) et avec un retry maximum ;
+- scan Standard Nmap limité aux hôtes actifs et aux ports usuels ;
+- Naabu réservé aux ensembles d'au moins **32 hôtes actifs** ;
+- Naabu exécuté par lots de **32 hôtes**, avec plafond de temps par lot ;
+- repli Nmap limité aux hôtes déjà découverts si Naabu est indisponible ou trop lent ;
+- aucun résultat incomplet n'est enregistré comme scan réussi lorsqu'une phase essentielle échoue.
-Le double-clic sur une machine lance un scan des 1000 ports les plus courants avec détection de services.
+## 🛡️ Mode Administrateur
-## Sécurité
+Le mode **Admin** s'appuie sur Polkit. LibreNet Scanner ne lance jamais toute l'interface en root.
-LibreNet Scanner n'exécute pas son interface graphique en root et transmet les cibles à Nmap/arp-scan sous forme d'arguments, sans passer par un shell.
+Le helper privilégié est installé ici :
-Utilisez le programme uniquement sur des réseaux que vous êtes autorisé à scanner.
+```text
+/usr/libexec/librenet-scanner-helper
+```
-## Données locales
+La politique Polkit est installée ici :
-L'historique se trouve dans :
+```text
+/usr/share/polkit-1/actions/org.librenet.scanner.policy
+```
+
+Le mode Admin permet notamment :
+
+- `arp-scan` privilégié ;
+- scans TCP SYN (`-sS`) ;
+- détection OS lors des scans Approfondi et Détaillé.
+
+La découverte Standard reste volontairement cohérente entre le mode utilisateur et le mode Admin afin d'éviter que l'activation des privilèges modifie artificiellement la liste d'hôtes détectés.
+
+## 🛑 Annulation propre
+
+Le bouton **Arrêter** ne se contente pas d'interrompre l'interface : LibreNet supervise le processus réseau en cours.
+
+- en mode utilisateur, le groupe de processus est terminé proprement ;
+- en mode Admin, l'UI envoie un ordre `STOP` au helper privilégié ;
+- le helper applique une escalade `TERM → KILL` si nécessaire ;
+- un scan interrompu n'est pas enregistré comme un résultat complet.
+
+## 🖥️ Vue équipements
+
+### Vue compacte
+
+```text
+pve01.local 192.168.10.10 Hyperviseur Proxmox
+nas01.local 192.168.10.20 NAS
+printer01.local 192.168.10.30 Imprimante
+```
+
+### Vue détaillée
+
+```text
+pve01.local 192.168.10.10 Hyperviseur Proxmox
+ SSH 22/tcp OpenSSH
+ NFS 2049/tcp
+ Proxmox VE 8006/tcp
+```
+
+Un double-clic sur un service compatible peut ouvrir directement HTTP/HTTPS, SSH, SMB ou RDP.
+
+## 🧠 Identification et mémoire locale
+
+LibreNet sépare volontairement :
+
+- **les résultats visibles** ;
+- **l'historique des scans** ;
+- **les identifications mémorisées** ;
+- **les métadonnées utilisateur** : favoris, groupes et notes.
+
+Quand une MAC fiable est disponible, les métadonnées peuvent suivre l'équipement lors d'un changement d'adresse IP. La logique d'identité évite autant que possible les fusions dangereuses liées aux MAC virtuelles, clonées, partagées ou localement administrées.
+
+Les données sont conservées localement dans :
```text
~/.local/share/librenet-scanner/history.sqlite3
```
-ou sous `$XDG_DATA_HOME/librenet-scanner/` si cette variable est définie.
+## 🌐 Constructeurs et confidentialité
-## Licence
+Par défaut, LibreNet s'appuie sur les bases OUI locales.
-Le code de LibreNet Scanner est sous licence **GPL-3.0-or-later**.
+Une recherche constructeur en ligne peut être activée dans :
-Nmap et arp-scan sont des programmes externes installés séparément par Debian et conservent leurs propres licences.
+**Paramètres → Identification des constructeurs…**
-### À propos de `arp-scan` sur Debian 13
+Cette fonction est **désactivée par défaut**, car une recherche distante transmet la MAC complète au fournisseur choisi. Les résultats peuvent être mis en cache localement pendant 30 jours.
-Debian compile `arp-scan` avec le support des capabilities, mais le paquet indique qu'un utilisateur non privilégié peut devoir activer explicitement `CAP_NET_RAW` pour utiliser toutes les fonctions :
+## ⚙️ Naabu optionnel
+
+LibreNet Scanner fonctionne sans Naabu. L'installation du `.deb` **n'effectue aucun téléchargement réseau obligatoire**.
+
+Naabu 2.6.1 peut être ajouté comme accélérateur pour les grands ensembles d'hôtes :
```bash
-sudo setcap cap_net_raw+p /usr/sbin/arp-scan
+sudo /usr/libexec/librenet-scanner-install-naabu --ensure
```
-Cette commande n'est **pas exécutée automatiquement** par LibreNet Scanner. Si `arp-scan` n'est pas utilisable par l'utilisateur courant, le profil Rapide se replie automatiquement sur `nmap -sn` et l'interface graphique reste non-root.
+Le binaire validé est placé dans :
+
+```text
+/usr/lib/librenet-scanner/bin/naabu
+```
+
+LibreNet n'utilise pas arbitrairement un autre `naabu` trouvé dans le `$PATH`. L'installateur vérifie la version et le SHA-256 de l'archive officielle avant installation.
+
+## 🧰 Dépendances
+
+### Obligatoires
+
+- Python 3
+- PySide6 / Qt6
+- Nmap
+- arp-scan
+- iproute2
+- iputils-ping
+- xdg-utils
+- pkexec / Polkit
+
+### Recommandées ou optionnelles
+
+- `polkit-kde-agent-1` sous KDE Plasma
+- Konsole
+- Remmina
+- traceroute
+- libcap2-bin
+- Naabu 2.6.1 pour l'accélération des grands ensembles
+
+## ⌨️ Raccourcis utiles
+
+| Raccourci | Action |
+|---|---|
+| `F5` | Scan Standard |
+| `Ctrl+F5` | Scan Rapide |
+| `Shift+F5` | Scan Approfondi |
+| `Ctrl+F` | Recherche |
+
+## 🔐 Sécurité
+
+- aucune commande utilisateur n'est exécutée via un shell ;
+- les cibles sont validées avant exécution ;
+- la taille des scans est limitée ;
+- le helper privilégié n'accepte que des opérations prédéfinies ;
+- les moteurs privilégiés sont lancés avec des profils contrôlés ;
+- les scans sont conçus pour être interrompus proprement.
+
+> Utilise LibreNet Scanner uniquement sur des réseaux que tu possèdes ou que tu es autorisé à analyser.
+
+## ✅ Validation de la release stable
+
+La base 1.0.0 est couverte par **191 tests automatisés** portant notamment sur :
+
+- parsing Nmap / arp-scan / Naabu ;
+- validation des cibles ;
+- moteur adaptatif petit/grand réseau ;
+- délais maximaux et annulation ;
+- helper privilégié ;
+- identité des équipements et systèmes ;
+- historique et persistance ;
+- non-régression de l'interface et du packaging.
+
+Lancer la suite :
+
+```bash
+PYTHONPATH=src python3 -m unittest discover -s tests -v
+```
+
+## 📁 Arborescence
+
+```text
+librenet-scanner-1.0.0/
+├── assets/ logo, badges et icônes
+├── packaging/ paquet Debian, helper et Polkit
+├── src/librenet_scanner/ application Python
+├── tests/ suite automatisée
+├── README.md
+├── LICENSE
+└── THIRD_PARTY_ASSETS.md
+```
+
+## 📜 Licence
+
+LibreNet Scanner est distribué sous **GPL-3.0-or-later**.
+
+Nmap, arp-scan, Naabu et les pictogrammes tiers conservent leurs licences respectives. Les détails sont documentés dans [`THIRD_PARTY_ASSETS.md`](THIRD_PARTY_ASSETS.md) et [`NAABU-LICENSE.txt`](NAABU-LICENSE.txt).
+
+---
+
+
+
+ LibreNet Scanner 1.0.0 — Stable
+ Voir le réseau. Comprendre les équipements. Garder le contrôle.
+
diff --git a/THIRD_PARTY_ASSETS.md b/THIRD_PARTY_ASSETS.md
new file mode 100644
index 0000000..9e9edd1
--- /dev/null
+++ b/THIRD_PARTY_ASSETS.md
@@ -0,0 +1,47 @@
+# Third-party assets
+
+LibreNet Scanner est distribué sous GPLv3, mais certains pictogrammes graphiques embarqués ont leur propre licence.
+
+## Font Awesome Free — pictogrammes UI
+
+Les pictogrammes d’OS et d’équipement dans `assets/icons/` sont des rendus SVG dérivés de glyphes **Font Awesome Free**.
+
+Utilisations principales :
+
+- Linux / Tux (`linux`)
+- FreeBSD (`freebsd`)
+- Windows (`windows`)
+- Apple (`apple`)
+- Android (`android`)
+- poste (`desktop`)
+- serveur (`server`)
+- hyperviseur (`layer-group`)
+- pare-feu (`shield-alt`)
+- routeur (`ethernet`)
+- NAS (`hdd`)
+- switch (`network-wired`)
+- point d’accès (`wifi`)
+- imprimante (`print`)
+- équipement réseau (`sitemap`)
+
+Font Awesome Free (assets utilisés jusqu’à la série 6.7.2) : Copyright Fonticons, Inc. / Font Awesome contributors.
+Les icônes Font Awesome Free sont distribuées sous **CC BY 4.0**. Les fontes sont distribuées sous **SIL OFL 1.1** et le code Font Awesome sous licence MIT. Voir : https://fontawesome.com/license/free
+
+Les marques et logos représentés (notamment FreeBSD, Linux et Windows) restent la propriété de leurs détenteurs respectifs et sont utilisés uniquement comme identifiants visuels de plateformes.
+
+## BSD Daemon / Beastie
+
+LibreNet Scanner **n’embarque pas Beastie**, le BSD Daemon historique. Son image est protégée par des droits spécifiques. LibreNet Scanner utilise à la place le pictogramme FreeBSD de Font Awesome Free afin d’éviter d’introduire une licence/autorisation supplémentaire dans un dépôt public.
+
+## Naabu — moteur réseau ProjectDiscovery
+
+LibreNet Scanner peut utiliser **Naabu 2.6.1**, projet ProjectDiscovery distribué sous licence **MIT**, comme accélérateur de scan de ports lorsque le Standard a découvert un grand nombre d'hôtes. Il n'est pas requis pour le fonctionnement normal : sur les petits ensembles, Nmap est volontairement utilisé. Le binaire Naabu n'est pas versionné dans l'archive source LibreNet et l'installation du paquet n'effectue aucun téléchargement réseau. L'installateur optionnel place une version 2.6.1 vérifiée dans `/usr/lib/librenet-scanner/bin/naabu`. À l’exécution, LibreNet n’utilise pas directement un Naabu externe du `PATH`.
+
+Le téléchargement officiel est vérifié avant extraction avec le SHA-256 :
+
+```text
+018c4c9884dea971eda860435ede3021d1150732f34cfd245498c6726d8cab90
+```
+
+Projet officiel : https://github.com/projectdiscovery/naabu
+Licence Naabu : MIT. Le texte de licence est fourni dans `NAABU-LICENSE.txt`.
diff --git a/assets/badges/license.svg b/assets/badges/license.svg
new file mode 100644
index 0000000..564211f
--- /dev/null
+++ b/assets/badges/license.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/assets/badges/platform.svg b/assets/badges/platform.svg
new file mode 100644
index 0000000..284fdfd
--- /dev/null
+++ b/assets/badges/platform.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/assets/badges/status.svg b/assets/badges/status.svg
new file mode 100644
index 0000000..7ae9019
--- /dev/null
+++ b/assets/badges/status.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/assets/badges/tests.svg b/assets/badges/tests.svg
new file mode 100644
index 0000000..9bb25a5
--- /dev/null
+++ b/assets/badges/tests.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/assets/badges/ui.svg b/assets/badges/ui.svg
new file mode 100644
index 0000000..5e55260
--- /dev/null
+++ b/assets/badges/ui.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/assets/badges/version.svg b/assets/badges/version.svg
new file mode 100644
index 0000000..07a89d3
--- /dev/null
+++ b/assets/badges/version.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/assets/icons/equipment-access-point.svg b/assets/icons/equipment-access-point.svg
new file mode 100644
index 0000000..98affee
--- /dev/null
+++ b/assets/icons/equipment-access-point.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-firewall.svg b/assets/icons/equipment-firewall.svg
new file mode 100644
index 0000000..1f94ade
--- /dev/null
+++ b/assets/icons/equipment-firewall.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-hypervisor.svg b/assets/icons/equipment-hypervisor.svg
new file mode 100644
index 0000000..8637395
--- /dev/null
+++ b/assets/icons/equipment-hypervisor.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-nas.svg b/assets/icons/equipment-nas.svg
new file mode 100644
index 0000000..8be9b02
--- /dev/null
+++ b/assets/icons/equipment-nas.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-network-device.svg b/assets/icons/equipment-network-device.svg
new file mode 100644
index 0000000..9b815ac
--- /dev/null
+++ b/assets/icons/equipment-network-device.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-printer.svg b/assets/icons/equipment-printer.svg
new file mode 100644
index 0000000..99f2692
--- /dev/null
+++ b/assets/icons/equipment-printer.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-router.svg b/assets/icons/equipment-router.svg
new file mode 100644
index 0000000..1831328
--- /dev/null
+++ b/assets/icons/equipment-router.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-server.svg b/assets/icons/equipment-server.svg
new file mode 100644
index 0000000..1efbdb7
--- /dev/null
+++ b/assets/icons/equipment-server.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-switch.svg b/assets/icons/equipment-switch.svg
new file mode 100644
index 0000000..00e0933
--- /dev/null
+++ b/assets/icons/equipment-switch.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-unknown.svg b/assets/icons/equipment-unknown.svg
new file mode 100644
index 0000000..848f653
--- /dev/null
+++ b/assets/icons/equipment-unknown.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/equipment-workstation.svg b/assets/icons/equipment-workstation.svg
new file mode 100644
index 0000000..b3b105f
--- /dev/null
+++ b/assets/icons/equipment-workstation.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/os-android.svg b/assets/icons/os-android.svg
new file mode 100755
index 0000000..d314310
--- /dev/null
+++ b/assets/icons/os-android.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/os-apple.svg b/assets/icons/os-apple.svg
new file mode 100755
index 0000000..053904f
--- /dev/null
+++ b/assets/icons/os-apple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/os-bsd.svg b/assets/icons/os-bsd.svg
new file mode 100644
index 0000000..79ac697
--- /dev/null
+++ b/assets/icons/os-bsd.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/os-linux.svg b/assets/icons/os-linux.svg
new file mode 100644
index 0000000..15ebddd
--- /dev/null
+++ b/assets/icons/os-linux.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/os-other.svg b/assets/icons/os-other.svg
new file mode 100644
index 0000000..99025a3
--- /dev/null
+++ b/assets/icons/os-other.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/os-windows.svg b/assets/icons/os-windows.svg
new file mode 100644
index 0000000..de1b735
--- /dev/null
+++ b/assets/icons/os-windows.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/librenet-scanner.desktop b/assets/librenet-scanner.desktop
index 77369e4..04b6e6e 100644
--- a/assets/librenet-scanner.desktop
+++ b/assets/librenet-scanner.desktop
@@ -2,10 +2,11 @@
Type=Application
Name=LibreNet Scanner
GenericName=Scanner réseau
-Comment=Découvrir les machines et services d'un réseau avec Nmap et arp-scan
+Comment=Découvrir les machines et services d'un réseau avec arp-scan, Naabu et Nmap
Exec=librenet-scanner
Icon=librenet-scanner
Terminal=false
Categories=Network;System;Utility;
-Keywords=network;scanner;nmap;arp;ip;lan;
+Keywords=network;scanner;nmap;naabu;arp;ip;lan;inventory;
StartupNotify=true
+StartupWMClass=librenet-scanner
diff --git a/install-debian13.sh b/install-debian13.sh
index 2b4c6a2..68722d2 100755
--- a/install-debian13.sh
+++ b/install-debian13.sh
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-DEB="$HERE/dist/librenet-scanner_0.1.0_all.deb"
+DEB="$HERE/dist/librenet-scanner_1.0.0_amd64.deb"
if [ ! -f "$DEB" ]; then
echo "Paquet .deb absent. Construction..."
"$HERE/packaging/build-deb.sh"
diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh
index 3237065..e243d5c 100755
--- a/packaging/build-deb.sh
+++ b/packaging/build-deb.sh
@@ -1,7 +1,8 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
-VERSION=0.1.0
+VERSION=1.0.0
+ARCH=amd64
PKGROOT="/tmp/librenet-scanner-debroot-$$"
trap 'rm -rf "$PKGROOT"' EXIT HUP INT TERM
OUT="$ROOT/dist"
@@ -11,9 +12,19 @@ mkdir -p "$PKGROOT/DEBIAN" \
"$PKGROOT/usr/bin" \
"$PKGROOT/usr/share/applications" \
"$PKGROOT/usr/share/icons/hicolor/scalable/apps" \
+ "$PKGROOT/usr/share/librenet-scanner/icons" \
"$PKGROOT/usr/share/doc/librenet-scanner" \
+ "$PKGROOT/usr/share/doc/librenet-scanner/assets" \
+ "$PKGROOT/usr/share/doc/librenet-scanner/assets/badges" \
+ "$PKGROOT/usr/share/doc/librenet-scanner/assets/icons" \
+ "$PKGROOT/usr/libexec" \
+ "$PKGROOT/usr/share/polkit-1/actions" \
+ "$PKGROOT/usr/lib/librenet-scanner/bin" \
"$OUT"
+rm -f "$OUT"/librenet-scanner_*.deb
cp "$ROOT/packaging/debian/control" "$PKGROOT/DEBIAN/control"
+cp "$ROOT/packaging/debian/postinst" "$PKGROOT/DEBIAN/postinst"
+cp "$ROOT/packaging/debian/postrm" "$PKGROOT/DEBIAN/postrm"
cp "$ROOT/src/librenet_scanner/"*.py "$PKGROOT/usr/lib/python3/dist-packages/librenet_scanner/"
cat > "$PKGROOT/usr/bin/librenet-scanner" <<'EOS'
#!/bin/sh
@@ -22,10 +33,24 @@ EOS
chmod 0755 "$PKGROOT/usr/bin/librenet-scanner"
cp "$ROOT/assets/librenet-scanner.desktop" "$PKGROOT/usr/share/applications/"
cp "$ROOT/assets/librenet-scanner.svg" "$PKGROOT/usr/share/icons/hicolor/scalable/apps/"
+cp "$ROOT/assets/icons/"*.svg "$PKGROOT/usr/share/librenet-scanner/icons/"
+cp "$ROOT/packaging/librenet-scanner-helper" "$PKGROOT/usr/libexec/librenet-scanner-helper"
+cp "$ROOT/packaging/librenet-scanner-install-naabu" "$PKGROOT/usr/libexec/librenet-scanner-install-naabu"
+cp "$ROOT/packaging/org.librenet.scanner.policy" "$PKGROOT/usr/share/polkit-1/actions/org.librenet.scanner.policy"
cp "$ROOT/README.md" "$PKGROOT/usr/share/doc/librenet-scanner/README.md"
+cp "$ROOT/CHANGELOG.md" "$PKGROOT/usr/share/doc/librenet-scanner/CHANGELOG.md"
+cp "$ROOT/assets/librenet-scanner.svg" "$PKGROOT/usr/share/doc/librenet-scanner/assets/"
+cp "$ROOT/assets/badges/"*.svg "$PKGROOT/usr/share/doc/librenet-scanner/assets/badges/"
+cp "$ROOT/assets/icons/"*.svg "$PKGROOT/usr/share/doc/librenet-scanner/assets/icons/"
cp "$ROOT/LICENSE" "$PKGROOT/usr/share/doc/librenet-scanner/LICENSE"
+cp "$ROOT/THIRD_PARTY_ASSETS.md" "$PKGROOT/usr/share/doc/librenet-scanner/THIRD_PARTY_ASSETS.md"
+cp "$ROOT/NAABU-LICENSE.txt" "$PKGROOT/usr/share/doc/librenet-scanner/NAABU-LICENSE.txt"
find "$PKGROOT" -type d -exec chmod 0755 {} +
find "$PKGROOT" -type f -exec chmod 0644 {} +
-chmod 0755 "$PKGROOT/usr/bin/librenet-scanner" "$PKGROOT/DEBIAN"
-dpkg-deb --root-owner-group --build "$PKGROOT" "$OUT/librenet-scanner_${VERSION}_all.deb"
-echo "$OUT/librenet-scanner_${VERSION}_all.deb"
+chmod 0755 \
+ "$PKGROOT/usr/bin/librenet-scanner" \
+ "$PKGROOT/usr/libexec/librenet-scanner-helper" \
+ "$PKGROOT/usr/libexec/librenet-scanner-install-naabu" \
+ "$PKGROOT/DEBIAN/postinst" "$PKGROOT/DEBIAN/postrm"
+dpkg-deb --root-owner-group --build "$PKGROOT" "$OUT/librenet-scanner_${VERSION}_${ARCH}.deb"
+echo "$OUT/librenet-scanner_${VERSION}_${ARCH}.deb"
diff --git a/packaging/debian/control b/packaging/debian/control
index 03e32d2..67feb15 100644
--- a/packaging/debian/control
+++ b/packaging/debian/control
@@ -1,12 +1,14 @@
Package: librenet-scanner
-Version: 0.1.0
+Version: 1.0.0
Section: net
Priority: optional
-Architecture: all
+Architecture: amd64
Maintainer: Local package
-Depends: python3, python3-pyside6.qtwidgets, nmap, arp-scan, iproute2, xdg-utils, iputils-ping
-Suggests: konsole, remmina
-Description: scanner reseau graphique libre pour Linux
- LibreNet Scanner fournit une interface Qt simple pour decouvrir les hotes,
- adresses MAC, constructeurs et services d'un reseau en utilisant Nmap et
- arp-scan comme moteurs externes.
+Depends: python3, python3-pyside6.qtwidgets, nmap, arp-scan, iproute2, xdg-utils, iputils-ping, pkexec
+Recommends: polkit-kde-agent-1
+Suggests: konsole, remmina, traceroute, libcap2-bin, ca-certificates, libpcap0.8t64
+Description: scanner reseau graphique libre et adaptatif pour Linux
+ LibreNet Scanner fournit une interface Qt orientee equipements pour decouvrir,
+ identifier et inventorier les hotes et services d'un reseau. Le moteur combine
+ arp-scan et Nmap, avec Naabu 2.6.1 comme accelerateur optionnel pour les grands
+ ensembles d'hotes.
diff --git a/packaging/debian/postinst b/packaging/debian/postinst
new file mode 100755
index 0000000..3867fd2
--- /dev/null
+++ b/packaging/debian/postinst
@@ -0,0 +1,5 @@
+#!/bin/sh
+set -eu
+# Aucune dépendance réseau pendant l'installation : Naabu est un accélérateur
+# optionnel. LibreNet reste pleinement fonctionnel avec Nmap seul.
+exit 0
diff --git a/packaging/debian/postrm b/packaging/debian/postrm
new file mode 100755
index 0000000..bec2010
--- /dev/null
+++ b/packaging/debian/postrm
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -eu
+
+case "${1:-}" in
+ remove|purge)
+ rm -f /usr/lib/librenet-scanner/bin/naabu
+ rmdir /usr/lib/librenet-scanner/bin 2>/dev/null || true
+ rmdir /usr/lib/librenet-scanner 2>/dev/null || true
+ ;;
+esac
+
+exit 0
diff --git a/packaging/librenet-scanner-helper b/packaging/librenet-scanner-helper
new file mode 100755
index 0000000..68becaa
--- /dev/null
+++ b/packaging/librenet-scanner-helper
@@ -0,0 +1,4 @@
+#!/usr/bin/python3
+from librenet_scanner.privileged_helper import main
+
+raise SystemExit(main())
diff --git a/packaging/librenet-scanner-install-naabu b/packaging/librenet-scanner-install-naabu
new file mode 100755
index 0000000..0901be1
--- /dev/null
+++ b/packaging/librenet-scanner-install-naabu
@@ -0,0 +1,4 @@
+#!/usr/bin/python3
+from librenet_scanner.naabu_runtime import main
+
+raise SystemExit(main())
diff --git a/packaging/org.librenet.scanner.policy b/packaging/org.librenet.scanner.policy
new file mode 100644
index 0000000..25c582b
--- /dev/null
+++ b/packaging/org.librenet.scanner.policy
@@ -0,0 +1,20 @@
+
+
+
+ LibreNet Scanner
+
+ Exécuter un scan réseau privilégié avec LibreNet Scanner
+ Exécuter un scan réseau privilégié avec LibreNet Scanner
+ Authentication is required to enable LibreNet Scanner administrator mode
+ Authentification requise pour activer le mode administrateur de LibreNet Scanner
+
+ no
+ no
+ auth_admin_keep
+
+ /usr/libexec/librenet-scanner-helper
+ false
+
+
diff --git a/pyproject.toml b/pyproject.toml
index cebee6f..7d88c88 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "librenet-scanner"
-version = "0.1.0"
-description = "Scanner réseau graphique libre pour Linux, basé sur Nmap et arp-scan"
+version = "1.0.0"
+description = "Scanner réseau graphique libre pour Linux avec moteur adaptatif arp-scan, Nmap et Naabu"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "GPL-3.0-or-later"}
diff --git a/src/librenet_scanner/__init__.py b/src/librenet_scanner/__init__.py
index aff5dac..db388da 100644
--- a/src/librenet_scanner/__init__.py
+++ b/src/librenet_scanner/__init__.py
@@ -1,3 +1,3 @@
"""LibreNet Scanner - scanner réseau graphique libre pour Linux."""
-__version__ = "0.1.0"
+__version__ = "1.0.0"
diff --git a/src/librenet_scanner/actions.py b/src/librenet_scanner/actions.py
new file mode 100644
index 0000000..e078707
--- /dev/null
+++ b/src/librenet_scanner/actions.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+import re
+import socket
+
+
+MAC_RE = re.compile(r"^[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}$")
+
+
+def normalize_mac(mac: str) -> str:
+ value = mac.strip().replace("-", ":")
+ if not MAC_RE.match(value):
+ raise ValueError(f"Adresse MAC invalide : {mac}")
+ return value.upper()
+
+
+def send_magic_packet(mac: str, broadcast: str = "255.255.255.255", port: int = 9) -> None:
+ normalized = normalize_mac(mac)
+ raw_mac = bytes.fromhex(normalized.replace(":", ""))
+ packet = b"\xff" * 6 + raw_mac * 16
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.sendto(packet, (broadcast, port))
diff --git a/src/librenet_scanner/comparison.py b/src/librenet_scanner/comparison.py
new file mode 100644
index 0000000..9b606a6
--- /dev/null
+++ b/src/librenet_scanner/comparison.py
@@ -0,0 +1,160 @@
+from __future__ import annotations
+
+from collections import Counter
+from copy import deepcopy
+
+from .identity import mac_identity_kind, normalize_mac
+from .intelligence import enrich_host
+from .models import Host
+
+
+def _port_keys(host: Host) -> set[tuple[int, str]]:
+ return {(p.port, p.protocol) for p in host.ports if p.state == "open"}
+
+
+def _short_port(port: tuple[int, str]) -> str:
+ number, protocol = port
+ return f"{number}/{protocol}"
+
+
+def _host_differences(current: Host, previous: Host) -> list[str]:
+ details: list[str] = []
+ if current.mac and previous.mac and current.mac.upper() != previous.mac.upper():
+ details.append("MAC changée")
+ if current.hostname and previous.hostname and current.hostname != previous.hostname:
+ details.append(f"Nom : {previous.hostname} → {current.hostname}")
+ if current.os_name and previous.os_name and current.os_name != previous.os_name:
+ details.append("OS modifié")
+
+ cur_ports = _port_keys(current)
+ prev_ports = _port_keys(previous)
+ added = sorted(cur_ports - prev_ports)
+ removed = sorted(prev_ports - cur_ports)
+ if added:
+ details.append("Ports + " + ", ".join(_short_port(p) for p in added))
+ if removed:
+ details.append("Ports - " + ", ".join(_short_port(p) for p in removed))
+ return details
+
+
+def _unique_mac_map(hosts: list[Host]) -> dict[str, Host]:
+ normalized = [normalize_mac(host.mac) for host in hosts if host.mac]
+ counts = Counter(mac for mac in normalized if mac)
+ result: dict[str, Host] = {}
+ for host in hosts:
+ mac = normalize_mac(host.mac)
+ if not mac or counts[mac] != 1:
+ continue
+ # Une MAC virtuelle (VRRP/CARP/HSRP) désigne un endpoint logique et peut
+ # changer de nœud physique. On ne l'utilise pas pour conclure à un move IP.
+ if mac_identity_kind(mac) == "virtual":
+ continue
+ result[mac] = host
+ return result
+
+
+def _can_infer_ip_move(current: Host, previous: Host) -> bool:
+ """Décide si une MAC identique suffit à conclure à un changement d'IP.
+
+ Une MAC globale unique est un signal fort entre deux scans consécutifs. Une
+ LAA peut en revanche être stable ou générée ; elle exige donc un hostname ou
+ une signature de services concordante. Les MAC virtuelles ne sont jamais
+ utilisées pour suivre un nœud physique.
+ """
+ mac = normalize_mac(current.mac)
+ if not mac or mac != normalize_mac(previous.mac):
+ return False
+ kind = mac_identity_kind(mac)
+ if kind == "global":
+ if current.hostname and previous.hostname and current.hostname != previous.hostname:
+ overlap = _port_keys(current) & _port_keys(previous)
+ if not overlap:
+ return False
+ return True
+ if kind == "laa":
+ # Entre deux scans consécutifs, une LAA strictement identique et unique est
+ # un indice suffisant pour signaler un déplacement d'IP. Cela ne lui donne
+ # PAS pour autant le niveau de confiance nécessaire à l'héritage long terme
+ # d'un fingerprint (géré séparément par identity.py).
+ if current.hostname and previous.hostname and current.hostname != previous.hostname:
+ overlap = _port_keys(current) & _port_keys(previous)
+ if not overlap:
+ return False
+ return True
+ return False
+
+
+def compare_hosts(current_hosts: list[Host], previous_hosts: list[Host] | None) -> list[Host]:
+ """Marque les changements et renvoie aussi les hôtes disparus.
+
+ Une IP n'est pas une identité. Si la même IP présente une autre MAC entre deux
+ scans, LibreNet signale la MAC/identité comme incertaine et ne transfère pas
+ l'historique. Les changements d'IP ne sont inférés que lorsqu'une MAC est unique
+ dans les deux scans et que sa nature fournit un niveau de preuve suffisant.
+ """
+ for host in current_hosts:
+ enrich_host(host)
+ host.change_status = ""
+ host.change_detail = ""
+ host.previous_ip = ""
+
+ if previous_hosts is None:
+ return current_hosts
+
+ prev_by_ip = {h.ip: h for h in previous_hosts}
+ prev_by_mac = _unique_mac_map(previous_hosts)
+ cur_unique_macs = _unique_mac_map(current_hosts)
+ matched_prev_ips: set[str] = set()
+
+ for host in current_hosts:
+ previous = prev_by_ip.get(host.ip)
+ if previous is not None:
+ current_mac = normalize_mac(host.mac)
+ previous_mac = normalize_mac(previous.mac)
+ if current_mac and previous_mac and current_mac != previous_mac:
+ # Une IP identique avec une autre MAC peut être un bail DHCP réattribué,
+ # mais aussi un bond/bridge qui bascule, une NIC remplacée ou une MAC
+ # privée qui tourne. On signale le changement sans prétendre connaître
+ # l'identité physique. L'historique riche n'est pas transféré.
+ matched_prev_ips.add(previous.ip)
+ if "laa" in {mac_identity_kind(current_mac), mac_identity_kind(previous_mac)}:
+ host.change_status = "Identité incertaine"
+ else:
+ host.change_status = "MAC modifiée"
+ host.change_detail = (
+ f"Même IP, MAC différente : {previous_mac} → {current_mac}; "
+ "identification historique non transférée"
+ )
+ continue
+ matched_prev_ips.add(previous.ip)
+ details = _host_differences(host, previous)
+ if details:
+ host.change_status = "Modifié"
+ host.change_detail = "; ".join(details)
+ else:
+ host.change_status = "Inchangé"
+ continue
+
+ mac = normalize_mac(host.mac)
+ moved_from = prev_by_mac.get(mac) if mac and mac in cur_unique_macs else None
+ if moved_from is not None and _can_infer_ip_move(host, moved_from):
+ matched_prev_ips.add(moved_from.ip)
+ host.change_status = "IP modifiée"
+ host.previous_ip = moved_from.ip
+ host.change_detail = f"{moved_from.ip} → {host.ip}"
+ else:
+ host.change_status = "Nouveau"
+ host.change_detail = "Absent du scan précédent"
+
+ result = list(current_hosts)
+ for previous in previous_hosts:
+ if previous.ip in matched_prev_ips:
+ continue
+ ghost = deepcopy(previous)
+ enrich_host(ghost)
+ ghost.status = "down"
+ ghost.change_status = "Disparu"
+ ghost.change_detail = "Présent au scan précédent, absent de ce scan"
+ ghost.latency_ms = None
+ result.append(ghost)
+ return result
diff --git a/src/librenet_scanner/diagnostics.py b/src/librenet_scanner/diagnostics.py
new file mode 100644
index 0000000..21b9293
--- /dev/null
+++ b/src/librenet_scanner/diagnostics.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from dataclasses import dataclass
+
+
+@dataclass(slots=True, frozen=True)
+class ArpScanDiagnostic:
+ path: str | None
+ cap_net_raw: bool | None
+ detail: str
+
+
+def find_arp_scan() -> str | None:
+ found = shutil.which("arp-scan")
+ if found:
+ return found
+ for candidate in ("/usr/sbin/arp-scan", "/usr/bin/arp-scan"):
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ return candidate
+ return None
+
+
+def parse_getcap_output(text: str) -> bool:
+ lowered = text.casefold()
+ return "cap_net_raw" in lowered
+
+
+def arp_scan_succeeded(returncode: int) -> bool:
+ """arp-scan considère uniquement le code retour 0 comme un succès."""
+ return returncode == 0
+
+
+def arp_scan_diagnostic() -> ArpScanDiagnostic:
+ path = find_arp_scan()
+ if not path:
+ return ArpScanDiagnostic(None, False, "arp-scan est introuvable")
+
+ if os.geteuid() == 0:
+ return ArpScanDiagnostic(path, True, "application exécutée avec les privilèges root")
+
+ getcap = shutil.which("getcap")
+ if not getcap:
+ return ArpScanDiagnostic(
+ path,
+ None,
+ "getcap est absent : la capability CAP_NET_RAW ne peut pas être vérifiée automatiquement",
+ )
+
+ try:
+ proc = subprocess.run(
+ [getcap, path],
+ capture_output=True,
+ text=True,
+ timeout=3,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError) as exc:
+ return ArpScanDiagnostic(path, None, f"vérification getcap impossible : {exc}")
+
+ has_cap = parse_getcap_output(proc.stdout)
+ if has_cap:
+ detail = "CAP_NET_RAW est présente"
+ else:
+ detail = "CAP_NET_RAW n'est pas détectée"
+ return ArpScanDiagnostic(path, has_cap, detail)
diff --git a/src/librenet_scanner/exporters.py b/src/librenet_scanner/exporters.py
index 0ae633e..74fbaa3 100644
--- a/src/librenet_scanner/exporters.py
+++ b/src/librenet_scanner/exporters.py
@@ -10,12 +10,20 @@ from .models import Host
def export_csv(path: str | Path, hosts: list[Host]) -> None:
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
- writer.writerow(["status", "hostname", "ip", "mac", "vendor", "ports", "os", "latency_ms", "last_seen"])
+ writer.writerow([
+ "status", "change", "change_detail", "device_type", "is_local", "hostname", "ip", "previous_ip",
+ "mac", "vendor", "ports", "os", "latency_ms", "last_seen",
+ ])
for host in hosts:
writer.writerow([
host.status,
+ host.change_status,
+ host.change_detail,
+ host.device_type,
+ "yes" if host.is_local else "no",
host.hostname,
host.ip,
+ host.previous_ip,
host.mac,
host.vendor,
host.ports_summary,
@@ -30,8 +38,13 @@ def export_json(path: str | Path, hosts: list[Host]) -> None:
for host in hosts:
payload.append({
"status": host.status,
+ "change": host.change_status,
+ "change_detail": host.change_detail,
+ "device_type": host.device_type,
+ "is_local": host.is_local,
"hostname": host.hostname,
"ip": host.ip,
+ "previous_ip": host.previous_ip,
"mac": host.mac,
"vendor": host.vendor,
"os": host.os_name,
@@ -41,6 +54,7 @@ def export_json(path: str | Path, hosts: list[Host]) -> None:
{
"port": p.port,
"protocol": p.protocol,
+ "state": p.state,
"service": p.service,
"product": p.product,
"version": p.version,
diff --git a/src/librenet_scanner/fastscan.py b/src/librenet_scanner/fastscan.py
new file mode 100644
index 0000000..02d9cab
--- /dev/null
+++ b/src/librenet_scanner/fastscan.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import ipaddress
+import json
+import os
+from dataclasses import dataclass
+from .intelligence import canonical_service_name, enrich_host
+from .models import Host, PortInfo
+from .naabu_runtime import BUNDLED_NAABU_PATH, NAABU_VERSION, naabu_version, trusted_root_binary
+
+
+SYSTEM_NAABU_CANDIDATES = (BUNDLED_NAABU_PATH,)
+
+
+@dataclass(slots=True, frozen=True)
+class NaabuDiagnostic:
+ user_path: str | None
+ admin_path: str | None
+ user_detail: str
+ admin_detail: str
+
+
+def find_naabu() -> str | None:
+ """Retourne exclusivement le moteur Naabu provisionné par LibreNet.
+
+ Le paquet peut provisionner un Naabu 2.6.1 dans l'espace privé de
+ LibreNet comme accélérateur optionnel pour les grands ensembles d'hôtes. Ne jamais choisir silencieusement un ``naabu`` du PATH évite qu'une
+ version différente modifie les options ou le comportement du profil Standard.
+ Si le moteur intégré est absent ou corrompu, le scanner reste pleinement
+ utilisable avec Nmap au lieu d'utiliser un binaire inconnu.
+ """
+ candidate = BUNDLED_NAABU_PATH
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ if naabu_version(candidate) == NAABU_VERSION:
+ return candidate
+ return None
+
+
+def _trusted_system_naabu(path: str) -> bool:
+ """Même règle de confiance que le helper root, sans exécuter de privilèges."""
+ return trusted_root_binary(path)
+
+
+def find_admin_naabu() -> str | None:
+ """Retourne le moteur LibreNet exact et sûr acceptable par le helper Polkit."""
+ candidate = BUNDLED_NAABU_PATH
+ if _trusted_system_naabu(candidate) and naabu_version(candidate) == NAABU_VERSION:
+ return candidate
+ return None
+
+
+def naabu_diagnostic() -> NaabuDiagnostic:
+ user_path = find_naabu()
+ admin_path = find_admin_naabu()
+ if user_path:
+ version = naabu_version(user_path)
+ suffix = f" — v{version}" if version else " — version non confirmée"
+ source = "moteur LibreNet intégré" if user_path == BUNDLED_NAABU_PATH else "moteur externe"
+ user_detail = f"OK — {source}{suffix}"
+ else:
+ user_detail = "INDISPONIBLE — Nmap sera utilisé (fonctionnement normal)"
+ if admin_path:
+ version = naabu_version(admin_path)
+ suffix = f", v{version}" if version else ", version non confirmée"
+ source = "moteur LibreNet intégré" if admin_path == BUNDLED_NAABU_PATH else "moteur externe"
+ admin_detail = f"OK — {source}, root, non modifiable par groupe/autres{suffix}"
+ elif user_path:
+ admin_detail = (
+ "INDISPONIBLE — un Naabu utilisateur existe, mais le mode Admin exige "
+ "le moteur LibreNet ou un Naabu système appartenant à root"
+ )
+ else:
+ admin_detail = "INDISPONIBLE — aucun Naabu système de confiance"
+ return NaabuDiagnostic(user_path, admin_path, user_detail, admin_detail)
+
+
+def _naabu_payload(line: str) -> dict | None:
+ try:
+ payload = json.loads(line)
+ except (TypeError, json.JSONDecodeError):
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+def _payload_ipv4(payload: dict) -> str | None:
+ value = str(payload.get("ip") or payload.get("host") or "").strip()
+ try:
+ address = ipaddress.ip_address(value)
+ except ValueError:
+ return None
+ if address.version != 4:
+ return None
+ return str(address)
+
+
+def parse_naabu_host_json_line(line: str) -> Host | None:
+ """Parse une ligne JSONL issue de ``naabu -sn -json``.
+
+ Depuis Naabu 2.4, un scan de découverte JSON émet aussi les résultats sans port.
+ LibreNet n'a besoin ici que de l'IPv4 : l'équipement est marqué actif et sera
+ enrichi par ARP/neighbor/ports lors des phases suivantes.
+ """
+ payload = _naabu_payload(line)
+ if payload is None:
+ return None
+ ip = _payload_ipv4(payload)
+ if not ip:
+ return None
+ return Host(ip=ip, status="up")
+
+
+def parse_naabu_json_line(line: str) -> Host | None:
+ """Transforme une ligne JSONL de scan de ports Naabu en observation LibreNet."""
+ payload = _naabu_payload(line)
+ if payload is None:
+ return None
+ ip = _payload_ipv4(payload)
+ if not ip:
+ return None
+ try:
+ port = int(payload.get("port"))
+ except (ValueError, TypeError):
+ return None
+ if not 1 <= port <= 65535:
+ return None
+ protocol = str(payload.get("protocol") or "tcp").strip().lower()
+ if protocol not in {"tcp", "udp"}:
+ protocol = "tcp"
+ service = str(payload.get("service") or "").strip()
+ host = Host(
+ ip=ip,
+ status="up",
+ ports=[
+ PortInfo(
+ port=port,
+ protocol=protocol,
+ state="open",
+ service=canonical_service_name(port, protocol, service),
+ )
+ ],
+ )
+ return enrich_host(host)
diff --git a/src/librenet_scanner/identity.py b/src/librenet_scanner/identity.py
new file mode 100644
index 0000000..0def851
--- /dev/null
+++ b/src/librenet_scanner/identity.py
@@ -0,0 +1,405 @@
+from __future__ import annotations
+
+import ipaddress
+import json
+import re
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Iterable, Mapping, Sequence
+
+from .models import Host
+
+
+AUTO_APPLY_THRESHOLD = 85
+STALE_MOVE_DAYS = 180
+
+
+@dataclass(frozen=True, slots=True)
+class IdentityMatch:
+ """Résultat explicable d'une tentative de corrélation historique.
+
+ Le but n'est pas de prétendre connaître l'identité physique absolue d'un
+ équipement, ce qui est impossible depuis un simple scan réseau, mais de
+ décider si une ancienne identification peut être réutilisée sans risque
+ déraisonnable de la coller au mauvais hôte.
+ """
+
+ score: int
+ reason: str
+ identity_kind: str
+ safe_to_apply: bool
+
+
+def normalize_mac(mac: str) -> str:
+ compact = re.sub(r"[^0-9A-Fa-f]", "", mac or "")
+ if len(compact) != 12:
+ return ""
+ try:
+ bytes.fromhex(compact)
+ except ValueError:
+ return ""
+ return ":".join(compact[i : i + 2] for i in range(0, 12, 2)).upper()
+
+
+def _mac_bytes(mac: str) -> bytes:
+ normalized = normalize_mac(mac)
+ return bytes.fromhex(normalized.replace(":", "")) if normalized else b""
+
+
+def is_multicast_mac(mac: str) -> bool:
+ raw = _mac_bytes(mac)
+ return bool(raw and (raw[0] & 0x01))
+
+
+def is_locally_administered_mac(mac: str) -> bool:
+ raw = _mac_bytes(mac)
+ return bool(raw and (raw[0] & 0x02))
+
+
+def is_known_virtual_mac(mac: str) -> bool:
+ """Détecte quelques familles de MAC de redondance L2 bien connues.
+
+ - VRRPv3 IPv4 : 00:00:5E:00:01:xx
+ - VRRPv3 IPv6 : 00:00:5E:00:02:xx
+ - CARP (OPNsense/pfSense) : 00:00:5E:00:01:xx
+ - Cisco HSRPv1 : 00:00:0C:07:AC:xx
+ - Cisco HSRPv2 : 00:00:0C:9F:F0:00 .. 00:00:0C:9F:FF:FF
+
+ Une MAC virtuelle identifie un service/redondance, pas nécessairement une
+ machine physique. On la traite donc plus prudemment qu'une MAC globale.
+ """
+ raw = _mac_bytes(mac)
+ if len(raw) != 6:
+ return False
+ if raw[:5] in (b"\x00\x00\x5e\x00\x01", b"\x00\x00\x5e\x00\x02"):
+ return True
+ if raw[:5] == b"\x00\x00\x0c\x07\xac":
+ return True
+ if raw[:4] == b"\x00\x00\x0c\x9f" and (raw[4] & 0xF0) == 0xF0:
+ return True
+ return False
+
+
+def mac_identity_kind(mac: str) -> str:
+ normalized = normalize_mac(mac)
+ if not normalized:
+ return "none"
+ if is_multicast_mac(normalized):
+ return "multicast"
+ if is_known_virtual_mac(normalized):
+ return "virtual"
+ if is_locally_administered_mac(normalized):
+ return "laa"
+ return "global"
+
+
+def shared_macs(hosts: Iterable[Host]) -> set[str]:
+ by_mac: dict[str, set[str]] = {}
+ for host in hosts:
+ mac = normalize_mac(host.mac)
+ if not mac or host.status == "down":
+ continue
+ by_mac.setdefault(mac, set()).add(host.ip)
+ return {mac for mac, ips in by_mac.items() if len(ips) > 1}
+
+
+def identity_key(host: Host, *, shared_mac: bool = False) -> str:
+ """Clé de persistance prudente.
+
+ Une MAC partagée par plusieurs IP pendant le même scan peut être un bridge,
+ un proxy ARP, une VIP ou un clone. Dans ce cas on ne fusionne pas toutes ces
+ IP dans une seule identité persistante : la clé inclut aussi l'IP.
+ """
+ if host.is_local:
+ return "local:self"
+ mac = normalize_mac(host.mac)
+ if mac:
+ if shared_mac:
+ return f"macip:{mac}@{host.ip}"
+ return f"mac:{mac}"
+ hostname = meaningful_hostname(host.hostname, host.ip)
+ if hostname:
+ return "host:" + hostname.casefold()
+ return "ip:" + host.ip
+
+
+def meaningful_hostname(hostname: str, ip: str = "") -> str:
+ value = (hostname or "").strip().rstrip(".")
+ if not value:
+ return ""
+ if value.casefold() in {"localhost", "localhost.localdomain", "unknown"}:
+ return ""
+ try:
+ ipaddress.ip_address(value)
+ return ""
+ except ValueError:
+ pass
+ if ip and value == ip:
+ return ""
+ return value
+
+
+def os_family(value: str) -> str:
+ folded = (value or "").casefold()
+
+ # Android is Linux-based but must remain a distinct family for display and
+ # remembered-identification consistency.
+ if "android" in folded:
+ return "android"
+
+ # Apple platforms. Avoid a generic "ios" substring because Cisco IOS / IOS XE
+ # are unrelated operating systems.
+ if any(token in folded for token in (
+ "macos", "mac os x", "darwin", "iphone os", "apple ios",
+ "ipados", "apple tv", "tvos",
+ )):
+ return "apple"
+
+ families = (
+ ("openwrt", "linux"),
+ ("proxmox", "linux"),
+ ("synology", "linux"),
+ ("debian", "linux"),
+ ("ubuntu", "linux"),
+ ("fedora", "linux"),
+ ("centos", "linux"),
+ ("red hat", "linux"),
+ ("linux", "linux"),
+ ("opnsense", "freebsd"),
+ ("pfsense", "freebsd"),
+ ("freebsd", "freebsd"),
+ ("openbsd", "openbsd"),
+ ("netbsd", "netbsd"),
+ ("windows", "windows"),
+ ("routeros", "routeros"),
+ ("vmware", "vmware"),
+ ("esxi", "vmware"),
+ ("fortios", "fortios"),
+ ("junos", "junos"),
+ ("ios xe", "iosxe"),
+ ("cisco ios", "ios"),
+ )
+ for token, family in families:
+ if token in folded:
+ return family
+ return ""
+
+
+def open_port_keys(host: Host) -> set[tuple[int, str]]:
+ return {(p.port, p.protocol.lower()) for p in host.ports if p.state == "open"}
+
+
+def parse_port_keys(payload: str | Sequence[object] | None) -> set[tuple[int, str]]:
+ if not payload:
+ return set()
+ data: object = payload
+ if isinstance(payload, str):
+ try:
+ data = json.loads(payload)
+ except (json.JSONDecodeError, TypeError):
+ return set()
+ result: set[tuple[int, str]] = set()
+ if not isinstance(data, Sequence):
+ return result
+ for item in data:
+ if isinstance(item, Mapping):
+ try:
+ result.add((int(item.get("port", 0)), str(item.get("protocol", "tcp")).lower()))
+ except (TypeError, ValueError):
+ continue
+ elif isinstance(item, (list, tuple)) and item:
+ try:
+ result.add((int(item[0]), str(item[1] if len(item) > 1 else "tcp").lower()))
+ except (TypeError, ValueError):
+ continue
+ return {p for p in result if p[0] > 0}
+
+
+def port_fingerprint_json(host: Host) -> str:
+ payload = [
+ {"port": port, "protocol": proto}
+ for port, proto in sorted(open_port_keys(host), key=lambda value: (value[1], value[0]))
+ ]
+ return json.dumps(payload, separators=(",", ":"))
+
+
+def _port_match_points(current: set[tuple[int, str]], previous: set[tuple[int, str]]) -> tuple[int, str]:
+ if not current or not previous:
+ return 0, ""
+ overlap = current & previous
+ if not overlap:
+ # Deux signatures fournies et entièrement disjointes sont un signal
+ # négatif utile, notamment pour détecter un clone/réemploi de MAC.
+ if len(current) >= 2 and len(previous) >= 2:
+ return -25, "services incompatibles"
+ return 0, ""
+ coverage = len(overlap) / max(1, min(len(current), len(previous)))
+ if len(overlap) >= 2 and coverage >= 0.75:
+ return 25, "services concordants"
+ if len(overlap) >= 2:
+ return 15, "plusieurs services concordants"
+ return 7, "un service concordant"
+
+
+def _record_identity_base(record: Mapping[str, object]) -> str:
+ value = str(record.get("identity") or "")
+ return value.split("::", 1)[-1]
+
+
+def _record_age_days(record: Mapping[str, object]) -> float | None:
+ """Age de la *meilleure identification*, pas de la dernière observation réseau.
+
+ ``updated_at`` peut être rafraîchi par un scan Standard (nouvelle IP, hostname,
+ ports, etc.). Il ne doit donc jamais rajeunir artificiellement un fingerprint
+ OS ancien. Pour une ligne contenant un OS, ``os_seen_at`` est la référence.
+ Pour une identification uniquement typologique, on utilise ``type_seen_at``.
+ ``updated_at`` n'est qu'un fallback de migration pour les anciennes bases.
+ """
+ if str(record.get("os_name") or "").strip():
+ value = str(record.get("os_seen_at") or "").strip()
+ elif str(record.get("device_type") or "").strip():
+ value = str(record.get("type_seen_at") or "").strip()
+ else:
+ value = ""
+ if not value:
+ value = str(record.get("updated_at") or "").strip()
+ if not value:
+ return None
+ try:
+ then = datetime.fromisoformat(value)
+ now = datetime.now(timezone.utc).astimezone()
+ if then.tzinfo is None:
+ then = then.replace(tzinfo=now.tzinfo)
+ return max(0.0, (now - then.astimezone(now.tzinfo)).total_seconds() / 86400.0)
+ except ValueError:
+ return None
+
+
+def score_identity_match(host: Host, record: Mapping[str, object], *, shared_mac: bool = False) -> IdentityMatch:
+ """Score une corrélation sans jamais considérer l'IP seule comme identité.
+
+ Le moteur privilégie volontairement les faux négatifs aux faux positifs. Une
+ adresse IP, un hostname ou une signature de ports peuvent être réutilisés par
+ une autre machine ; ils servent donc uniquement de preuves complémentaires.
+ """
+ if host.is_local:
+ is_local_record = _record_identity_base(record) == "local:self"
+ return IdentityMatch(100 if is_local_record else 0, "poste local", "local", is_local_record)
+
+ current_mac = normalize_mac(host.mac)
+ previous_mac = normalize_mac(str(record.get("mac") or ""))
+ current_hostname = meaningful_hostname(host.hostname, host.ip).casefold()
+ previous_hostname = meaningful_hostname(str(record.get("hostname") or ""), str(record.get("ip") or "")).casefold()
+ same_ip = bool(host.ip and host.ip == str(record.get("ip") or ""))
+ same_hostname = bool(current_hostname and previous_hostname and current_hostname == previous_hostname)
+ hostname_conflict = bool(current_hostname and previous_hostname and current_hostname != previous_hostname)
+ record_was_shared = _record_identity_base(record).startswith("macip:")
+ effective_shared = shared_mac or record_was_shared
+
+ # Une MAC actuelle connue qui contredit la MAC mémorisée bloque formellement
+ # l'héritage. C'est le cas classique d'une IP DHCP réattribuée.
+ if current_mac and previous_mac and current_mac != previous_mac:
+ return IdentityMatch(0, "MAC différente : IP potentiellement réattribuée", "conflict", False)
+
+ current_family = os_family(host.os_name)
+ previous_family = os_family(str(record.get("os_name") or ""))
+ if current_family and previous_family and current_family != previous_family:
+ return IdentityMatch(0, "OS actuel incompatible avec l'identification mémorisée", "conflict", False)
+
+ current_ports = open_port_keys(host)
+ previous_ports = parse_port_keys(str(record.get("ports_json") or ""))
+ port_points, port_reason = _port_match_points(current_ports, previous_ports)
+
+ reasons: list[str] = []
+ kind = mac_identity_kind(current_mac)
+ score = 0
+
+ if current_mac and previous_mac == current_mac:
+ if effective_shared:
+ # Une MAC déjà vue sur plusieurs IP (proxy ARP, VIP, clone, certains
+ # bridges) reste scindée par IP même si, lors du scan courant, une seule
+ # de ces IP répond encore. Cela évite qu'un ancien endpoint "macip" soit
+ # soudain assimilé à tous les autres.
+ if not same_ip:
+ return IdentityMatch(0, "MAC historiquement partagée : IP différente", "shared", False)
+ score = 65
+ reasons.append("MAC partagée + même IP")
+ kind_for_result = "shared"
+ elif kind == "global":
+ score = 95
+ reasons.append("MAC globale identique")
+ kind_for_result = "global"
+ elif kind == "laa":
+ # Une LAA peut être stable, par SSID, par connexion ou aléatoire. Elle
+ # apporte un indice utile mais n'est jamais considérée suffisante seule.
+ score = 60
+ reasons.append("MAC locale (LAA) identique")
+ kind_for_result = "laa"
+ elif kind == "virtual":
+ # Une MAC VRRP/CARP/HSRP suit un service logique et peut changer de nœud.
+ if not same_ip:
+ return IdentityMatch(0, "MAC virtuelle sans continuité d'IP", "virtual", False)
+ score = 60
+ reasons.append("MAC virtuelle + même VIP")
+ kind_for_result = "virtual"
+ else:
+ return IdentityMatch(0, "MAC non exploitable comme identité", kind, False)
+
+ if same_ip and kind_for_result != "global":
+ score += 12
+ reasons.append("même IP")
+ if same_hostname:
+ score += 15 if kind_for_result != "global" else 3
+ reasons.append("même nom")
+ elif hostname_conflict:
+ # Un renommage est possible, donc ce n'est pas un rejet absolu. Mais un
+ # nom différent est un signal important lorsqu'une MAC a pu être clonée.
+ score -= 12
+ reasons.append("nom différent")
+ if port_points:
+ score += port_points
+ if port_reason:
+ reasons.append(port_reason)
+ if current_family and previous_family and current_family == previous_family:
+ score += 8
+ reasons.append("même famille OS")
+
+ # Une MAC globale est généralement un très bon identifiant L2, mais une VM
+ # clonée ou une MAC spoofée peut réapparaître longtemps après sur une autre
+ # IP. Au-delà de 180 jours, un déplacement d'IP exige donc un indice actuel
+ # supplémentaire (ports/hostname/OS) au lieu de faire confiance à la MAC seule.
+ age_days = _record_age_days(record)
+ if not same_ip and age_days is not None and age_days > STALE_MOVE_DAYS:
+ score -= 25
+ reasons.append(f"historique ancien ({int(age_days)} j)")
+
+ score = max(0, min(100, score))
+ safe = score >= AUTO_APPLY_THRESHOLD
+ return IdentityMatch(score, ", ".join(reasons), kind_for_result, safe)
+
+ # Sans MAC actuelle, l'identité physique/logique ne peut pas être vérifiée.
+ # Sur un réseau routé, DNS, IP et ports sont utiles pour afficher un *indice*,
+ # mais pas pour réinjecter automatiquement un ancien OS/type : DHCP, NAT,
+ # load-balancing et DNS obsolète rendraient ce comportement trop risqué.
+ if previous_mac:
+ return IdentityMatch(0, "MAC actuelle absente : identité non vérifiable", "no-current-mac", False)
+
+ if same_hostname:
+ score += 45
+ reasons.append("même nom")
+ if same_ip:
+ score += 10
+ reasons.append("même IP")
+ if port_points:
+ score += port_points
+ if port_reason:
+ reasons.append(port_reason)
+ if current_family and previous_family and current_family == previous_family:
+ score += 10
+ reasons.append("même famille OS")
+ score = max(0, min(100, score))
+ return IdentityMatch(score, ", ".join(reasons) or "IP seule insuffisante", "weak", False)
+
+def candidate_query_values(host: Host) -> tuple[str, str, str]:
+ """Valeurs utiles pour chercher des candidats sans décider de l'identité."""
+ return normalize_mac(host.mac), host.ip, meaningful_hostname(host.hostname, host.ip)
diff --git a/src/librenet_scanner/intelligence.py b/src/librenet_scanner/intelligence.py
new file mode 100644
index 0000000..9b7040a
--- /dev/null
+++ b/src/librenet_scanner/intelligence.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+import re
+
+from .models import Host
+
+
+CANONICAL_TCP_SERVICES: dict[int, str] = {
+ 20: "FTP-data",
+ 21: "FTP",
+ 22: "SSH",
+ 23: "Telnet",
+ 25: "SMTP",
+ 53: "DNS",
+ 80: "HTTP",
+ 110: "POP3",
+ 135: "MS-RPC",
+ 139: "NetBIOS",
+ 143: "IMAP",
+ 389: "LDAP",
+ 443: "HTTPS",
+ 445: "SMB",
+ 465: "SMTPS",
+ 515: "LPD",
+ 587: "SMTP submission",
+ 631: "IPP",
+ 636: "LDAPS",
+ 993: "IMAPS",
+ 995: "POP3S",
+ 1433: "MS SQL",
+ 1521: "Oracle",
+ 2049: "NFS",
+ 3306: "MySQL/MariaDB",
+ 3389: "RDP",
+ 5000: "Synology DSM",
+ 5001: "Synology DSM HTTPS",
+ 5432: "PostgreSQL",
+ 5900: "VNC",
+ 5985: "WinRM HTTP",
+ 5986: "WinRM HTTPS",
+ 8006: "Proxmox VE",
+ 8007: "Proxmox Backup Server",
+ 8080: "HTTP alternatif",
+ 8443: "HTTPS alternatif",
+ 9100: "JetDirect",
+}
+
+
+def canonical_service_name(port: int, protocol: str, nmap_name: str = "") -> str:
+ if protocol.lower() == "tcp" and port in CANONICAL_TCP_SERVICES:
+ return CANONICAL_TCP_SERVICES[port]
+ return nmap_name.strip()
+
+
+def _ports(host: Host) -> set[int]:
+ return {p.port for p in host.ports if p.state == "open" and p.protocol == "tcp"}
+
+
+def classify_host(host: Host) -> str:
+ """Classe un équipement avec des heuristiques explicables, sans prétendre à une détection certaine."""
+ name = host.hostname.casefold()
+ vendor = host.vendor.casefold()
+ os_name = host.os_name.casefold()
+ ports = _ports(host)
+
+ if host.is_local:
+ return "Ce poste"
+ if 8007 in ports or re.search(r"(^|[.-])pbs\d*([.-]|$)", name):
+ return "Proxmox Backup Server"
+ if 8006 in ports or "proxmox" in name or re.search(r"(^|[.-])pve\d*([.-]|$)", name):
+ return "Hyperviseur Proxmox"
+ if any(token in name for token in ("opnsense", "pfsense")) or re.search(r"(^|[.-])opns\d*([.-]|$)", name):
+ return "Pare-feu / routeur"
+ if "synology" in vendor or "synology" in name or name.startswith("syno") or ports.intersection({5000, 5001}):
+ return "NAS Synology"
+ if ports.intersection({9100, 515, 631}):
+ return "Imprimante"
+ if re.search(r"(^|[.-])sw\d", name) or "switch" in name:
+ return "Switch"
+ if re.search(r"(^|[.-])ap\d", name) or any(token in name for token in ("access-point", "accesspoint")):
+ return "Point d'accès Wi-Fi"
+ if 3389 in ports or 5985 in ports or 5986 in ports:
+ return "Poste / serveur Windows"
+ if "windows" in os_name:
+ return "Poste / serveur Windows"
+ if 445 in ports and 22 not in ports:
+ return "Poste / serveur Windows"
+ if "linux" in os_name:
+ return "Serveur Linux" if 22 in ports else "Hôte Linux"
+ if 22 in ports and ports.intersection({80, 443, 8080, 8443}):
+ return "Serveur / appliance"
+ if 22 in ports:
+ return "Serveur SSH"
+ if 53 in ports and ports.intersection({80, 443, 8443}):
+ return "Équipement réseau"
+ if ports and ports.issubset({80, 443, 8080, 8443}):
+ return "Appliance Web"
+ return "Hôte"
+
+
+def enrich_host(host: Host) -> Host:
+ for port in host.ports:
+ port.service = canonical_service_name(port.port, port.protocol, port.service)
+ host.device_type = classify_host(host)
+ return host
+
+
+DEVICE_ICON_NAMES: dict[str, str] = {
+ "Ce poste": "computer",
+ "Hyperviseur Proxmox": "computer-server",
+ "Proxmox Backup Server": "computer-server",
+ "Pare-feu / routeur": "network-connect",
+ "NAS Synology": "drive-harddisk",
+ "Imprimante": "printer",
+ "Switch": "network-wired",
+ "Point d'accès Wi-Fi": "network-wireless",
+ "Poste / serveur Windows": "computer",
+ "Serveur Linux": "computer-server",
+ "Hôte Linux": "computer",
+ "Serveur / appliance": "computer-server",
+ "Serveur SSH": "computer-server",
+ "Équipement réseau": "network-wired",
+ "Appliance Web": "applications-internet",
+ "Hôte": "computer",
+}
diff --git a/src/librenet_scanner/main.py b/src/librenet_scanner/main.py
index 0726744..212b97d 100644
--- a/src/librenet_scanner/main.py
+++ b/src/librenet_scanner/main.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import sys
+from pathlib import Path
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
@@ -8,14 +9,47 @@ from PySide6.QtWidgets import QApplication
from .ui import MainWindow
+APP_DESKTOP_ID = "librenet-scanner"
+APP_ICON_NAME = "librenet-scanner"
+
+
+def _load_app_icon() -> QIcon:
+ """Charge l'identité visuelle LibreNet, sans icône réseau générique."""
+ candidates = (
+ Path("/usr/share/icons/hicolor/scalable/apps/librenet-scanner.svg"),
+ Path(__file__).resolve().parents[2] / "assets" / "librenet-scanner.svg",
+ )
+ for path in candidates:
+ if path.is_file():
+ icon = QIcon(str(path))
+ if not icon.isNull():
+ return icon
+
+ # Dernier recours : demander explicitement notre propre nom d'icône au thème.
+ # On ne revient volontairement jamais à "network-wired".
+ return QIcon.fromTheme(APP_ICON_NAME)
+
+
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("LibreNet Scanner")
+ app.setApplicationDisplayName("LibreNet Scanner")
app.setOrganizationName("LibreNet")
- icon = QIcon.fromTheme("network-wired")
+
+ # Sous Plasma/Wayland, permet à KWin d'associer la fenêtre au .desktop
+ # et donc à la bonne icône dans la décoration et le gestionnaire de tâches.
+ if hasattr(app, "setDesktopFileName"):
+ app.setDesktopFileName(APP_DESKTOP_ID)
+
+ icon = _load_app_icon()
if not icon.isNull():
app.setWindowIcon(icon)
+
window = MainWindow()
+ if not icon.isNull():
+ # Explicite également l'icône sur la fenêtre principale pour les
+ # décorateurs X11/Wayland qui n'héritent pas toujours de QApplication.
+ window.setWindowIcon(icon)
window.show()
return app.exec()
diff --git a/src/librenet_scanner/models.py b/src/librenet_scanner/models.py
index 58d8c24..6ebd95f 100644
--- a/src/librenet_scanner/models.py
+++ b/src/librenet_scanner/models.py
@@ -15,8 +15,18 @@ class PortInfo:
@property
def label(self) -> str:
- service = self.service or "?"
- return f"{self.port}/{self.protocol} {service}" if service else f"{self.port}/{self.protocol}"
+ base = f"{self.port}/{self.protocol}"
+ if self.service:
+ base += f" ({self.service})"
+ return base
+
+ @property
+ def details(self) -> str:
+ parts = [self.label]
+ product = " ".join(part for part in (self.product, self.version) if part).strip()
+ if product:
+ parts.append(product)
+ return " — ".join(parts)
@dataclass(slots=True)
@@ -27,9 +37,29 @@ class Host:
vendor: str = ""
status: str = "up"
os_name: str = ""
+ os_accuracy: int | None = None
latency_ms: float | None = None
ports: list[PortInfo] = field(default_factory=list)
last_seen: str = field(default_factory=lambda: datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"))
+ device_type: str = "Hôte"
+ change_status: str = ""
+ change_detail: str = ""
+ previous_ip: str = ""
+ is_local: bool = False
+
+ # V0.4.9 : la meilleure identification connue est volontairement séparée
+ # des données du scan courant. Ainsi un scan Standard peut afficher un OS
+ # découvert précédemment en Approfondi sans prétendre l'avoir redétecté.
+ remembered_os_name: str = ""
+ remembered_os_accuracy: int | None = None
+ remembered_device_type: str = ""
+ remembered_os_source: str = ""
+ remembered_type_source: str = ""
+ remembered_os_seen_at: str = ""
+ remembered_type_seen_at: str = ""
+ remembered_match_score: int = 0
+ remembered_match_reason: str = ""
+ remembered_identity_kind: str = ""
def merge(self, other: "Host") -> "Host":
if other.hostname:
@@ -40,6 +70,8 @@ class Host:
self.vendor = other.vendor
if other.os_name:
self.os_name = other.os_name
+ if other.os_accuracy is not None:
+ self.os_accuracy = other.os_accuracy
if other.latency_ms is not None:
self.latency_ms = other.latency_ms
if other.ports:
@@ -47,16 +79,131 @@ class Host:
for port in other.ports:
known[(port.port, port.protocol)] = port
self.ports = sorted(known.values(), key=lambda p: (p.protocol, p.port))
+ if other.device_type and other.device_type != "Hôte":
+ self.device_type = other.device_type
+ self.is_local = self.is_local or other.is_local
self.status = other.status or self.status
self.last_seen = other.last_seen or self.last_seen
+
+ # Les informations mémorisées peuvent arriver avant ou après les données
+ # Nmap du scan courant. On les fusionne sans jamais écraser une valeur déjà
+ # plus complète portée par l'objet courant.
+ if other.remembered_os_name:
+ self.remembered_os_name = other.remembered_os_name
+ self.remembered_os_accuracy = other.remembered_os_accuracy
+ self.remembered_os_source = other.remembered_os_source
+ self.remembered_os_seen_at = other.remembered_os_seen_at
+ if other.remembered_device_type:
+ self.remembered_device_type = other.remembered_device_type
+ self.remembered_type_source = other.remembered_type_source
+ self.remembered_type_seen_at = other.remembered_type_seen_at
+ if other.remembered_match_score:
+ self.remembered_match_score = other.remembered_match_score
+ self.remembered_match_reason = other.remembered_match_reason
+ self.remembered_identity_kind = other.remembered_identity_kind
return self
+ @staticmethod
+ def _os_family(value: str) -> str:
+ folded = (value or "").casefold()
+ if "android" in folded:
+ return "android"
+ if any(token in folded for token in (
+ "macos", "mac os x", "darwin", "iphone os", "apple ios",
+ "ipados", "apple tv", "tvos",
+ )):
+ return "apple"
+ for token, family in (
+ ("openwrt", "linux"), ("proxmox", "linux"), ("synology", "linux"),
+ ("debian", "linux"), ("ubuntu", "linux"), ("fedora", "linux"),
+ ("centos", "linux"), ("red hat", "linux"), ("linux", "linux"),
+ ("opnsense", "freebsd"), ("pfsense", "freebsd"), ("freebsd", "freebsd"),
+ ("openbsd", "openbsd"), ("netbsd", "netbsd"), ("windows", "windows"),
+ ("routeros", "routeros"), ("vmware", "vmware"), ("esxi", "vmware"),
+ ("fortios", "fortios"), ("junos", "junos"), ("ios xe", "iosxe"),
+ ("cisco ios", "ios"),
+ ):
+ if token in folded:
+ return family
+ return ""
+
+ @property
+ def effective_os_name(self) -> str:
+ """OS affiché : le courant gagne en cas de contradiction, sinon le plus précis."""
+ current = (self.os_name or "").strip()
+ remembered = (self.remembered_os_name or "").strip()
+ if not current:
+ return remembered
+ if not remembered:
+ return current
+ current_family = self._os_family(current)
+ remembered_family = self._os_family(remembered)
+ if current_family and remembered_family and current_family != remembered_family:
+ return current
+ # Un scan Standard peut seulement redonner "Linux" alors qu'un ancien
+ # Approfondi avait identifié OpenWrt/Debian/etc. Dans la même famille, on
+ # garde la description la plus informative.
+ if remembered_family and current_family == remembered_family and len(remembered) > len(current):
+ return remembered
+ return current
+
+ @property
+ def effective_device_type(self) -> str:
+ """Type à afficher sans laisser un scan léger dégrader une identification riche."""
+ if self.is_local:
+ return "Ce poste"
+ if self.remembered_device_type and self.device_type in {
+ "", "Hôte", "Hôte Linux", "Serveur SSH", "Serveur / appliance", "Équipement réseau", "Appliance Web"
+ }:
+ return self.remembered_device_type
+ return self.device_type or self.remembered_device_type or "Hôte"
+
+ @property
+ def os_is_estimated(self) -> bool:
+ """True quand Nmap a fourni une correspondance OS non exacte (< 100 %)."""
+ return bool(self.os_name and self.os_accuracy is not None and self.os_accuracy < 100)
+
+ @property
+ def effective_os_accuracy(self) -> int | None:
+ if self.os_is_remembered:
+ return self.remembered_os_accuracy
+ return self.os_accuracy
+
+ @property
+ def os_is_remembered(self) -> bool:
+ return bool(self.remembered_os_name and self.effective_os_name == self.remembered_os_name and self.os_name != self.remembered_os_name)
+
+ @property
+ def type_is_remembered(self) -> bool:
+ return bool(
+ self.remembered_device_type
+ and self.effective_device_type == self.remembered_device_type
+ and self.device_type != self.remembered_device_type
+ )
+
@property
def ports_summary(self) -> str:
- if not self.ports:
- return ""
- return ", ".join(
- f"{p.port}/{p.protocol}" + (f" ({p.service})" if p.service else "")
- for p in self.ports
- if p.state == "open"
+ return ", ".join(p.label for p in self.ports if p.state == "open")
+
+ @property
+ def ports_details(self) -> str:
+ return "\n".join(p.details for p in self.ports if p.state == "open")
+
+ @property
+ def searchable_text(self) -> str:
+ values = (
+ self.status,
+ self.change_status,
+ self.change_detail,
+ self.device_type,
+ self.effective_device_type,
+ self.hostname,
+ self.ip,
+ self.previous_ip,
+ self.mac,
+ self.vendor,
+ self.ports_summary,
+ self.os_name,
+ self.effective_os_name,
)
+ return " ".join(v for v in values if v).casefold()
diff --git a/src/librenet_scanner/naabu_runtime.py b/src/librenet_scanner/naabu_runtime.py
new file mode 100644
index 0000000..886840f
--- /dev/null
+++ b/src/librenet_scanner/naabu_runtime.py
@@ -0,0 +1,236 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import os
+import platform
+import shutil
+import stat
+import subprocess
+import sys
+import tempfile
+import urllib.request
+import zipfile
+from pathlib import Path
+
+NAABU_VERSION = "2.6.1"
+NAABU_RELEASE_TAG = f"v{NAABU_VERSION}"
+BUNDLED_NAABU_PATH = "/usr/lib/librenet-scanner/bin/naabu"
+NAABU_AMD64_URL = (
+ "https://github.com/projectdiscovery/naabu/releases/download/"
+ f"{NAABU_RELEASE_TAG}/naabu_{NAABU_VERSION}_linux_amd64.zip"
+)
+# Empreinte publiée par ProjectDiscovery sur la release GitHub v2.6.1.
+NAABU_AMD64_SHA256 = "018c4c9884dea971eda860435ede3021d1150732f34cfd245498c6726d8cab90"
+
+
+class NaabuProvisionError(RuntimeError):
+ pass
+
+
+def _machine_is_amd64() -> bool:
+ return platform.machine().lower() in {"x86_64", "amd64"}
+
+
+def _download(url: str, *, timeout: float = 60.0) -> bytes:
+ request = urllib.request.Request(
+ url,
+ headers={"User-Agent": "LibreNet-Scanner-Naabu-Provisioner/1.0.0"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ chunks: list[bytes] = []
+ total = 0
+ limit = 100 * 1024 * 1024
+ while True:
+ chunk = response.read(1024 * 1024)
+ if not chunk:
+ break
+ total += len(chunk)
+ if total > limit:
+ raise NaabuProvisionError("Archive Naabu anormalement volumineuse (> 100 Mio)")
+ chunks.append(chunk)
+ return b"".join(chunks)
+ except Exception as exc: # urllib regroupe plusieurs classes d'erreurs réseau
+ raise NaabuProvisionError(f"Téléchargement de Naabu impossible : {exc}") from exc
+
+
+def _verify_archive(data: bytes, expected_sha256: str) -> None:
+ digest = hashlib.sha256(data).hexdigest()
+ if digest.lower() != expected_sha256.lower():
+ raise NaabuProvisionError(
+ "Empreinte SHA-256 Naabu invalide : "
+ f"attendue {expected_sha256}, reçue {digest}"
+ )
+
+
+def _extract_binary(data: bytes) -> bytes:
+ try:
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
+ matches = [name for name in archive.namelist() if name.rstrip("/").split("/")[-1] == "naabu"]
+ if len(matches) != 1:
+ raise NaabuProvisionError(
+ f"Archive Naabu inattendue : {len(matches)} exécutable(s) 'naabu' trouvé(s)"
+ )
+ return archive.read(matches[0])
+ except zipfile.BadZipFile as exc:
+ raise NaabuProvisionError("Archive Naabu ZIP invalide") from exc
+
+
+def naabu_version(path: str) -> str | None:
+ try:
+ result = subprocess.run(
+ [path, "-version", "-disable-update-check", "-config", "/dev/null", "-auth=false"],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=8,
+ check=False,
+ env={**os.environ, "HOME": "/nonexistent", "NO_COLOR": "1"},
+ )
+ except (OSError, subprocess.SubprocessError):
+ return None
+ text = (result.stdout or "").strip()
+ if result.returncode != 0 or NAABU_VERSION not in text:
+ return None
+ return NAABU_VERSION
+
+
+def trusted_root_binary(path: str) -> bool:
+ try:
+ info = Path(path).stat()
+ except OSError:
+ return False
+ return (
+ stat.S_ISREG(info.st_mode)
+ and info.st_uid == 0
+ and not (info.st_mode & (stat.S_IWGRP | stat.S_IWOTH))
+ and os.access(path, os.X_OK)
+ )
+
+
+
+def _copy_existing_system_naabu(destination: str) -> str | None:
+ """Réutilise un Naabu système root déjà présent avant tout téléchargement."""
+ for candidate in ("/usr/local/bin/naabu", "/usr/bin/naabu"):
+ if candidate == destination:
+ continue
+ if not trusted_root_binary(candidate) or naabu_version(candidate) != NAABU_VERSION:
+ continue
+ destination_path = Path(destination)
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_name = tempfile.mkstemp(prefix=".naabu-", dir=str(destination_path.parent))
+ try:
+ with open(candidate, "rb") as source, os.fdopen(fd, "wb") as target:
+ shutil.copyfileobj(source, target)
+ target.flush()
+ os.fsync(target.fileno())
+ os.chmod(tmp_name, 0o755)
+ os.chown(tmp_name, 0, 0)
+ os.replace(tmp_name, destination)
+ tmp_name = ""
+ return destination
+ finally:
+ if tmp_name:
+ try:
+ os.unlink(tmp_name)
+ except OSError:
+ pass
+ return None
+
+def install_naabu(
+ *,
+ destination: str = BUNDLED_NAABU_PATH,
+ url: str = NAABU_AMD64_URL,
+ expected_sha256: str = NAABU_AMD64_SHA256,
+ download_func=_download,
+ require_root: bool = True,
+) -> str:
+ """Installe atomiquement le binaire Naabu vérifié utilisé par LibreNet.
+
+ La fonction est volontairement paramétrable pour permettre des tests hors ligne.
+ En production, seuls l'URL et le SHA-256 figés ci-dessus sont utilisés.
+ """
+ if require_root and os.geteuid() != 0:
+ raise NaabuProvisionError("L'installation du moteur Naabu doit être exécutée en root")
+ if not _machine_is_amd64():
+ raise NaabuProvisionError(
+ f"Architecture non prise en charge par ce paquet : {platform.machine()} (amd64 requis)"
+ )
+
+ destination_path = Path(destination)
+ if destination_path.is_file() and os.access(destination, os.X_OK):
+ if naabu_version(destination) == NAABU_VERSION:
+ if require_root:
+ os.chown(destination, 0, 0)
+ os.chmod(destination, 0o755)
+ return destination
+
+ if require_root:
+ reused = _copy_existing_system_naabu(destination)
+ if reused:
+ return reused
+
+ data = download_func(url)
+ _verify_archive(data, expected_sha256)
+ binary = _extract_binary(data)
+ if not binary.startswith(b"\x7fELF"):
+ raise NaabuProvisionError("Le fichier Naabu extrait n'est pas un exécutable ELF")
+
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_name = tempfile.mkstemp(prefix=".naabu-", dir=str(destination_path.parent))
+ try:
+ with os.fdopen(fd, "wb") as handle:
+ handle.write(binary)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.chmod(tmp_name, 0o755)
+ if require_root:
+ os.chown(tmp_name, 0, 0)
+ os.replace(tmp_name, destination)
+ tmp_name = ""
+ if naabu_version(destination) != NAABU_VERSION:
+ try:
+ destination_path.unlink()
+ except OSError:
+ pass
+ raise NaabuProvisionError(
+ f"Le moteur Naabu installé ne s'identifie pas comme version {NAABU_VERSION}"
+ )
+ return destination
+ finally:
+ if tmp_name:
+ try:
+ os.unlink(tmp_name)
+ except OSError:
+ pass
+
+
+def ensure_naabu() -> int:
+ try:
+ path = install_naabu()
+ except NaabuProvisionError as exc:
+ print(f"LibreNet Scanner : ERREUR moteur Naabu : {exc}", file=sys.stderr)
+ print(
+ "L'accélérateur Naabu optionnel n'a pas pu être installé. "
+ "LibreNet Scanner reste utilisable avec son moteur Nmap adaptatif.",
+ file=sys.stderr,
+ )
+ return 1
+ print(f"LibreNet Scanner : moteur Naabu {NAABU_VERSION} prêt dans {path}")
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = list(sys.argv[1:] if argv is None else argv)
+ if args not in ([], ["--ensure"]):
+ print("Usage : librenet-scanner-install-naabu [--ensure]", file=sys.stderr)
+ return 64
+ return ensure_naabu()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/librenet_scanner/network.py b/src/librenet_scanner/network.py
index 7eea8e0..dc4eafe 100644
--- a/src/librenet_scanner/network.py
+++ b/src/librenet_scanner/network.py
@@ -2,14 +2,20 @@ from __future__ import annotations
import ipaddress
import json
+import re
import subprocess
from dataclasses import dataclass
+from pathlib import Path
VIRTUAL_PREFIXES = (
"lo", "docker", "br-", "veth", "virbr", "podman", "cni", "flannel", "tun", "tap",
)
+_MAC_RE = re.compile(r"^(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$")
+_FULL_RANGE_RE = re.compile(r"^\s*(\d{1,3}(?:\.\d{1,3}){3})\s*-\s*(\d{1,3}(?:\.\d{1,3}){3})\s*$")
+_NMAP_RANGE_RE = re.compile(r"^(\d{1,3}\.\d{1,3}\.\d{1,3})\.(\d{1,3})-(\d{1,3})$")
+
@dataclass(slots=True, frozen=True)
class NetworkInterface:
@@ -18,6 +24,7 @@ class NetworkInterface:
prefixlen: int
network: str
is_virtual: bool = False
+ mac: str = ""
@property
def label(self) -> str:
@@ -25,14 +32,173 @@ class NetworkInterface:
return f"{self.name} — {self.address}/{self.prefixlen} — {self.network}{suffix}"
+@dataclass(slots=True, frozen=True)
+class NeighborEntry:
+ ip: str
+ mac: str
+ state: str = ""
+
+
+def normalize_interface_mac(value: str) -> str:
+ """Normalise une adresse MAC d'interface et rejette les valeurs non exploitables."""
+ value = (value or "").strip().upper()
+ if not _MAC_RE.match(value):
+ return ""
+ if value == "00:00:00:00:00:00":
+ return ""
+ return value
+
+
+def interface_mac_address(interface_name: str) -> str:
+ """Retourne la MAC locale d'une interface sans passer par ARP/Nmap.
+
+ Sous Linux, sysfs est la source la plus directe et évite le cas classique où
+ la machine locale n'apparaît pas dans ``ip neigh``. ``ip -j link`` sert de
+ repli pour les environnements où sysfs n'est pas lisible.
+ """
+ try:
+ value = Path("/sys/class/net").joinpath(interface_name, "address").read_text(encoding="utf-8")
+ mac = normalize_interface_mac(value)
+ if mac:
+ return mac
+ except (OSError, UnicodeError):
+ pass
+
+ try:
+ proc = subprocess.run(
+ ["ip", "-j", "link", "show", "dev", interface_name],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ if proc.returncode == 0:
+ payload = json.loads(proc.stdout or "[]")
+ if payload:
+ return normalize_interface_mac(str(payload[0].get("address", "")))
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError, TypeError):
+ pass
+ return ""
+
+
+def target_contains_ip(target: str, address: str) -> bool:
+ """Indique si une cible validée contient une adresse IPv4 donnée."""
+ try:
+ ip = ipaddress.ip_address(address)
+ if ip.version != 4:
+ return False
+ endpoints = _range_endpoints(target)
+ if endpoints:
+ return int(endpoints[0]) <= int(ip) <= int(endpoints[1])
+ if "/" in target:
+ return ip in ipaddress.ip_network(target, strict=False)
+ return ip == ipaddress.ip_address(target)
+ except ValueError:
+ return False
+
+
+def _range_endpoints(value: str) -> tuple[ipaddress.IPv4Address, ipaddress.IPv4Address] | None:
+ full = _FULL_RANGE_RE.match(value)
+ if full:
+ start = ipaddress.ip_address(full.group(1))
+ end = ipaddress.ip_address(full.group(2))
+ if start.version != 4 or end.version != 4:
+ raise ValueError("IPv4 uniquement")
+ return start, end
+ compact = _NMAP_RANGE_RE.match(value.strip())
+ if compact:
+ start = ipaddress.ip_address(f"{compact.group(1)}.{compact.group(2)}")
+ end = ipaddress.ip_address(f"{compact.group(1)}.{compact.group(3)}")
+ return start, end
+ return None
+
+
def validate_target(value: str) -> str:
+ """Valide une cible IPv4 et accepte aussi la plage A.B.C.1 - A.B.C.254.
+
+ Les plages sont volontairement limitées à un même /24 en V0.3 afin de produire
+ une syntaxe Nmap sûre et lisible (A.B.C.1-254).
+ """
value = value.strip()
try:
+ endpoints = _range_endpoints(value)
+ if endpoints:
+ start, end = endpoints
+ if int(start) > int(end):
+ raise ValueError("Le début de la plage doit précéder la fin")
+ if start.packed[:3] != end.packed[:3]:
+ raise ValueError("Les plages V0.3 doivent rester dans le même /24")
+ count = int(end) - int(start) + 1
+ if count > 4096:
+ raise ValueError("4096 adresses maximum")
+ prefix = ".".join(str(start).split(".")[:3])
+ return f"{prefix}.{int(str(start).split('.')[-1])}-{int(str(end).split('.')[-1])}"
if "/" in value:
- return str(ipaddress.ip_network(value, strict=False))
- return str(ipaddress.ip_address(value))
+ network = ipaddress.ip_network(value, strict=False)
+ if network.version != 4:
+ raise ValueError("IPv4 uniquement")
+ return str(network)
+ address = ipaddress.ip_address(value)
+ if address.version != 4:
+ raise ValueError("IPv4 uniquement")
+ return str(address)
except ValueError as exc:
- raise ValueError(f"Cible IPv4 invalide : {value}") from exc
+ raise ValueError(f"Cible IPv4 invalide : {value} ({exc})") from exc
+
+
+def scan_identity_scope(target: str, interface: NetworkInterface | None = None) -> str:
+ """Retourne un domaine de corrélation stable pour l'historique d'identité.
+
+ Une même MAC peut exister dans deux VLANs/réseaux distincts (clone de VM,
+ équipement virtuel, lab). On évite donc une corrélation globale par MAC. Pour
+ un réseau directement connecté, le préfixe de l'interface sert de domaine ;
+ sinon le CIDR demandé — ou le /24 contenant une plage — est utilisé.
+ """
+ value = validate_target(target)
+ if interface is not None and target_is_on_interface(value, interface):
+ return f"ipv4:{ipaddress.ip_network(interface.network, strict=False)}"
+ endpoints = _range_endpoints(value)
+ if endpoints:
+ network = ipaddress.ip_network(f"{endpoints[0]}/24", strict=False)
+ return f"ipv4:{network}"
+ if "/" in value:
+ return f"ipv4:{ipaddress.ip_network(value, strict=False)}"
+ address = ipaddress.ip_address(value)
+ return f"ipv4:{address}/32"
+
+
+def target_address_count(target: str) -> int:
+ endpoints = _range_endpoints(target)
+ if endpoints:
+ return int(endpoints[1]) - int(endpoints[0]) + 1
+ if "/" in target:
+ return int(ipaddress.ip_network(target, strict=False).num_addresses)
+ return 1
+
+
+def target_ipv4_hosts(target: str) -> list[str]:
+ """Déplie une cible LibreNet en adresses IPv4 hôtes pour les moteurs sans syntaxe Nmap.
+
+ LibreNet limite déjà les cibles à 4096 adresses. Pour un CIDR classique, les
+ adresses réseau/broadcast sont ignorées comme hôtes ; /31 et /32 conservent le
+ comportement de :meth:`ipaddress.IPv4Network.hosts`.
+ """
+ value = validate_target(target)
+ endpoints = _range_endpoints(value)
+ if endpoints:
+ start, end = endpoints
+ return [str(ipaddress.ip_address(raw)) for raw in range(int(start), int(end) + 1)]
+ if "/" in value:
+ network = ipaddress.ip_network(value, strict=False)
+ return [str(address) for address in network.hosts()]
+ return [str(ipaddress.ip_address(value))]
+
+
+def display_target(target: str) -> str:
+ endpoints = _range_endpoints(target)
+ if endpoints:
+ return f"{endpoints[0]} - {endpoints[1]}"
+ return target
def list_ipv4_interfaces() -> list[NetworkInterface]:
@@ -49,11 +215,13 @@ def list_ipv4_interfaces() -> list[NetworkInterface]:
return []
result: list[NetworkInterface] = []
+ mac_cache: dict[str, str] = {}
for item in payload:
name = item.get("ifname", "")
if not name or name == "lo":
continue
virtual = name.startswith(VIRTUAL_PREFIXES)
+ mac_cache.setdefault(name, interface_mac_address(name))
for addr in item.get("addr_info", []):
if addr.get("family") != "inet" or addr.get("scope") != "global":
continue
@@ -62,16 +230,63 @@ def list_ipv4_interfaces() -> list[NetworkInterface]:
if not local:
continue
network = str(ipaddress.ip_network(f"{local}/{prefixlen}", strict=False))
- result.append(NetworkInterface(name, local, prefixlen, network, virtual))
+ result.append(NetworkInterface(name, local, prefixlen, network, virtual, mac_cache[name]))
result.sort(key=lambda i: (i.is_virtual, i.name, i.address))
return result
+def parse_neighbor_json(text: str) -> list[NeighborEntry]:
+ try:
+ payload = json.loads(text or "[]")
+ except json.JSONDecodeError:
+ return []
+
+ result: list[NeighborEntry] = []
+ for item in payload:
+ dst = str(item.get("dst", "")).strip()
+ mac = str(item.get("lladdr", "")).strip().upper()
+ state_value = item.get("state", "")
+ if isinstance(state_value, list):
+ state = ",".join(str(v) for v in state_value)
+ else:
+ state = str(state_value)
+ if not dst or not mac or not _MAC_RE.match(mac):
+ continue
+ if "FAILED" in state.upper() or "INCOMPLETE" in state.upper():
+ continue
+ try:
+ if ipaddress.ip_address(dst).version != 4:
+ continue
+ except ValueError:
+ continue
+ result.append(NeighborEntry(dst, mac, state))
+ return result
+
+
+def list_ipv4_neighbors(interface_name: str) -> list[NeighborEntry]:
+ try:
+ proc = subprocess.run(
+ ["ip", "-j", "neigh", "show", "dev", interface_name],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return []
+ if proc.returncode != 0:
+ return []
+ return parse_neighbor_json(proc.stdout)
+
+
def target_is_on_interface(target: str, interface: NetworkInterface | None) -> bool:
if interface is None:
return False
try:
iface_net = ipaddress.ip_network(interface.network, strict=False)
+ endpoints = _range_endpoints(target)
+ if endpoints:
+ return endpoints[0] in iface_net and endpoints[1] in iface_net
target_net = ipaddress.ip_network(target, strict=False) if "/" in target else ipaddress.ip_network(f"{target}/32")
return target_net.subnet_of(iface_net)
except ValueError:
diff --git a/src/librenet_scanner/online_vendor.py b/src/librenet_scanner/online_vendor.py
new file mode 100644
index 0000000..337924a
--- /dev/null
+++ b/src/librenet_scanner/online_vendor.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import json
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from datetime import datetime, timezone
+
+
+PROVIDER_MACLOOKUP = "maclookup.app"
+PROVIDER_MACVENDORS = "macvendors.com"
+PROVIDERS = (PROVIDER_MACLOOKUP, PROVIDER_MACVENDORS)
+
+
+class OnlineVendorError(RuntimeError):
+ pass
+
+
+@dataclass(slots=True)
+class OnlineVendorResult:
+ mac: str
+ provider: str
+ vendor: str = ""
+ found: bool = False
+ block_type: str = ""
+ is_randomized: bool = False
+ is_private: bool = False
+ checked_at: str = ""
+ from_cache: bool = False
+
+ def __post_init__(self) -> None:
+ if not self.checked_at:
+ self.checked_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+
+
+def normalize_mac(mac: str) -> str:
+ hexchars = "".join(ch for ch in mac.upper() if ch in "0123456789ABCDEF")
+ if len(hexchars) != 12:
+ return ""
+ return ":".join(hexchars[i : i + 2] for i in range(0, 12, 2))
+
+
+def is_locally_administered(mac: str) -> bool:
+ normalized = normalize_mac(mac)
+ if not normalized:
+ return False
+ return bool(int(normalized[:2], 16) & 0x02)
+
+
+def _request(url: str, *, timeout: float = 6.0) -> bytes:
+ req = urllib.request.Request(
+ url,
+ headers={
+ "User-Agent": "LibreNet-Scanner/1.0.0",
+ "Accept": "application/json,text/plain;q=0.9,*/*;q=0.1",
+ },
+ method="GET",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as response:
+ return response.read()
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ return b""
+ if exc.code == 429:
+ raise OnlineVendorError("Limite de requêtes atteinte chez le fournisseur en ligne.") from exc
+ raise OnlineVendorError(f"Service en ligne indisponible (HTTP {exc.code}).") from exc
+ except urllib.error.URLError as exc:
+ reason = getattr(exc, "reason", exc)
+ raise OnlineVendorError(f"Impossible de joindre le service en ligne : {reason}") from exc
+ except OSError as exc:
+ raise OnlineVendorError(f"Erreur réseau : {exc}") from exc
+
+
+def lookup_online_vendor(mac: str, provider: str = PROVIDER_MACLOOKUP, *, timeout: float = 6.0) -> OnlineVendorResult:
+ normalized = normalize_mac(mac)
+ if not normalized:
+ raise OnlineVendorError("Adresse MAC invalide.")
+ if provider not in PROVIDERS:
+ raise OnlineVendorError(f"Fournisseur inconnu : {provider}")
+
+ encoded = urllib.parse.quote(normalized, safe="")
+ if provider == PROVIDER_MACLOOKUP:
+ body = _request(f"https://api.maclookup.app/v2/macs/{encoded}", timeout=timeout)
+ if not body:
+ return OnlineVendorResult(mac=normalized, provider=provider)
+ try:
+ payload = json.loads(body.decode("utf-8", errors="replace"))
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+ raise OnlineVendorError("Réponse invalide de MACLookup.app.") from exc
+ if not payload.get("success", True):
+ raise OnlineVendorError(str(payload.get("error") or "Erreur MACLookup.app"))
+ found = bool(payload.get("found"))
+ vendor = str(payload.get("company") or "").strip() if found else ""
+ return OnlineVendorResult(
+ mac=normalized,
+ provider=provider,
+ vendor=vendor,
+ found=bool(found and vendor),
+ block_type=str(payload.get("blockType") or ""),
+ is_randomized=bool(payload.get("isRand")),
+ is_private=bool(payload.get("isPrivate")),
+ )
+
+ body = _request(f"https://api.macvendors.com/{encoded}", timeout=timeout)
+ vendor = body.decode("utf-8", errors="replace").strip() if body else ""
+ return OnlineVendorResult(
+ mac=normalized,
+ provider=provider,
+ vendor=vendor,
+ found=bool(vendor),
+ is_randomized=is_locally_administered(normalized),
+ )
+
+
+def provider_label(provider: str) -> str:
+ if provider == PROVIDER_MACVENDORS:
+ return "MACVendors.com"
+ return "MACLookup.app"
+
+
+def provider_min_interval(provider: str) -> float:
+ # MACVendors limite l'offre gratuite à 1 requête/s ; MACLookup autorise davantage,
+ # mais une petite temporisation évite de marteler inutilement le service.
+ return 1.05 if provider == PROVIDER_MACVENDORS else 0.12
diff --git a/src/librenet_scanner/parsers.py b/src/librenet_scanner/parsers.py
index 336ec08..5832906 100644
--- a/src/librenet_scanner/parsers.py
+++ b/src/librenet_scanner/parsers.py
@@ -4,6 +4,7 @@ import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
+from .intelligence import canonical_service_name, enrich_host
from .models import Host, PortInfo
@@ -21,7 +22,9 @@ def parse_arp_scan(text: str) -> list[Host]:
if not match:
continue
vendor = (match.group("vendor") or "").strip()
- if vendor == "(Unknown)":
+ if vendor.casefold().startswith("(unknown"):
+ # arp-scan peut renvoyer notamment "(Unknown: locally administered)"
+ # et des suffixes DUP. Ce n'est pas un constructeur exploitable.
vendor = ""
hosts.append(
Host(
@@ -80,11 +83,23 @@ def parse_nmap_xml(text: str) -> list[Host]:
hostname = candidate.get("name", "")
os_name = ""
+ os_accuracy: int | None = None
os_node = node.find("os")
if os_node is not None:
- match = os_node.find("osmatch")
- if match is not None:
+ # Avec --osscan-guess, Nmap peut renvoyer plusieurs osmatch. Ne
+ # supposons pas que l'ordre XML restera toujours le meilleur : on
+ # retient explicitement le match ayant la précision la plus élevée.
+ matches: list[tuple[int, ET.Element]] = []
+ for candidate in os_node.findall("osmatch"):
+ try:
+ accuracy = int(candidate.get("accuracy", "0"))
+ except (TypeError, ValueError):
+ accuracy = 0
+ matches.append((accuracy, candidate))
+ if matches:
+ accuracy, match = max(matches, key=lambda item: item[0])
os_name = match.get("name", "")
+ os_accuracy = accuracy if match.get("accuracy") is not None else None
ports: list[PortInfo] = []
ports_node = node.find("ports")
@@ -100,23 +115,30 @@ def parse_nmap_xml(text: str) -> list[Host]:
port=int(pnode.get("portid", "0")),
protocol=pnode.get("protocol", "tcp"),
state=state,
- service=service_node.get("name", "") if service_node is not None else "",
+ service=canonical_service_name(
+ int(pnode.get("portid", "0")),
+ pnode.get("protocol", "tcp"),
+ service_node.get("name", "") if service_node is not None else "",
+ ),
product=service_node.get("product", "") if service_node is not None else "",
version=service_node.get("version", "") if service_node is not None else "",
)
)
hosts.append(
- Host(
- ip=ip,
- hostname=hostname,
- mac=mac.upper(),
- vendor=vendor,
- status=status,
- os_name=os_name,
- latency_ms=_latency(node),
- ports=ports,
- last_seen=now,
+ enrich_host(
+ Host(
+ ip=ip,
+ hostname=hostname,
+ mac=mac.upper(),
+ vendor=vendor,
+ status=status,
+ os_name=os_name,
+ os_accuracy=os_accuracy,
+ latency_ms=_latency(node),
+ ports=ports,
+ last_seen=now,
+ )
)
)
return hosts
diff --git a/src/librenet_scanner/privileged_helper.py b/src/librenet_scanner/privileged_helper.py
new file mode 100644
index 0000000..ccaecaf
--- /dev/null
+++ b/src/librenet_scanner/privileged_helper.py
@@ -0,0 +1,260 @@
+from __future__ import annotations
+
+import ipaddress
+import os
+import re
+import signal
+import stat
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+
+COMMON_PORTS = (
+ "21,22,23,25,53,80,110,135,139,143,389,443,445,465,515,587,631,636,993,995,"
+ "1433,1521,2049,3306,3389,5000,5001,5432,5900,5985,5986,8006,8007,8080,8443,9100"
+)
+_INTERFACE_RE = re.compile(r"^[A-Za-z0-9_.:@-]{1,32}$")
+_RANGE_RE = re.compile(r"^(\d{1,3}\.\d{1,3}\.\d{1,3})\.(\d{1,3})-(\d{1,3})$")
+
+
+def _trusted_binary(*candidates: str) -> str:
+ for candidate in candidates:
+ path = Path(candidate)
+ if path.is_file() and os.access(path, os.X_OK):
+ info = path.stat()
+ if info.st_uid == 0 and not (info.st_mode & (stat.S_IWGRP | stat.S_IWOTH)):
+ return str(path)
+ raise RuntimeError(f"Binaire requis introuvable : {candidates[0]}")
+
+
+def validate_interface(value: str) -> str:
+ if not _INTERFACE_RE.fullmatch(value):
+ raise ValueError("Nom d'interface invalide")
+ if not Path("/sys/class/net", value).exists():
+ raise ValueError("Interface réseau inexistante")
+ return value
+
+
+def validate_target(value: str, *, network_allowed: bool = True) -> str:
+ value = value.strip()
+ if not value or value.startswith("-"):
+ raise ValueError("Cible invalide")
+ range_match = _RANGE_RE.fullmatch(value)
+ if range_match:
+ if not network_allowed:
+ raise ValueError("Une adresse hôte est requise")
+ start = int(range_match.group(2))
+ end = int(range_match.group(3))
+ if not (0 <= start <= end <= 255):
+ raise ValueError("Plage IPv4 invalide")
+ # valide aussi les trois premiers octets
+ ipaddress.ip_address(f"{range_match.group(1)}.{start}")
+ return value
+ try:
+ if "/" in value:
+ if not network_allowed:
+ raise ValueError("Une adresse hôte est requise")
+ network = ipaddress.ip_network(value, strict=False)
+ if network.version != 4:
+ raise ValueError("IPv4 uniquement")
+ if network.num_addresses > 4096:
+ raise ValueError("Réseau trop grand : 4096 adresses maximum")
+ return str(network)
+ address = ipaddress.ip_address(value)
+ if address.version != 4:
+ raise ValueError("IPv4 uniquement")
+ return str(address)
+ except ValueError as exc:
+ raise ValueError(str(exc)) from exc
+
+
+def validate_host_list(values: list[str]) -> list[str]:
+ if not values or len(values) > 4096:
+ raise ValueError("Liste d'hôtes invalide")
+ return [validate_target(value, network_allowed=False) for value in values]
+
+
+def command_for(operation: str, args: list[str]) -> list[str]:
+ nmap = lambda: _trusted_binary("/usr/bin/nmap", "/usr/local/bin/nmap")
+ arp_scan = lambda: _trusted_binary("/usr/sbin/arp-scan", "/usr/bin/arp-scan")
+ naabu = lambda: _trusted_binary("/usr/lib/librenet-scanner/bin/naabu")
+
+ if operation == "authorize":
+ if args:
+ raise ValueError("Aucun argument attendu")
+ return []
+
+ if operation == "arp-scan":
+ if len(args) != 2:
+ raise ValueError("Usage : arp-scan ")
+ iface = validate_interface(args[0])
+ target = validate_target(args[1])
+ if "-" in target and "/" not in target:
+ raise ValueError("arp-scan privilégié n'accepte pas les plages compactes ; utilise la découverte Nmap")
+ return [arp_scan(), "--interface", iface, target]
+
+ if operation == "nmap-discover":
+ if len(args) != 1:
+ raise ValueError("Usage : nmap-discover ")
+ target = validate_target(args[0])
+ return [nmap(), "-sn", "-n", "-T4", "--max-retries", "1", "-oX", "-", target]
+
+ if operation == "nmap-standard":
+ hosts = validate_host_list(args)
+ return [
+ nmap(), "-Pn", "-n", "-sS", "--open", "-T4",
+ "--max-retries", "1", "--host-timeout", "12s", "-p", COMMON_PORTS,
+ "-oX", "-", *hosts,
+ ]
+
+ if operation == "naabu-discover":
+ hosts = validate_host_list(args)
+ return [
+ naabu(), "-host", ",".join(hosts),
+ "-sn", "-pe", "-ps", "22,80,443,445,3389", "-pa", "80,443",
+ "-json", "-silent", "-no-color", "-disable-update-check",
+ "-no-stdin", "-config", "/dev/null", "-auth=false", "-ip-version", "4",
+ "-rate", "1200", "-retries", "1", "-timeout", "900", "-warm-up-time", "0",
+ ]
+
+ if operation == "naabu-standard":
+ hosts = validate_host_list(args)
+ return [
+ naabu(), "-host", ",".join(hosts), "-p", COMMON_PORTS, "-Pn",
+ "-scan-type", "s", "-stream", "-json", "-silent", "-no-color",
+ "-disable-update-check", "-no-stdin", "-config", "/dev/null", "-auth=false", "-ip-version", "4",
+ "-c", "100", "-rate", "2500", "-timeout", "800ms", "-warm-up-time", "0",
+ ]
+
+ if operation == "nmap-deep":
+ if len(args) != 1:
+ raise ValueError("Usage : nmap-deep ")
+ target = validate_target(args[0])
+ return [
+ nmap(), "-sS", "-sV", "-O", "--osscan-guess", "--version-light",
+ "--open", "-T4", "--top-ports", "100", "-oX", "-", target,
+ ]
+
+ if operation == "nmap-deep-hosts":
+ # Voie conservée depuis 0.4.8 : uniquement des IP déjà confirmées par la phase de
+ # découverte. -Pn interdit à Nmap de refaire une host-discovery susceptible
+ # d'écarter un pare-feu qui filtre certaines sondes.
+ hosts = validate_host_list(args)
+ return [
+ nmap(), "-Pn", "-n", "-sS", "-sV", "-O", "--osscan-guess",
+ "--version-light", "--open", "-T4", "--top-ports", "1000",
+ "-oX", "-", *hosts,
+ ]
+
+ if operation == "nmap-host":
+ if len(args) != 1:
+ raise ValueError("Usage : nmap-host ")
+ host = validate_target(args[0], network_allowed=False)
+ return [
+ nmap(), "-Pn", "-n", "-sS", "-sV", "-O", "--osscan-guess", "--version-light",
+ "--open", "-T4", "--top-ports", "1000", "-oX", "-", host,
+ ]
+
+ raise ValueError("Opération privilégiée non autorisée")
+
+
+
+def _terminate_child_group(proc: subprocess.Popen[bytes]) -> None:
+ """Termine un moteur de scan lancé en root, puis force après un court délai."""
+ if proc.poll() is not None:
+ return
+ try:
+ os.killpg(proc.pid, signal.SIGTERM)
+ except (ProcessLookupError, OSError):
+ try:
+ proc.terminate()
+ except (ProcessLookupError, OSError):
+ return
+ try:
+ proc.wait(timeout=1.5)
+ return
+ except subprocess.TimeoutExpired:
+ pass
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ except (ProcessLookupError, OSError):
+ try:
+ proc.kill()
+ except (ProcessLookupError, OSError):
+ pass
+ try:
+ proc.wait(timeout=1.0)
+ except subprocess.TimeoutExpired:
+ pass
+
+
+def run_supervised(command: list[str], input_stream=None) -> int:
+ """Exécute la commande root et écoute le canal de contrôle LibreNet sur stdin.
+
+ L'UI écrit exactement ``STOP\n`` lorsqu'un scan doit être interrompu. Le helper,
+ qui possède les privilèges du moteur enfant, est le seul endroit fiable pour tuer
+ un Nmap/Naabu root. Un EOF (fermeture/crash de l'UI) annule aussi le scan afin de
+ ne jamais laisser un processus réseau privilégié orphelin.
+ """
+ stream = sys.stdin if input_stream is None else input_stream
+ stop_event = threading.Event()
+ proc = subprocess.Popen(command, start_new_session=True)
+
+ def request_stop(_signum=None, _frame=None) -> None:
+ stop_event.set()
+
+ def watch_control() -> None:
+ try:
+ while True:
+ line = stream.readline()
+ if line == "":
+ stop_event.set()
+ return
+ if line.strip() == "STOP":
+ stop_event.set()
+ return
+ except (OSError, ValueError):
+ stop_event.set()
+
+ previous_term = signal.getsignal(signal.SIGTERM)
+ previous_int = signal.getsignal(signal.SIGINT)
+ signal.signal(signal.SIGTERM, request_stop)
+ signal.signal(signal.SIGINT, request_stop)
+ threading.Thread(target=watch_control, daemon=True).start()
+ try:
+ while proc.poll() is None:
+ if stop_event.is_set():
+ _terminate_child_group(proc)
+ return 130
+ time.sleep(0.05)
+ return int(proc.returncode or 0)
+ finally:
+ signal.signal(signal.SIGTERM, previous_term)
+ signal.signal(signal.SIGINT, previous_int)
+
+
+def main(argv: list[str] | None = None) -> int:
+ argv = list(sys.argv[1:] if argv is None else argv)
+ if os.geteuid() != 0:
+ print("Ce helper doit être lancé via pkexec.", file=sys.stderr)
+ return 77
+ if not argv:
+ print("Opération manquante", file=sys.stderr)
+ return 64
+ operation, *args = argv
+ try:
+ command = command_for(operation, args)
+ if operation == "authorize":
+ print("AUTHORIZED")
+ return 0
+ return run_supervised(command)
+ except (OSError, ValueError, RuntimeError) as exc:
+ print(str(exc), file=sys.stderr)
+ return 64
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/librenet_scanner/privileges.py b/src/librenet_scanner/privileges.py
new file mode 100644
index 0000000..572ffb9
--- /dev/null
+++ b/src/librenet_scanner/privileges.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import os
+import shutil
+from dataclasses import dataclass
+
+
+HELPER_PATH = "/usr/libexec/librenet-scanner-helper"
+POLICY_PATH = "/usr/share/polkit-1/actions/org.librenet.scanner.policy"
+
+
+@dataclass(slots=True, frozen=True)
+class PrivilegeDiagnostic:
+ pkexec_path: str | None
+ helper_path: str | None
+ policy_path: str | None
+ ready: bool
+ detail: str
+
+
+def find_pkexec() -> str | None:
+ return shutil.which("pkexec") or ("/usr/bin/pkexec" if os.path.isfile("/usr/bin/pkexec") else None)
+
+
+def find_helper() -> str | None:
+ override = os.environ.get("LIBRENET_PRIVILEGED_HELPER", "").strip()
+ if override and os.path.isfile(override) and os.access(override, os.X_OK):
+ return override
+ if os.path.isfile(HELPER_PATH) and os.access(HELPER_PATH, os.X_OK):
+ return HELPER_PATH
+ return None
+
+
+def privileged_command(operation: str, *args: str) -> list[str]:
+ pkexec = find_pkexec()
+ helper = find_helper()
+ if not pkexec:
+ raise RuntimeError("pkexec est introuvable. Installe le paquet Debian 'pkexec'.")
+ if not helper:
+ raise RuntimeError(
+ "Le helper privilégié LibreNet est introuvable. Réinstalle le paquet librenet-scanner 1.0.0."
+ )
+ return [pkexec, helper, operation, *args]
+
+
+def privilege_diagnostic() -> PrivilegeDiagnostic:
+ pkexec = find_pkexec()
+ helper = find_helper()
+ policy = POLICY_PATH if os.path.isfile(POLICY_PATH) else None
+ missing: list[str] = []
+ if not pkexec:
+ missing.append("pkexec")
+ if not helper:
+ missing.append("helper LibreNet")
+ if not policy:
+ missing.append("politique Polkit")
+ if missing:
+ return PrivilegeDiagnostic(pkexec, helper, policy, False, "Manquant : " + ", ".join(missing))
+ return PrivilegeDiagnostic(pkexec, helper, policy, True, "Mode administrateur prêt via Polkit/pkexec")
diff --git a/src/librenet_scanner/scan_logic.py b/src/librenet_scanner/scan_logic.py
new file mode 100644
index 0000000..894ffb3
--- /dev/null
+++ b/src/librenet_scanner/scan_logic.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from .models import Host
+
+
+def union_host_ips(*groups: Iterable[Host]) -> set[str]:
+ """Retourne l'union des IP découvertes par plusieurs méthodes.
+
+ Les méthodes privilégiées sont complémentaires : elles ne doivent jamais
+ remplacer les résultats d'une découverte utilisateur déjà réussie.
+ """
+ return {host.ip for group in groups for host in group if host.ip}
diff --git a/src/librenet_scanner/scanner.py b/src/librenet_scanner/scanner.py
index 713704f..640a8c8 100644
--- a/src/librenet_scanner/scanner.py
+++ b/src/librenet_scanner/scanner.py
@@ -2,18 +2,55 @@ from __future__ import annotations
import ipaddress
import os
-import shutil
+import queue
+import signal
import subprocess
+import threading
+import time
from dataclasses import dataclass
from PySide6.QtCore import QThread, Signal
+from .diagnostics import arp_scan_succeeded, find_arp_scan
+from .fastscan import find_admin_naabu, find_naabu, parse_naabu_json_line
from .models import Host
-from .network import NetworkInterface, target_is_on_interface
+from .network import (
+ NetworkInterface,
+ interface_mac_address,
+ list_ipv4_neighbors,
+ target_contains_ip,
+ target_is_on_interface,
+ target_ipv4_hosts,
+)
from .parsers import parse_arp_scan, parse_nmap_xml
+from .privileges import privileged_command
+from .scan_logic import union_host_ips
+from .vendors import lookup_mac_vendor
-COMMON_PORTS = "22,23,53,80,139,443,445,3389,5900,8006,8080,8443,9100"
+NAABU_ACTIVE_HOST_THRESHOLD = 32
+NAABU_BATCH_SIZE = 32
+NAABU_BATCH_TIMEOUT_SECONDS = 10.0
+NAABU_CONNECT_TIMEOUT = "800ms"
+NAABU_RATE = "2500"
+NAABU_CONCURRENCY = "100"
+
+COMMON_PORTS = (
+ "21,22,23,25,53,80,110,135,139,143,389,443,445,465,515,587,631,636,993,995,"
+ "1433,1521,2049,3306,3389,5000,5001,5432,5900,5985,5986,8006,8007,8080,8443,9100"
+)
+
+
+PROFILE_SIGNATURES = {
+ "Rapide": "quick-v3",
+ "Standard": f"standard-v9:adaptive-nmap-small-naabu-large:{COMMON_PORTS}",
+ "Approfondi": f"deep-v10:adaptive-standard-baseline-nmap-enrichment:{COMMON_PORTS}:top1000-pn-sv-osguess-version-light",
+}
+
+
+def profile_signature(profile: str, privileged: bool = False) -> str:
+ base = PROFILE_SIGNATURES.get(profile, f"unknown:{profile}")
+ return f"{base}:privileged-v1" if privileged else base
@dataclass(slots=True)
@@ -21,10 +58,13 @@ class ScanRequest:
target: str
profile: str
interface: NetworkInterface | None = None
+ privileged: bool = False
class ScanWorker(QThread):
progress = Signal(str)
+ progress_state = Signal(int, str) # -1 = phase indéterminée, 0..100 = progression globale
+ warning = Signal(str)
hosts_found = Signal(object)
failed = Signal(str)
completed = Signal()
@@ -33,105 +73,788 @@ class ScanWorker(QThread):
super().__init__(parent)
self.request = request
self._proc: subprocess.Popen[str] | None = None
+ self._proc_uses_helper = False
+
+ @staticmethod
+ def _uses_privileged_helper(args: list[str]) -> bool:
+ # Toutes les commandes privilégiées LibreNet passent par pkexec + notre
+ # helper. Le canal stdin reste alors réservé au protocole STOP de LibreNet.
+ return bool(args) and os.path.basename(args[0]) == "pkexec"
+
+ @staticmethod
+ def _kill_process_group(proc: subprocess.Popen[str], sig: int) -> None:
+ try:
+ os.killpg(proc.pid, sig)
+ except (ProcessLookupError, PermissionError, OSError):
+ try:
+ proc.send_signal(sig)
+ except (ProcessLookupError, PermissionError, OSError):
+ pass
+
+ def _escalate_stop(self, proc: subprocess.Popen[str], uses_helper: bool) -> None:
+ try:
+ proc.wait(timeout=2.0)
+ return
+ except subprocess.TimeoutExpired:
+ pass
+ # Le helper root est censé avoir tué son enfant après STOP. S'il est lui-même
+ # bloqué, on tente aussi de terminer l'enveloppe pkexec. Pour un processus
+ # utilisateur, SIGKILL porte sur tout le groupe créé par LibreNet.
+ if uses_helper:
+ try:
+ proc.terminate()
+ except (ProcessLookupError, PermissionError, OSError):
+ pass
+ else:
+ self._kill_process_group(proc, signal.SIGKILL)
+
+ def _signal_process_stop(self, proc: subprocess.Popen[str] | None = None) -> None:
+ proc = proc or self._proc
+ if not proc or proc.poll() is not None:
+ return
+ uses_helper = self._proc_uses_helper
+ if uses_helper and proc.stdin is not None:
+ try:
+ proc.stdin.write("STOP\n")
+ proc.stdin.flush()
+ except (BrokenPipeError, OSError, ValueError):
+ pass
+ else:
+ self._kill_process_group(proc, signal.SIGTERM)
+ threading.Thread(
+ target=self._escalate_stop, args=(proc, uses_helper), daemon=True
+ ).start()
def stop(self) -> None:
self.requestInterruption()
- proc = self._proc
- if proc and proc.poll() is None:
- proc.terminate()
+ self._signal_process_stop()
- def _run_command(self, args: list[str], label: str) -> tuple[int, str, str]:
+ def _set_progress(self, value: int, label: str) -> None:
+ """Publie une étape de scan sans prétendre connaître l'avancement interne de Nmap.
+
+ ``value == -1`` indique une phase active de durée inconnue : l'UI affiche alors
+ une barre animée. Les valeurs 0..100 sont des jalons de pipeline réels.
+ """
+ self.progress.emit(label)
+ self.progress_state.emit(value, label)
+
+ def _run_command(
+ self,
+ args: list[str],
+ label: str,
+ *,
+ start_percent: int | None = None,
+ end_percent: int | None = None,
+ timeout_seconds: float | None = None,
+ ) -> tuple[int, str, str]:
+ """Exécute une commande réseau avec annulation et délai maximal optionnel.
+
+ Le délai maximal est volontairement utilisé pour les phases interactives
+ (notamment la découverte Standard) afin qu'un moteur qui se bloque ne puisse
+ plus immobiliser l'interface pendant plusieurs minutes.
+ """
if self.isInterruptionRequested():
return 130, "", "Interrompu"
+ if start_percent is not None:
+ self._set_progress(max(0, min(100, start_percent)), label)
+ self.progress_state.emit(-1, label)
self.progress.emit(label)
+ timed_out = False
try:
+ uses_helper = self._uses_privileged_helper(args)
+ self._proc_uses_helper = uses_helper
self._proc = subprocess.Popen(
args,
+ stdin=subprocess.PIPE if uses_helper else subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
+ start_new_session=True,
)
- stdout, stderr = self._proc.communicate()
- return self._proc.returncode or 0, stdout, stderr
+ proc = self._proc
+ started = time.monotonic()
+ if self.isInterruptionRequested():
+ self._signal_process_stop(proc)
+
+ if uses_helper:
+ stdout_parts: list[str] = []
+ stderr_parts: list[str] = []
+
+ def drain(stream, target: list[str]) -> None:
+ if stream is None:
+ return
+ while True:
+ chunk = stream.read(65536)
+ if not chunk:
+ break
+ target.append(chunk)
+
+ readers = [
+ threading.Thread(target=drain, args=(proc.stdout, stdout_parts), daemon=True),
+ threading.Thread(target=drain, args=(proc.stderr, stderr_parts), daemon=True),
+ ]
+ for reader in readers:
+ reader.start()
+ stop_sent = False
+ while proc.poll() is None:
+ if self.isInterruptionRequested() and not stop_sent:
+ self._signal_process_stop(proc)
+ stop_sent = True
+ elif (
+ timeout_seconds is not None
+ and not stop_sent
+ and time.monotonic() - started >= timeout_seconds
+ ):
+ timed_out = True
+ self._signal_process_stop(proc)
+ stop_sent = True
+ try:
+ proc.wait(timeout=0.10)
+ except subprocess.TimeoutExpired:
+ pass
+ for reader in readers:
+ reader.join(timeout=1.0)
+ stdout = "".join(stdout_parts)
+ stderr = "".join(stderr_parts)
+ else:
+ try:
+ stdout, stderr = proc.communicate(timeout=timeout_seconds)
+ except subprocess.TimeoutExpired:
+ timed_out = True
+ self._kill_process_group(proc, signal.SIGTERM)
+ try:
+ stdout, stderr = proc.communicate(timeout=1.5)
+ except subprocess.TimeoutExpired:
+ self._kill_process_group(proc, signal.SIGKILL)
+ stdout, stderr = proc.communicate()
+
+ if timed_out and not self.isInterruptionRequested():
+ return 124, stdout, f"Délai maximal dépassé ({timeout_seconds:.1f} s). {stderr}".strip()
+ code = proc.returncode or 0
+ if end_percent is not None and code == 0 and not self.isInterruptionRequested():
+ self._set_progress(max(0, min(100, end_percent)), label)
+ return code, stdout, stderr
except FileNotFoundError:
return 127, "", f"Commande introuvable : {args[0]}"
except OSError as exc:
return 1, "", str(exc)
finally:
self._proc = None
+ self._proc_uses_helper = False
- def _arp_scan_command(self) -> str | None:
- found = shutil.which("arp-scan")
- if found:
- return found
- for candidate in ("/usr/sbin/arp-scan", "/usr/bin/arp-scan"):
- if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
- return candidate
- return None
+ def _arp_error_message(self, command: str, code: int, stderr: str) -> str:
+ detail = stderr.strip().splitlines()[-1] if stderr.strip() else f"code retour {code}"
+ lowered = stderr.casefold()
+ if any(token in lowered for token in ("operation not permitted", "permission denied", "cap_net_raw", "raw socket")):
+ return (
+ "arp-scan n'a pas les privilèges nécessaires pour le scan ARP. "
+ "Les MAC seront récupérées autant que possible via la table neighbor Linux. "
+ f"Pour corriger durablement : sudo setcap cap_net_raw+p {command}"
+ )
+ return f"arp-scan a échoué ({detail}). La découverte principale continue ; récupération MAC via la table neighbor Linux."
- def _emit_arp(self) -> bool:
- command = self._arp_scan_command()
+ def _local_host(self) -> Host | None:
+ """Construit l'entrée du poste local depuis l'interface sélectionnée.
+
+ ARP et la table neighbor ne contiennent normalement pas la machine elle-même.
+ La MAC locale doit donc provenir directement de l'interface Linux.
+ """
+ iface = self.request.interface
+ if not iface or not target_contains_ip(self.request.target, iface.address):
+ return None
+ mac = iface.mac or interface_mac_address(iface.name)
+ return Host(
+ ip=iface.address,
+ mac=mac,
+ vendor=lookup_mac_vendor(mac) if mac else "",
+ status="up",
+ is_local=True,
+ )
+
+ def _emit_local_host(self) -> list[Host]:
+ host = self._local_host()
+ if host is None:
+ return []
+ self.hosts_found.emit([host])
+ return [host]
+
+ def _emit_arp(self, *, start_percent: int = 5, end_percent: int = 15) -> list[Host]:
+ command = find_arp_scan()
if not command:
- return False
+ self.warning.emit("arp-scan est introuvable. La découverte locale ARP est ignorée ; le moteur principal continue.")
+ return []
iface = self.request.interface
if not iface or not target_is_on_interface(self.request.target, iface):
- return False
- args = [command, "--interface", iface.name, self.request.target]
- code, stdout, stderr = self._run_command(args, "Découverte ARP…")
- # arp-scan peut retourner 1 si aucun hôte n'est trouvé : on ne bloque pas le scan Nmap.
+ return []
+ arp_target = self.request.target
+ # L'interface propose par défaut A.B.C.1 - A.B.C.254. Pour cette plage complète
+ # d'un /24, arp-scan peut travailler sur le CIDR de l'interface et conserver
+ # la récupération rapide des MAC. Les autres plages compactes restent à Nmap.
+ if "-" in arp_target and "/" not in arp_target:
+ expected = ""
+ if iface.prefixlen == 24:
+ net = ipaddress.ip_network(iface.network, strict=False)
+ prefix = ".".join(str(net.network_address).split(".")[:3])
+ expected = f"{prefix}.1-254"
+ if arp_target != expected:
+ return []
+ arp_target = iface.network
+ if self.request.privileged:
+ try:
+ args = privileged_command("arp-scan", iface.name, arp_target)
+ label = "Découverte des hôtes — ARP (Admin)…"
+ except RuntimeError as exc:
+ self.warning.emit(str(exc))
+ args = [command, "--interface", iface.name, arp_target]
+ label = "Découverte des hôtes — ARP…"
+ else:
+ args = [command, "--interface", iface.name, arp_target]
+ label = "Découverte des hôtes — ARP…"
+ code, stdout, stderr = self._run_command(
+ args, label, start_percent=start_percent, end_percent=end_percent,
+ timeout_seconds=8.0,
+ )
hosts = parse_arp_scan(stdout)
if hosts:
self.hosts_found.emit(hosts)
- if code not in (0, 1) and stderr:
- self.progress.emit(f"arp-scan indisponible sans privilèges, repli Nmap : {stderr.strip().splitlines()[-1]}")
- return bool(hosts)
+ # arp-scan documente 0 comme succès ; tout code non nul est une erreur.
+ if not arp_scan_succeeded(code):
+ self.warning.emit(self._arp_error_message(command, code, stderr))
+ return hosts
- def _nmap(self, args: list[str], label: str) -> list[Host]:
- code, stdout, stderr = self._run_command(args, label)
+ def _neighbor_hosts(self, allowed_ips: set[str], *, percent: int | None = None) -> list[Host]:
+ iface = self.request.interface
+ if not iface or not allowed_ips or not target_is_on_interface(self.request.target, iface):
+ return []
+ label = "Récupération des MAC via la table neighbor Linux…"
+ if percent is None:
+ self.progress.emit(label)
+ else:
+ self._set_progress(percent, label)
+ result: list[Host] = []
+ for entry in list_ipv4_neighbors(iface.name):
+ if entry.ip not in allowed_ips:
+ continue
+ result.append(
+ Host(
+ ip=entry.ip,
+ mac=entry.mac,
+ vendor=lookup_mac_vendor(entry.mac),
+ status="up",
+ )
+ )
+ if result:
+ self.hosts_found.emit(result)
+ return result
+
+ def _nmap(
+ self, args: list[str], label: str, *, start_percent: int | None = None,
+ end_percent: int | None = None, timeout_seconds: float | None = None
+ ) -> list[Host]:
+ code, stdout, stderr = self._run_command(
+ args, label, start_percent=start_percent, end_percent=end_percent,
+ timeout_seconds=timeout_seconds,
+ )
if self.isInterruptionRequested():
return []
if code != 0:
raise RuntimeError(stderr.strip() or f"Nmap a quitté avec le code {code}")
return parse_nmap_xml(stdout)
+ def _run_naabu_json(
+ self,
+ args: list[str],
+ label: str,
+ parser,
+ *,
+ start_percent: int,
+ end_percent: int,
+ phase: str,
+ timeout_seconds: float | None = None,
+ ) -> list[Host] | None:
+ """Exécute une passe Naabu bornée et parse son JSONL.
+
+ Naabu CLI 2.6.1 agrège une partie de ses résultats avant de les écrire. LibreNet
+ ne dépend donc plus d'une hypothétique diffusion temps réel : les cibles sont
+ découpées en petits lots et chaque processus possède un délai maximal. Les
+ équipements sont déjà visibles grâce à la phase de découverte précédente.
+ """
+ if self.isInterruptionRequested():
+ return []
+ self._set_progress(start_percent, label)
+ self.progress_state.emit(-1, label)
+ results: dict[str, Host] = {}
+ diagnostics: list[str] = []
+ proc: subprocess.Popen[str] | None = None
+ reader: threading.Thread | None = None
+ lines: queue.Queue[str | object] = queue.Queue()
+ end_of_stream = object()
+ code = 1
+ timed_out = False
+
+ def consume(raw: str) -> None:
+ host = parser(raw.strip())
+ if host is None:
+ text = raw.strip()
+ if text and len(diagnostics) < 10:
+ diagnostics.append(text)
+ return
+ existing = results.get(host.ip)
+ if existing:
+ existing.merge(host)
+ else:
+ results[host.ip] = host
+ self.hosts_found.emit([host])
+
+ try:
+ uses_helper = self._uses_privileged_helper(args)
+ self._proc_uses_helper = uses_helper
+ self._proc = subprocess.Popen(
+ args,
+ stdin=subprocess.PIPE if uses_helper else subprocess.DEVNULL,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
+ encoding="utf-8", errors="replace", bufsize=1,
+ start_new_session=True,
+ )
+ proc = self._proc
+ started = time.monotonic()
+ assert proc.stdout is not None
+
+ def drain_stdout() -> None:
+ try:
+ for raw in proc.stdout:
+ lines.put(raw)
+ finally:
+ lines.put(end_of_stream)
+
+ reader = threading.Thread(target=drain_stdout, daemon=True)
+ reader.start()
+ stream_done = False
+ stop_sent = False
+ while True:
+ if self.isInterruptionRequested() and not stop_sent:
+ self._signal_process_stop(proc)
+ stop_sent = True
+ elif (
+ timeout_seconds is not None
+ and not stop_sent
+ and time.monotonic() - started >= timeout_seconds
+ ):
+ timed_out = True
+ self._signal_process_stop(proc)
+ stop_sent = True
+ try:
+ item = lines.get(timeout=0.10)
+ except queue.Empty:
+ item = None
+ if item is end_of_stream:
+ stream_done = True
+ elif isinstance(item, str):
+ consume(item)
+
+ if proc.poll() is not None and stream_done and lines.empty():
+ break
+
+ while True:
+ try:
+ item = lines.get_nowait()
+ except queue.Empty:
+ break
+ if isinstance(item, str):
+ consume(item)
+ code = int(proc.returncode or 0)
+ except (OSError, FileNotFoundError) as exc:
+ self.warning.emit(f"Naabu indisponible pendant {phase} ({exc}).")
+ return None
+ finally:
+ if proc is not None and self.isInterruptionRequested() and proc.poll() is None:
+ self._signal_process_stop(proc)
+ try:
+ proc.wait(timeout=3.0)
+ except subprocess.TimeoutExpired:
+ pass
+ if reader is not None:
+ reader.join(timeout=1.0)
+ if proc is not None:
+ for stream in (proc.stdout, proc.stdin):
+ if stream is not None:
+ try:
+ stream.close()
+ except (OSError, ValueError):
+ pass
+ self._proc = None
+ self._proc_uses_helper = False
+
+ if self.isInterruptionRequested():
+ return []
+ if timed_out:
+ self.warning.emit(
+ f"Naabu a dépassé le délai maximal pendant {phase} "
+ f"({timeout_seconds:.0f} s). Repli Nmap sur les hôtes déjà découverts."
+ )
+ return None
+ if code != 0:
+ detail = diagnostics[-1] if diagnostics else f"code retour {code}"
+ self.warning.emit(f"Naabu a échoué pendant {phase} ({detail}).")
+ return None
+ if phase == "le scan de ports":
+ count = sum(len(h.ports) for h in results.values())
+ done = f"Naabu : {count} port(s) ouvert(s) sur {len(results)} hôte(s)"
+ else:
+ done = f"Naabu : {len(results)} hôte(s) actif(s) détecté(s)"
+ self._set_progress(end_percent, done)
+ return list(results.values())
+
+ def _naabu_ports(
+ self, ips: list[str], *, start_percent: int, end_percent: int
+ ) -> list[Host] | None:
+ """Scanne les ports uniquement sur les hôtes déjà découverts.
+
+ Envoyer tout un /24 à un scanner de ports multiplie inutilement les connexions
+ et peut retarder l'affichage. Le moteur adaptatif découpe au contraire
+ la liste d'hôtes actifs en lots courts. Un lot ne peut pas bloquer plus de
+ ``NAABU_BATCH_TIMEOUT_SECONDS`` ; en cas de problème le niveau supérieur
+ repasse sur Nmap, mais uniquement pour les hôtes déjà confirmés.
+ """
+ if not ips:
+ return []
+ binary = find_naabu()
+ if not binary:
+ self.warning.emit("Naabu est indisponible : repli Nmap pour les ports Standard.")
+ return None
+ if self.request.privileged and not find_admin_naabu():
+ self.warning.emit("Naabu Admin est indisponible : repli Nmap pour les ports Standard.")
+ return None
+
+ ordered = sorted(set(ips), key=ipaddress.ip_address)
+ batches = [
+ ordered[index:index + NAABU_BATCH_SIZE]
+ for index in range(0, len(ordered), NAABU_BATCH_SIZE)
+ ]
+ all_results: dict[str, Host] = {}
+ span = max(1, end_percent - start_percent)
+
+ for index, batch in enumerate(batches, start=1):
+ if self.isInterruptionRequested():
+ return list(all_results.values())
+ batch_start = start_percent + round(span * ((index - 1) / len(batches)))
+ batch_end = start_percent + round(span * (index / len(batches)))
+ suffix = f" — lot {index}/{len(batches)} ({len(batch)} hôte(s))"
+ try:
+ if self.request.privileged:
+ args = privileged_command("naabu-standard", *batch)
+ label = f"{self.request.profile} — ports Naabu SYN (Admin){suffix}…"
+ else:
+ args = [
+ binary, "-host", ",".join(batch), "-p", COMMON_PORTS, "-Pn",
+ "-scan-type", "c", "-stream",
+ "-json", "-silent", "-no-color",
+ "-disable-update-check", "-no-stdin", "-config", "/dev/null",
+ "-auth=false", "-ip-version", "4",
+ "-c", NAABU_CONCURRENCY, "-rate", NAABU_RATE,
+ "-timeout", NAABU_CONNECT_TIMEOUT, "-warm-up-time", "0",
+ ]
+ label = f"{self.request.profile} — ports Naabu CONNECT{suffix}…"
+ except RuntimeError as exc:
+ self.warning.emit(f"Naabu Admin non utilisable ({exc}). Repli Nmap.")
+ return None
+
+ result = self._run_naabu_json(
+ args, label, parse_naabu_json_line,
+ start_percent=batch_start, end_percent=batch_end,
+ phase="le scan de ports", timeout_seconds=NAABU_BATCH_TIMEOUT_SECONDS,
+ )
+ if result is None:
+ return None
+ for host in result:
+ existing = all_results.get(host.ip)
+ if existing:
+ existing.merge(host)
+ else:
+ all_results[host.ip] = host
+
+ return list(all_results.values())
+
+ def _nmap_standard_ports(
+ self, ips: list[str], *, start_percent: int, end_percent: int, fallback: bool = False
+ ) -> list[Host]:
+ """Scanne les ports usuels avec Nmap sur les seuls hôtes actifs.
+
+ ``fallback`` ne décrit que le cas où Naabu a d'abord été choisi puis a
+ échoué. Sur les petits ensembles d'hôtes, Nmap est le moteur nominal et
+ l'interface ne doit surtout pas le présenter comme un repli.
+ """
+ if not ips:
+ return []
+ prefix = "Base Approfondi" if self.request.profile == "Approfondi" else "Standard"
+ suffix = " — REPLI Naabu" if fallback else " — moteur adaptatif"
+ if self.request.privileged:
+ args = privileged_command("nmap-standard", *ips)
+ label = f"{prefix} — ports Nmap SYN (Admin){suffix}…"
+ else:
+ args = [
+ "nmap", "-Pn", "-n", "-sT", "--open", "-T4",
+ "--max-retries", "1", "--host-timeout", "12s",
+ "-p", COMMON_PORTS, "-oX", "-", *ips
+ ]
+ label = f"{prefix} — ports Nmap TCP{suffix}…"
+ timeout_seconds = min(60.0, max(15.0, 8.0 + len(ips) * 0.5))
+ hosts = self._nmap(
+ args, label, start_percent=start_percent, end_percent=end_percent,
+ timeout_seconds=timeout_seconds,
+ )
+ if hosts:
+ self.hosts_found.emit(hosts)
+ return hosts
+
+ def _standard_discovery(
+ self, *, start_percent: int, end_percent: int, target_ips: list[str]
+ ) -> set[str]:
+ """Découverte rapide et robuste avant le scan de ports Standard.
+
+ Le Standard est volontairement hybride : ARP fournit rapidement les
+ voisins/MAC sur le LAN, puis une unique passe Nmap ``-sn -n -T4`` confirme
+ les hôtes actifs. Naabu ne reçoit ensuite que ces hôtes, jamais tout le /24.
+ Cette stratégie privilégie un temps de réponse court et prévisible.
+ """
+ span = max(10, end_percent - start_percent)
+ arp_end = start_percent + round(span * 0.35)
+ local_hosts = self._emit_local_host()
+ arp_hosts = self._emit_arp(start_percent=start_percent, end_percent=arp_end)
+ if self.isInterruptionRequested():
+ return union_host_ips(local_hosts, arp_hosts)
+
+ discovery_timeout = min(45.0, max(12.0, 8.0 + len(target_ips) / 32.0))
+ # Le Standard utilise volontairement la même découverte Nmap en mode
+ # utilisateur et Admin. Les privilèges servent à ARP et au SYN scan de ports,
+ # pas à changer le jeu de sondes de découverte : activer Admin ne doit pas
+ # rendre des hôtes invisibles ni ajouter un second passage Nmap.
+ args = [
+ "nmap", "-sn", "-n", "-T4", "--max-retries", "1",
+ "-oX", "-", self.request.target,
+ ]
+ if self.request.privileged:
+ label = f"{self.request.profile} — découverte rapide Nmap + ARP (mode Admin)…"
+ else:
+ label = f"{self.request.profile} — découverte rapide Nmap/ARP…"
+
+ nmap_hosts = self._nmap(
+ args, label, start_percent=arp_end, end_percent=end_percent,
+ timeout_seconds=discovery_timeout,
+ )
+ if nmap_hosts:
+ self.hosts_found.emit(nmap_hosts)
+ known_ips = union_host_ips(local_hosts, arp_hosts, nmap_hosts)
+ self._neighbor_hosts(known_ips, percent=end_percent)
+ self._set_progress(
+ end_percent,
+ f"Découverte terminée : {len(known_ips)} hôte(s) actif(s) — ports à scanner : {len(known_ips)}",
+ )
+ return known_ips
+
+ def _standard_baseline(
+ self, *, start_percent: int, end_percent: int
+ ) -> tuple[set[str], str]:
+ """Socle adaptatif commun au Standard et au début de l'Approfondi.
+
+ Retourne ``(hôtes_connus, moteur_ports)`` avec ``moteur_ports`` parmi
+ ``nmap``, ``naabu``, ``nmap-fallback`` ou ``none``. Pour un petit LAN,
+ Nmap est volontairement préféré : avec seulement quelques dizaines de
+ ports sur quelques hôtes déjà actifs, son démarrage et son comportement
+ sont plus prévisibles que la CLI Naabu. Naabu est réservé aux ensembles
+ plus importants, là où son parallélisme apporte réellement quelque chose.
+ """
+ target_ips = target_ipv4_hosts(self.request.target)
+ span = max(20, end_percent - start_percent)
+ at = lambda fraction: min(end_percent, start_percent + round(span * fraction))
+
+ known_ips = self._standard_discovery(
+ start_percent=start_percent, end_percent=at(0.38), target_ips=target_ips
+ )
+ if self.isInterruptionRequested() or not known_ips:
+ return known_ips, "none"
+
+ ips = sorted(known_ips, key=ipaddress.ip_address)
+
+ # Naabu est un scanner massif. Sur un petit ensemble déjà découvert, Nmap
+ # est plus simple, plus déterministe et évite le problème de buffering CLI
+ # Le seuil est volontairement conservateur pour les petits réseaux.
+ if len(ips) < NAABU_ACTIVE_HOST_THRESHOLD:
+ self._set_progress(
+ at(0.42),
+ f"{self.request.profile} — {len(ips)} hôte(s) actif(s) : "
+ "scan de ports Nmap optimisé…",
+ )
+ self._nmap_standard_ports(
+ ips, start_percent=at(0.45), end_percent=at(0.92), fallback=False
+ )
+ engine = "nmap"
+ else:
+ port_hosts = self._naabu_ports(
+ ips, start_percent=at(0.42), end_percent=at(0.90)
+ )
+ if self.isInterruptionRequested():
+ return known_ips, "none"
+ if port_hosts is None:
+ self._set_progress(
+ at(0.43),
+ "Naabu indisponible ou trop lent — REPLI Nmap sur les seuls hôtes actifs…",
+ )
+ self._nmap_standard_ports(
+ ips, start_percent=at(0.45), end_percent=at(0.92), fallback=True
+ )
+ engine = "nmap-fallback"
+ else:
+ engine = "naabu"
+
+ self._neighbor_hosts(known_ips, percent=at(0.96))
+ return known_ips, engine
+
+ def _standard_scan(self) -> None:
+ known_ips, engine = self._standard_baseline(start_percent=4, end_percent=96)
+ if self.isInterruptionRequested():
+ return
+ if engine == "naabu":
+ mode = "SYN (Admin)" if self.request.privileged else "CONNECT"
+ label = f"Standard terminé — {len(known_ips)} hôte(s) — ports Naabu {mode}"
+ elif engine == "nmap-fallback":
+ label = f"Standard terminé — {len(known_ips)} hôte(s) — ports en REPLI Nmap"
+ elif engine == "nmap":
+ mode = "SYN (Admin)" if self.request.privileged else "TCP"
+ label = f"Standard terminé — {len(known_ips)} hôte(s) — ports Nmap {mode} (adaptatif)"
+ else:
+ label = f"Standard terminé — {len(known_ips)} hôte(s)"
+ self._set_progress(96, label)
+
+ def _nmap_optional(
+ self, args: list[str], label: str, warning_prefix: str, *,
+ start_percent: int | None = None, end_percent: int | None = None,
+ timeout_seconds: float | None = None
+ ) -> list[Host]:
+ """Lance une phase Nmap complémentaire sans invalider tout le scan.
+
+ La découverte d'hôtes combine plusieurs méthodes. Une méthode qui échoue ne
+ doit jamais effacer les hôtes déjà vus par ARP ou par une autre découverte.
+ """
+ try:
+ hosts = self._nmap(
+ args, label, start_percent=start_percent, end_percent=end_percent,
+ timeout_seconds=timeout_seconds,
+ )
+ except RuntimeError as exc:
+ self.warning.emit(f"{warning_prefix} : {exc}")
+ return []
+ if hosts:
+ self.hosts_found.emit(hosts)
+ return hosts
+
+ def _discover_hosts(self, *, start_percent: int = 4, end_percent: int = 52) -> set[str]:
+ """Découverte robuste et additive des hôtes.
+
+ IMPORTANT : le mode administrateur ne remplace plus la découverte normale.
+ Il ajoute ARP/Nmap privilégiés aux résultats non privilégiés. Ainsi activer
+ les privilèges ne peut pas réduire le nombre d'hôtes détectés.
+ """
+ span = max(20, end_percent - start_percent)
+ arp_end = start_percent + round(span * 0.20)
+ normal_end = start_percent + round(span * 0.62)
+ admin_end = start_percent + round(span * 0.84)
+ neighbor_percent = start_percent + round(span * 0.94)
+
+ local_hosts = self._emit_local_host()
+ arp_hosts = self._emit_arp(start_percent=start_percent, end_percent=arp_end)
+ if self.isInterruptionRequested():
+ return union_host_ips(local_hosts, arp_hosts)
+
+ # Toujours conserver la découverte Nmap utilisateur comme socle. C'était
+ # précisément la régression de la 0.4.3 : en mode admin elle était remplacée
+ # par la variante root, qui peut choisir des probes/routages différents.
+ normal_hosts = self._nmap_optional(
+ ["nmap", "-sn", "-n", "-T4", "--max-retries", "1", "-oX", "-", self.request.target],
+ "Découverte des hôtes — Nmap (utilisateur)…",
+ "Découverte Nmap utilisateur échouée",
+ start_percent=arp_end,
+ end_percent=normal_end,
+ )
+
+ if self.isInterruptionRequested():
+ return union_host_ips(local_hosts, arp_hosts, normal_hosts)
+
+ privileged_hosts: list[Host] = []
+ if self.request.privileged and not self.isInterruptionRequested():
+ try:
+ cmd = privileged_command("nmap-discover", self.request.target)
+ except RuntimeError as exc:
+ self.warning.emit(str(exc))
+ else:
+ privileged_hosts = self._nmap_optional(
+ cmd,
+ "Découverte des hôtes — Nmap (Admin complémentaire)…",
+ "Découverte Nmap Admin complémentaire échouée",
+ start_percent=normal_end,
+ end_percent=admin_end,
+ )
+
+ known_ips = union_host_ips(arp_hosts, normal_hosts, privileged_hosts)
+ known_ips.update(union_host_ips(local_hosts))
+ if self.isInterruptionRequested():
+ return known_ips
+ self._neighbor_hosts(known_ips, percent=neighbor_percent)
+ details = f"local {len(local_hosts)} · ARP {len(arp_hosts)} · Nmap {len(normal_hosts)}"
+ if self.request.privileged:
+ details += f" · admin {len(privileged_hosts)}"
+ self._set_progress(end_percent, f"Découverte : {len(known_ips)} hôte(s) unique(s) — {details}")
+ return known_ips
+
def run(self) -> None:
try:
profile = self.request.profile
target = self.request.target
+ self._set_progress(1, f"Préparation du scan {profile.lower()}…")
+
if profile == "Rapide":
- arp_ok = self._emit_arp()
- if not arp_ok:
- hosts = self._nmap(["nmap", "-sn", "-oX", "-", target], "Découverte Nmap…")
- if hosts:
- self.hosts_found.emit(hosts)
+ self._discover_hosts(start_percent=4, end_percent=96)
elif profile == "Standard":
- self._emit_arp()
- hosts = self._nmap(["nmap", "-sn", "-oX", "-", target], "Découverte des hôtes…")
- if hosts:
- self.hosts_found.emit(hosts)
- if self.isInterruptionRequested():
- return
- ips = [h.ip for h in hosts]
- if ips:
- args = ["nmap", "-Pn", "-sT", "--open", "-T4", "-p", COMMON_PORTS, "-oX", "-", *ips]
- port_hosts = self._nmap(args, "Scan des ports usuels…")
- if port_hosts:
- self.hosts_found.emit(port_hosts)
+ self._standard_scan()
elif profile == "Approfondi":
- self._emit_arp()
- args = ["nmap", "-sT", "-sV", "--version-light", "--open", "-T4", "--top-ports", "100", "-oX", "-", target]
- hosts = self._nmap(args, "Scan approfondi : services et 100 ports principaux…")
- if hosts:
- self.hosts_found.emit(hosts)
+ # Même socle performant que Standard : découverte rapide d'abord,
+ # puis ports usuels sur les seuls hôtes actifs. Nmap intervient
+ # ensuite volontairement pour l'enrichissement -sV/OS.
+ known_ips, _ports_engine = self._standard_baseline(
+ start_percent=4, end_percent=56
+ )
+ if self.isInterruptionRequested():
+ return
+ ips = sorted(known_ips, key=ipaddress.ip_address)
+ if ips:
+ if self.request.privileged:
+ deep_args = privileged_command("nmap-deep-hosts", *ips)
+ deep_label = "Enrichissement approfondi — Nmap SYN, services et OS (Admin)…"
+ else:
+ deep_args = [
+ "nmap", "-Pn", "-n", "-sT", "-sV", "--version-light",
+ "--open", "-T4", "--top-ports", "1000",
+ "-oX", "-", *ips,
+ ]
+ deep_label = "Enrichissement approfondi — Nmap TCP et services…"
+ deep_hosts = self._nmap(
+ deep_args, deep_label, start_percent=62, end_percent=96
+ )
+ if deep_hosts:
+ self.hosts_found.emit(deep_hosts)
+ self._neighbor_hosts(set(ips), percent=98)
else:
raise RuntimeError(f"Profil inconnu : {profile}")
- except Exception as exc: # frontière thread -> GUI
+ if not self.isInterruptionRequested():
+ self._set_progress(100, "Finalisation du scan…")
+
+ except Exception as exc:
self.failed.emit(str(exc))
finally:
self.completed.emit()
@@ -143,22 +866,114 @@ class HostScanWorker(QThread):
failed = Signal(str)
completed = Signal()
- def __init__(self, ip: str, parent=None) -> None:
+ def __init__(self, ip: str, parent=None, privileged: bool = False) -> None:
super().__init__(parent)
self.ip = str(ipaddress.ip_address(ip))
+ self.privileged = privileged
self._proc: subprocess.Popen[str] | None = None
def stop(self) -> None:
self.requestInterruption()
- if self._proc and self._proc.poll() is None:
- self._proc.terminate()
+ proc = self._proc
+ if not proc or proc.poll() is not None:
+ return
+ uses_helper = self.privileged and proc.stdin is not None
+ if uses_helper:
+ try:
+ proc.stdin.write("STOP\n")
+ proc.stdin.flush()
+ except (BrokenPipeError, OSError, ValueError):
+ pass
+ else:
+ try:
+ os.killpg(proc.pid, signal.SIGTERM)
+ except (ProcessLookupError, PermissionError, OSError):
+ try:
+ proc.terminate()
+ except (ProcessLookupError, PermissionError, OSError):
+ pass
+
+ def escalate() -> None:
+ try:
+ proc.wait(timeout=2.5)
+ return
+ except subprocess.TimeoutExpired:
+ pass
+ if uses_helper:
+ try:
+ proc.terminate()
+ except (ProcessLookupError, PermissionError, OSError):
+ pass
+ else:
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ except (ProcessLookupError, PermissionError, OSError):
+ try:
+ proc.kill()
+ except (ProcessLookupError, PermissionError, OSError):
+ pass
+
+ threading.Thread(target=escalate, daemon=True).start()
def run(self) -> None:
self.progress.emit(f"Scan détaillé de {self.ip}…")
- args = ["nmap", "-sT", "-sV", "--version-light", "--open", "-T4", "--top-ports", "1000", "-oX", "-", self.ip]
+ if self.privileged:
+ try:
+ args = privileged_command("nmap-host", self.ip)
+ except RuntimeError as exc:
+ self.failed.emit(str(exc))
+ self.completed.emit()
+ return
+ else:
+ args = [
+ "nmap", "-Pn", "-n", "-sT", "-sV", "--version-light", "--open", "-T4",
+ "--top-ports", "1000", "-oX", "-", self.ip,
+ ]
try:
- self._proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
- stdout, stderr = self._proc.communicate()
+ uses_helper = bool(args) and os.path.basename(args[0]) == "pkexec"
+ self._proc = subprocess.Popen(
+ args,
+ stdin=subprocess.PIPE if uses_helper else subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ start_new_session=True,
+ )
+ if self.isInterruptionRequested():
+ self.stop()
+ if uses_helper:
+ stdout_parts: list[str] = []
+ stderr_parts: list[str] = []
+ def drain(stream, target: list[str]) -> None:
+ if stream is None:
+ return
+ while True:
+ chunk = stream.read(65536)
+ if not chunk:
+ break
+ target.append(chunk)
+ readers = [
+ threading.Thread(target=drain, args=(self._proc.stdout, stdout_parts), daemon=True),
+ threading.Thread(target=drain, args=(self._proc.stderr, stderr_parts), daemon=True),
+ ]
+ for reader in readers:
+ reader.start()
+ stop_sent = False
+ while self._proc.poll() is None:
+ if self.isInterruptionRequested() and not stop_sent:
+ self.stop()
+ stop_sent = True
+ try:
+ self._proc.wait(timeout=0.10)
+ except subprocess.TimeoutExpired:
+ pass
+ for reader in readers:
+ reader.join(timeout=1.0)
+ stdout, stderr = "".join(stdout_parts), "".join(stderr_parts)
+ else:
+ stdout, stderr = self._proc.communicate()
if self.isInterruptionRequested():
return
if self._proc.returncode != 0:
diff --git a/src/librenet_scanner/storage.py b/src/librenet_scanner/storage.py
index 10c37a2..3bc8c88 100644
--- a/src/librenet_scanner/storage.py
+++ b/src/librenet_scanner/storage.py
@@ -3,10 +3,21 @@ from __future__ import annotations
import json
import os
import sqlite3
+from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
-from .models import Host
+from .identity import (
+ candidate_query_values,
+ identity_key,
+ mac_identity_kind,
+ normalize_mac,
+ port_fingerprint_json,
+ score_identity_match,
+ shared_macs,
+)
+from .intelligence import enrich_host
+from .models import Host, PortInfo
def data_dir() -> Path:
@@ -25,10 +36,20 @@ class HistoryStore:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
- def _connect(self) -> sqlite3.Connection:
+ @contextmanager
+ def _connect(self):
+ """Connexion SQLite transactionnelle toujours refermée proprement."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
- return conn
+ conn.execute("PRAGMA foreign_keys=ON")
+ try:
+ yield conn
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
def _init_db(self) -> None:
with self._connect() as conn:
@@ -52,22 +73,107 @@ class HistoryStore:
ports_json TEXT NOT NULL,
FOREIGN KEY(scan_id) REFERENCES scans(id) ON DELETE CASCADE
);
+ CREATE INDEX IF NOT EXISTS idx_scans_target_profile
+ ON scans(target, profile, id DESC);
+ CREATE INDEX IF NOT EXISTS idx_scan_hosts_scan_id
+ ON scan_hosts(scan_id);
+ CREATE TABLE IF NOT EXISTS host_metadata (
+ identity TEXT PRIMARY KEY,
+ mac TEXT,
+ ip TEXT,
+ hostname TEXT,
+ favorite INTEGER NOT NULL DEFAULT 0,
+ group_name TEXT NOT NULL DEFAULT '',
+ note TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_host_metadata_mac ON host_metadata(mac);
+ CREATE INDEX IF NOT EXISTS idx_host_metadata_ip ON host_metadata(ip);
+ CREATE TABLE IF NOT EXISTS online_vendor_cache (
+ mac TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ vendor TEXT NOT NULL DEFAULT '',
+ found INTEGER NOT NULL DEFAULT 0,
+ block_type TEXT NOT NULL DEFAULT '',
+ is_randomized INTEGER NOT NULL DEFAULT 0,
+ is_private INTEGER NOT NULL DEFAULT 0,
+ checked_at TEXT NOT NULL,
+ PRIMARY KEY(mac, provider)
+ );
+ CREATE INDEX IF NOT EXISTS idx_online_vendor_cache_checked
+ ON online_vendor_cache(checked_at);
+ CREATE TABLE IF NOT EXISTS host_identification (
+ identity TEXT PRIMARY KEY,
+ mac TEXT NOT NULL DEFAULT '',
+ ip TEXT NOT NULL DEFAULT '',
+ hostname TEXT NOT NULL DEFAULT '',
+ os_name TEXT NOT NULL DEFAULT '',
+ os_score INTEGER NOT NULL DEFAULT 0,
+ os_source TEXT NOT NULL DEFAULT '',
+ os_seen_at TEXT NOT NULL DEFAULT '',
+ device_type TEXT NOT NULL DEFAULT '',
+ type_score INTEGER NOT NULL DEFAULT 0,
+ type_source TEXT NOT NULL DEFAULT '',
+ type_seen_at TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_host_identification_mac ON host_identification(mac);
+ CREATE INDEX IF NOT EXISTS idx_host_identification_ip ON host_identification(ip);
+ CREATE TABLE IF NOT EXISTS endpoint_identification (
+ identity TEXT PRIMARY KEY,
+ scope TEXT NOT NULL DEFAULT '',
+ mac TEXT NOT NULL DEFAULT '',
+ mac_kind TEXT NOT NULL DEFAULT '',
+ ip TEXT NOT NULL DEFAULT '',
+ hostname TEXT NOT NULL DEFAULT '',
+ ports_json TEXT NOT NULL DEFAULT '[]',
+ os_name TEXT NOT NULL DEFAULT '',
+ os_accuracy INTEGER,
+ os_score INTEGER NOT NULL DEFAULT 0,
+ os_source TEXT NOT NULL DEFAULT '',
+ os_seen_at TEXT NOT NULL DEFAULT '',
+ device_type TEXT NOT NULL DEFAULT '',
+ type_score INTEGER NOT NULL DEFAULT 0,
+ type_source TEXT NOT NULL DEFAULT '',
+ type_seen_at TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_endpoint_identification_mac ON endpoint_identification(mac);
+ CREATE INDEX IF NOT EXISTS idx_endpoint_identification_scope ON endpoint_identification(scope);
+ CREATE INDEX IF NOT EXISTS idx_endpoint_identification_ip ON endpoint_identification(ip);
+ CREATE INDEX IF NOT EXISTS idx_endpoint_identification_hostname ON endpoint_identification(hostname);
"""
)
+ scan_host_columns = {row[1] for row in conn.execute("PRAGMA table_info(scan_hosts)")}
+ if "os_accuracy" not in scan_host_columns:
+ conn.execute("ALTER TABLE scan_hosts ADD COLUMN os_accuracy INTEGER")
+ endpoint_columns = {row[1] for row in conn.execute("PRAGMA table_info(endpoint_identification)")}
+ if "scope" not in endpoint_columns:
+ conn.execute("ALTER TABLE endpoint_identification ADD COLUMN scope TEXT NOT NULL DEFAULT ''")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_endpoint_identification_scope ON endpoint_identification(scope)")
+ scan_columns = {row[1] for row in conn.execute("PRAGMA table_info(scans)")}
+ if "scan_schema" not in scan_columns:
+ conn.execute("ALTER TABLE scans ADD COLUMN scan_schema TEXT")
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_scans_target_profile_schema "
+ "ON scans(target, profile, scan_schema, id DESC)"
+ )
- def save_scan(self, target: str, profile: str, hosts: list[Host]) -> int:
+ def save_scan(self, target: str, profile: str, hosts: list[Host], scan_schema: str = "") -> int:
now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+ active_hosts = [h for h in hosts if h.status != "down"]
with self._connect() as conn:
cur = conn.execute(
- "INSERT INTO scans(created_at, target, profile, host_count) VALUES (?, ?, ?, ?)",
- (now, target, profile, len(hosts)),
+ "INSERT INTO scans(created_at, target, profile, host_count, scan_schema) VALUES (?, ?, ?, ?, ?)",
+ (now, target, profile, len(active_hosts), scan_schema),
)
scan_id = int(cur.lastrowid)
- for host in hosts:
+ for host in active_hosts:
ports = [
{
"port": p.port,
"protocol": p.protocol,
+ "state": p.state,
"service": p.service,
"product": p.product,
"version": p.version,
@@ -76,10 +182,13 @@ class HistoryStore:
]
conn.execute(
"""
- INSERT INTO scan_hosts(scan_id, ip, hostname, mac, vendor, os_name, ports_json)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO scan_hosts(scan_id, ip, hostname, mac, vendor, os_name, os_accuracy, ports_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
- (scan_id, host.ip, host.hostname, host.mac, host.vendor, host.os_name, json.dumps(ports, ensure_ascii=False)),
+ (
+ scan_id, host.ip, host.hostname, host.mac, host.vendor, host.os_name,
+ host.os_accuracy, json.dumps(ports, ensure_ascii=False),
+ ),
)
return scan_id
@@ -91,3 +200,540 @@ class HistoryStore:
(limit,),
)
)
+
+ def clear_scan_history(self) -> int:
+ """Supprime uniquement l'historique des scans.
+
+ Les métadonnées utilisateur (favoris/groupes/notes), le cache OUI en ligne
+ et les identifications mémorisées restent intacts. ``scan_hosts`` est
+ supprimé automatiquement grâce à la clé étrangère ON DELETE CASCADE.
+ """
+ with self._connect() as conn:
+ count = int(conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0])
+ conn.execute("DELETE FROM scans")
+ return count
+
+ def latest_scan(self, target: str, profile: str, scan_schema: str = "") -> sqlite3.Row | None:
+ with self._connect() as conn:
+ return conn.execute(
+ """
+ SELECT id, created_at, target, profile, host_count, scan_schema
+ FROM scans
+ WHERE target = ? AND profile = ? AND scan_schema = ?
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (target, profile, scan_schema),
+ ).fetchone()
+
+ def load_scan_hosts(self, scan_id: int) -> list[Host]:
+ with self._connect() as conn:
+ scan = conn.execute("SELECT created_at FROM scans WHERE id = ?", (scan_id,)).fetchone()
+ if scan is None:
+ return []
+ rows = list(
+ conn.execute(
+ """
+ SELECT ip, hostname, mac, vendor, os_name, os_accuracy, ports_json
+ FROM scan_hosts
+ WHERE scan_id = ?
+ ORDER BY ip
+ """,
+ (scan_id,),
+ )
+ )
+
+ result: list[Host] = []
+ for row in rows:
+ ports_payload = json.loads(row["ports_json"] or "[]")
+ ports = [
+ PortInfo(
+ port=int(p.get("port", 0)),
+ protocol=str(p.get("protocol", "tcp")),
+ state=str(p.get("state", "open")),
+ service=str(p.get("service", "")),
+ product=str(p.get("product", "")),
+ version=str(p.get("version", "")),
+ )
+ for p in ports_payload
+ ]
+ result.append(
+ enrich_host(
+ Host(
+ ip=row["ip"],
+ hostname=row["hostname"] or "",
+ mac=(row["mac"] or "").upper(),
+ vendor=row["vendor"] or "",
+ os_name=row["os_name"] or "",
+ os_accuracy=row["os_accuracy"],
+ ports=ports,
+ status="up",
+ last_seen=scan["created_at"],
+ )
+ )
+ )
+ return result
+
+ @staticmethod
+ def _host_identity(host: Host, *, shared_mac: bool = False, scope: str = "") -> str:
+ base = identity_key(host, shared_mac=shared_mac)
+ if base == "local:self" or not scope:
+ return base
+ return f"{scope}::{base}"
+
+ def host_metadata(self, host: Host, *, shared_mac: bool = False) -> dict[str, object]:
+ """Retourne favoris/groupe/note sans transférer une fiche à un autre hôte.
+
+ Dès qu'une MAC actuelle est connue, une ligne portant une autre MAC sur la
+ même IP n'est jamais utilisée. Le fallback IP n'est accepté que pour une
+ ancienne fiche réellement *legacy* sans MAC. Une MAC partagée (proxy ARP,
+ VIP, clone...) est scindée par IP afin d'éviter de partager les notes entre
+ plusieurs endpoints.
+ """
+ identity = self._host_identity(host, shared_mac=shared_mac)
+ mac = normalize_mac(host.mac)
+ with self._connect() as conn:
+ row = conn.execute(
+ "SELECT * FROM host_metadata WHERE identity = ? LIMIT 1", (identity,)
+ ).fetchone()
+ if row is None and mac and not shared_mac:
+ row = conn.execute(
+ "SELECT * FROM host_metadata WHERE mac = ? ORDER BY updated_at DESC LIMIT 1",
+ (mac,),
+ ).fetchone()
+ if row is None and mac:
+ # Migration sûre d'une fiche créée avant que la MAC ne soit connue.
+ row = conn.execute(
+ """SELECT * FROM host_metadata
+ WHERE ip = ? AND COALESCE(mac, '') = ''
+ ORDER BY updated_at DESC LIMIT 1""",
+ (host.ip,),
+ ).fetchone()
+ if row is None and not mac:
+ row = conn.execute(
+ "SELECT * FROM host_metadata WHERE ip = ? ORDER BY updated_at DESC LIMIT 1",
+ (host.ip,),
+ ).fetchone()
+ if row is None:
+ return {"favorite": False, "group_name": "", "note": ""}
+ return {
+ "favorite": bool(row["favorite"]),
+ "group_name": row["group_name"] or "",
+ "note": row["note"] or "",
+ }
+
+ def save_host_metadata(
+ self, host: Host, *, favorite: bool | None = None, group_name: str | None = None,
+ note: str | None = None, shared_mac: bool = False
+ ) -> None:
+ current = self.host_metadata(host, shared_mac=shared_mac)
+ if favorite is None:
+ favorite = bool(current["favorite"])
+ if group_name is None:
+ group_name = str(current["group_name"])
+ if note is None:
+ note = str(current["note"])
+ identity = self._host_identity(host, shared_mac=shared_mac)
+ mac = normalize_mac(host.mac)
+ now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+ with self._connect() as conn:
+ # Ne supprimer que l'ancienne fiche IP sans MAC. Une fiche avec une
+ # MAC différente peut appartenir au précédent détenteur du bail DHCP.
+ if mac:
+ conn.execute(
+ "DELETE FROM host_metadata WHERE ip = ? AND COALESCE(mac, '') = '' AND identity <> ?",
+ (host.ip, identity),
+ )
+ conn.execute(
+ """
+ INSERT INTO host_metadata(identity, mac, ip, hostname, favorite, group_name, note, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(identity) DO UPDATE SET
+ mac=excluded.mac, ip=excluded.ip, hostname=excluded.hostname,
+ favorite=excluded.favorite, group_name=excluded.group_name,
+ note=excluded.note, updated_at=excluded.updated_at
+ """,
+ (identity, mac, host.ip, host.hostname, int(bool(favorite)), group_name.strip(), note.strip(), now),
+ )
+
+ @staticmethod
+ def _profile_priority(profile: str) -> int:
+ return {"Rapide": 0, "Standard": 10, "Approfondi": 25, "Détaillé": 30}.get(profile, 0)
+
+ @classmethod
+ def _os_quality(cls, os_name: str, profile: str, accuracy: int | None = None) -> int:
+ value = (os_name or "").strip()
+ if not value:
+ return 0
+ # Quand Nmap fournit son accuracy, ne la "gonfle" jamais avec le nom ou le
+ # profil : 82 % doit rester 82 %. Cela évite de transformer une hypothèse
+ # Nmap en quasi-certitude simplement parce qu'elle contient "OpenWrt 24".
+ if accuracy is not None:
+ return max(0, min(100, int(accuracy)))
+
+ folded = value.casefold()
+ score = 35 + cls._profile_priority(profile)
+ if any(token in folded for token in (
+ "openwrt", "opnsense", "pfsense", "debian", "ubuntu", "fedora",
+ "centos", "red hat", "windows", "freebsd", "routeros", "proxmox",
+ "synology", "vmware", "esxi", "fortios", "ios xe", "junos",
+ )):
+ score += 20
+ elif "linux" in folded or "bsd" in folded:
+ score += 10
+ if any(ch.isdigit() for ch in value):
+ score += 5
+ return min(score, 95)
+
+ @classmethod
+ def _type_quality(cls, device_type: str, profile: str) -> int:
+ value = (device_type or "").strip()
+ if not value or value == "Hôte":
+ return 0
+ base = {
+ "Hôte Linux": 35,
+ "Serveur SSH": 38,
+ "Appliance Web": 40,
+ "Équipement réseau": 45,
+ "Serveur / appliance": 48,
+ "Serveur Linux": 60,
+ "Poste / serveur Windows": 65,
+ "Imprimante": 80,
+ "Switch": 82,
+ "Point d'accès Wi-Fi": 84,
+ "NAS Synology": 92,
+ "Pare-feu / routeur": 92,
+ "Proxmox Backup Server": 95,
+ "Hyperviseur Proxmox": 95,
+ "Ce poste": 100,
+ }.get(value, 50)
+ return min(base + cls._profile_priority(profile) // 5, 100)
+
+ def _identification_candidates(self, host: Host, *, scope: str = "") -> list[sqlite3.Row]:
+ mac, ip, hostname = candidate_query_values(host)
+ clauses: list[str] = []
+ params: list[str] = []
+ if host.is_local:
+ clauses.append("identity = ?")
+ params.append("local:self")
+ if mac:
+ clauses.append("mac = ?")
+ params.append(mac)
+ if ip:
+ clauses.append("ip = ?")
+ params.append(ip)
+ if hostname:
+ clauses.append("LOWER(hostname) = LOWER(?)")
+ params.append(hostname)
+ if not clauses:
+ return []
+ selector = "(" + " OR ".join(clauses) + ")"
+ if host.is_local:
+ query = "SELECT * FROM endpoint_identification WHERE " + selector + " ORDER BY updated_at DESC"
+ else:
+ query = "SELECT * FROM endpoint_identification WHERE scope = ? AND " + selector + " ORDER BY updated_at DESC"
+ params = [scope] + params
+ with self._connect() as conn:
+ return list(conn.execute(query, params).fetchall())
+
+ def host_identification(self, host: Host, *, shared_mac: bool = False, scope: str = "") -> dict[str, object]:
+ """Retourne une identification uniquement si la corrélation est assez forte.
+
+ L'IP n'est jamais considérée comme une identité. Une IP réattribuée à une
+ autre MAC est explicitement rejetée ; une LAA, une MAC virtuelle ou une MAC
+ partagée exigent des preuves supplémentaires (IP/hostname/services).
+ """
+ best_row: sqlite3.Row | None = None
+ best_match = None
+ for row in self._identification_candidates(host, scope=scope):
+ match = score_identity_match(
+ host, {key: row[key] for key in row.keys()}, shared_mac=shared_mac
+ )
+ if best_match is None or match.score > best_match.score:
+ best_row = row
+ best_match = match
+ if best_row is None or best_match is None or not best_match.safe_to_apply:
+ return {}
+ result = {key: best_row[key] for key in best_row.keys()}
+ result["match_score"] = best_match.score
+ result["match_reason"] = best_match.reason
+ result["identity_kind"] = best_match.identity_kind
+ return result
+
+ def apply_host_identification(self, host: Host, *, shared_mac: bool = False, scope: str = "") -> Host:
+ # Toujours repartir d'un état neutre : si le contexte change (ex. la MAC
+ # devient partagée dans ce scan), une ancienne mémoire ne doit pas rester.
+ 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 = ""
+ remembered = self.host_identification(host, shared_mac=shared_mac, scope=scope)
+ if not remembered:
+ return host
+ host.remembered_os_name = str(remembered.get("os_name") or "")
+ host.remembered_os_accuracy = remembered.get("os_accuracy")
+ host.remembered_device_type = str(remembered.get("device_type") or "")
+ host.remembered_os_source = str(remembered.get("os_source") or "")
+ host.remembered_type_source = str(remembered.get("type_source") or "")
+ host.remembered_os_seen_at = str(remembered.get("os_seen_at") or "")
+ host.remembered_type_seen_at = str(remembered.get("type_seen_at") or "")
+ host.remembered_match_score = int(remembered.get("match_score") or 0)
+ host.remembered_match_reason = str(remembered.get("match_reason") or "")
+ host.remembered_identity_kind = str(remembered.get("identity_kind") or "")
+ return host
+
+ def apply_identifications(self, hosts: list[Host], *, scope: str = "") -> list[Host]:
+ shared = shared_macs(hosts)
+ for host in hosts:
+ self.apply_host_identification(host, shared_mac=normalize_mac(host.mac) in shared, scope=scope)
+ return hosts
+
+ def _identification_row_by_identity(self, identity: str) -> sqlite3.Row | None:
+ with self._connect() as conn:
+ return conn.execute(
+ "SELECT * FROM endpoint_identification WHERE identity = ? LIMIT 1", (identity,)
+ ).fetchone()
+
+ def remember_host_identification(
+ self, host: Host, profile: str, *, observed_at: str = "", shared_mac: bool = False, scope: str = ""
+ ) -> None:
+ """Mémorise le meilleur fingerprint connu pour une identité prudente.
+
+ Les scans légers n'écrasent pas une identification riche. À qualité égale,
+ un nouveau scan Approfondi/Détaillé peut en revanche remplacer une ancienne
+ version/OS : c'est indispensable après une réinstallation ou une mise à jour.
+ """
+ enrich_host(host)
+ now = observed_at or datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+ identity = self._host_identity(host, shared_mac=shared_mac, scope=scope)
+ current = self._identification_row_by_identity(identity)
+ authoritative_refresh = profile in {"Approfondi", "Détaillé"}
+
+ # Une même MAC peut être clonée/spoofée ou réutilisée. Avant d'écraser une
+ # identité ``mac:...`` existante après un changement d'IP, on vérifie que
+ # la corrélation historique est suffisamment forte. Sinon on scinde la
+ # nouvelle observation en ``macip:...@IP`` afin de préserver les deux
+ # endpoints au lieu de corrompre silencieusement l'ancien fingerprint.
+ if current is not None and not host.is_local and normalize_mac(host.mac):
+ same_ip = host.ip == str(current["ip"] or "")
+ same_mac = normalize_mac(host.mac) == normalize_mac(str(current["mac"] or ""))
+ if not (authoritative_refresh and same_ip and same_mac):
+ match = score_identity_match(
+ host, {key: current[key] for key in current.keys()}, shared_mac=shared_mac
+ )
+ if not match.safe_to_apply:
+ base = f"macip:{normalize_mac(host.mac)}@{host.ip}"
+ identity = base if not scope else f"{scope}::{base}"
+ current = self._identification_row_by_identity(identity)
+
+ current_os = str(current["os_name"] or "") if current is not None else ""
+ current_type = str(current["device_type"] or "") if current is not None else ""
+ current_os_score = int(current["os_score"] or 0) if current is not None else 0
+ current_type_score = int(current["type_score"] or 0) if current is not None else 0
+
+ candidate_os_score = self._os_quality(host.os_name, profile, host.os_accuracy)
+ candidate_type_score = self._type_quality(host.device_type, profile)
+
+ best_os = current_os
+ best_os_accuracy = current["os_accuracy"] if current is not None else None
+ best_os_score = current_os_score
+ best_os_source = str(current["os_source"] or "") if current is not None else ""
+ best_os_seen = str(current["os_seen_at"] or "") if current is not None else ""
+ # Seuls les profils qui réalisent réellement un fingerprint OS peuvent
+ # créer/rafraîchir ``os_seen_at``. Un Standard peut actualiser l'observation
+ # réseau de l'endpoint, mais ne rajeunit jamais l'OS mémorisé.
+ if authoritative_refresh and host.os_name and (
+ not current_os
+ or candidate_os_score > current_os_score
+ or candidate_os_score >= current_os_score - 5
+ ):
+ best_os = host.os_name
+ best_os_accuracy = host.os_accuracy
+ best_os_score = candidate_os_score
+ best_os_source = profile
+ best_os_seen = now
+
+ best_type = current_type
+ best_type_score = current_type_score
+ best_type_source = str(current["type_source"] or "") if current is not None else ""
+ best_type_seen = str(current["type_seen_at"] or "") if current is not None else ""
+ if host.device_type and (
+ not current_type
+ or candidate_type_score > current_type_score
+ or (authoritative_refresh and candidate_type_score >= current_type_score - 5)
+ ):
+ best_type = host.device_type
+ best_type_score = candidate_type_score
+ best_type_source = profile
+ best_type_seen = now
+
+ if not best_os and not best_type:
+ return
+
+ mac = normalize_mac(host.mac)
+ with self._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO endpoint_identification(
+ identity, scope, mac, mac_kind, ip, hostname, ports_json,
+ os_name, os_accuracy, os_score, os_source, os_seen_at,
+ device_type, type_score, type_source, type_seen_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(identity) DO UPDATE SET
+ scope=excluded.scope, mac=excluded.mac, mac_kind=excluded.mac_kind, ip=excluded.ip,
+ hostname=excluded.hostname, ports_json=excluded.ports_json,
+ os_name=excluded.os_name, os_accuracy=excluded.os_accuracy,
+ os_score=excluded.os_score, os_source=excluded.os_source,
+ os_seen_at=excluded.os_seen_at, device_type=excluded.device_type,
+ type_score=excluded.type_score, type_source=excluded.type_source,
+ type_seen_at=excluded.type_seen_at, updated_at=excluded.updated_at
+ """,
+ (
+ identity, "" if host.is_local else scope, mac, mac_identity_kind(mac), host.ip, host.hostname,
+ port_fingerprint_json(host), best_os, best_os_accuracy, best_os_score,
+ best_os_source, best_os_seen, best_type, best_type_score,
+ best_type_source, best_type_seen, now,
+ ),
+ )
+
+ def remember_identifications(self, hosts: list[Host], profile: str, *, observed_at: str = "", scope: str = "") -> None:
+ shared = shared_macs(hosts)
+ for host in hosts:
+ if host.status != "down":
+ self.remember_host_identification(
+ host, profile, observed_at=observed_at,
+ shared_mac=normalize_mac(host.mac) in shared, scope=scope,
+ )
+
+ def forget_identification_for_host(
+ self, host: Host, *, shared_mac: bool = False, scope: str = ""
+ ) -> int:
+ """Oublie uniquement le fingerprint associé à l'endpoint sélectionné."""
+ remembered = self.host_identification(host, shared_mac=shared_mac, scope=scope)
+ identities: list[str] = []
+ if remembered.get("identity"):
+ identities.append(str(remembered["identity"]))
+ exact = self._host_identity(host, shared_mac=shared_mac, scope=scope)
+ if exact not in identities:
+ identities.append(exact)
+ # Un endpoint peut avoir été scindé en macip lors d'une ambiguïté antérieure.
+ mac = normalize_mac(host.mac)
+ if mac and host.ip:
+ base = f"macip:{mac}@{host.ip}"
+ macip = base if not scope else f"{scope}::{base}"
+ if macip not in identities:
+ identities.append(macip)
+ if not identities:
+ return 0
+ placeholders = ",".join("?" for _ in identities)
+ with self._connect() as conn:
+ count = int(conn.execute(
+ f"SELECT COUNT(*) FROM endpoint_identification WHERE identity IN ({placeholders})", identities
+ ).fetchone()[0])
+ conn.execute(
+ f"DELETE FROM endpoint_identification WHERE identity IN ({placeholders})", identities
+ )
+ return count
+
+ def forget_identifications_for_scope(self, scope: str) -> int:
+ """Oublie les fingerprints du réseau courant, sans toucher au poste local."""
+ if not scope:
+ return 0
+ with self._connect() as conn:
+ count = int(conn.execute(
+ "SELECT COUNT(*) FROM endpoint_identification WHERE scope = ?", (scope,)
+ ).fetchone()[0])
+ conn.execute("DELETE FROM endpoint_identification WHERE scope = ?", (scope,))
+ return count
+
+ def forget_all_identifications(self) -> int:
+ """Oublie tous les fingerprints, sans supprimer scans, favoris, groupes ou notes."""
+ with self._connect() as conn:
+ count = int(conn.execute("SELECT COUNT(*) FROM endpoint_identification").fetchone()[0])
+ conn.execute("DELETE FROM endpoint_identification")
+ # Ancienne table pré-0.4.9 : la vider aussi évite qu'une migration future
+ # ne ressuscite une identification que l'utilisateur pensait oubliée.
+ conn.execute("DELETE FROM host_identification")
+ return count
+
+ def known_groups(self) -> list[str]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ "SELECT DISTINCT group_name FROM host_metadata WHERE group_name <> '' ORDER BY group_name COLLATE NOCASE"
+ ).fetchall()
+ return [str(row[0]) for row in rows]
+ def online_vendor_cache(self, mac: str, provider: str, *, max_age_days: int = 30) -> dict[str, object] | None:
+ from .online_vendor import normalize_mac
+
+ normalized = normalize_mac(mac)
+ if not normalized:
+ return None
+ with self._connect() as conn:
+ row = conn.execute(
+ "SELECT * FROM online_vendor_cache WHERE mac = ? AND provider = ?",
+ (normalized, provider),
+ ).fetchone()
+ if row is None:
+ return None
+ try:
+ checked = datetime.fromisoformat(row["checked_at"])
+ now = datetime.now(timezone.utc).astimezone()
+ if checked.tzinfo is None:
+ checked = checked.replace(tzinfo=now.tzinfo)
+ if (now - checked.astimezone(now.tzinfo)).total_seconds() > max_age_days * 86400:
+ return None
+ except (TypeError, ValueError):
+ return None
+ return {
+ "mac": row["mac"],
+ "provider": row["provider"],
+ "vendor": row["vendor"] or "",
+ "found": bool(row["found"]),
+ "block_type": row["block_type"] or "",
+ "is_randomized": bool(row["is_randomized"]),
+ "is_private": bool(row["is_private"]),
+ "checked_at": row["checked_at"],
+ "from_cache": True,
+ }
+
+ def save_online_vendor_cache(
+ self,
+ mac: str,
+ provider: str,
+ *,
+ vendor: str = "",
+ found: bool = False,
+ block_type: str = "",
+ is_randomized: bool = False,
+ is_private: bool = False,
+ checked_at: str = "",
+ ) -> None:
+ from .online_vendor import normalize_mac
+
+ normalized = normalize_mac(mac)
+ if not normalized:
+ return
+ timestamp = checked_at or datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+ with self._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO online_vendor_cache(
+ mac, provider, vendor, found, block_type, is_randomized, is_private, checked_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(mac, provider) DO UPDATE SET
+ vendor=excluded.vendor, found=excluded.found, block_type=excluded.block_type,
+ is_randomized=excluded.is_randomized, is_private=excluded.is_private,
+ checked_at=excluded.checked_at
+ """,
+ (
+ normalized, provider, vendor.strip(), int(bool(found)), block_type.strip(),
+ int(bool(is_randomized)), int(bool(is_private)), timestamp,
+ ),
+ )
+
diff --git a/src/librenet_scanner/ui.py b/src/librenet_scanner/ui.py
index 61cc092..d931bf3 100644
--- a/src/librenet_scanner/ui.py
+++ b/src/librenet_scanner/ui.py
@@ -1,20 +1,25 @@
from __future__ import annotations
import ipaddress
-import os
import shutil
import subprocess
-from pathlib import Path
+import time
+from datetime import datetime
-from PySide6.QtCore import Qt, QUrl
+from PySide6.QtCore import Qt, QThread, QUrl, Signal, QSize, QSettings, QTimer
from PySide6.QtGui import QAction, QColor, QDesktopServices, QIcon
from PySide6.QtWidgets import (
- QAbstractItemView,
QApplication,
+ QButtonGroup,
+ QCheckBox,
QComboBox,
QDialog,
+ QDialogButtonBox,
QFileDialog,
QFormLayout,
+ QFrame,
+ QGroupBox,
+ QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
@@ -22,46 +27,230 @@ from PySide6.QtWidgets import (
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 .models import Host
-from .network import NetworkInterface, list_ipv4_interfaces, validate_target
-from .scanner import HostScanWorker, ScanRequest, ScanWorker
+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
-COL_STATUS = 0
-COL_HOSTNAME = 1
-COL_IP = 2
-COL_MAC = 3
-COL_VENDOR = 4
-COL_PORTS = 5
-COL_OS = 6
-COL_LATENCY = 7
-COL_LAST = 8
+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(780, 420)
+ 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"])
- table.setEditTriggers(QAbstractItemView.NoEditTriggers)
- table.setSelectionBehavior(QAbstractItemView.SelectRows)
rows = store.recent_scans()
table.setRowCount(len(rows))
for row_idx, row in enumerate(rows):
- values = [row["created_at"], row["target"], row["profile"], str(row["host_count"]), str(row["id"])]
+ 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)
@@ -72,280 +261,1910 @@ class HistoryDialog(QDialog):
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
- self.setWindowTitle("LibreNet Scanner 0.1.0")
- self.resize(1220, 700)
+ 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)
- top = QHBoxLayout()
- self.interface_combo = QComboBox()
- self.interface_combo.setMinimumWidth(350)
- self.interface_combo.currentIndexChanged.connect(self._interface_changed)
+ 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.setPlaceholderText("192.168.1.0/24")
- self.target_edit.setMinimumWidth(180)
- self.profile_combo = QComboBox()
- self.profile_combo.addItems(["Rapide", "Standard", "Approfondi"])
- self.scan_btn = QPushButton("▶ Scanner")
- self.scan_btn.clicked.connect(self.start_scan)
- self.stop_btn = QPushButton("■ Stop")
+ 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)
- top.addWidget(QLabel("Interface :"))
- top.addWidget(self.interface_combo, 2)
- top.addWidget(QLabel("Réseau / cible :"))
- top.addWidget(self.target_edit, 1)
- top.addWidget(QLabel("Profil :"))
- top.addWidget(self.profile_combo)
- top.addWidget(self.scan_btn)
- top.addWidget(self.stop_btn)
- outer.addLayout(top)
+ 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)
- self.table = QTableWidget(0, 9)
- self.table.setHorizontalHeaderLabels([
- "État", "Nom", "IP", "MAC", "Constructeur", "Ports / services", "OS", "Latence", "Dernière vue"
- ])
- self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
- self.table.setSelectionMode(QAbstractItemView.SingleSelection)
- self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
- self.table.setSortingEnabled(True)
- self.table.setContextMenuPolicy(Qt.CustomContextMenu)
- self.table.customContextMenuRequested.connect(self._context_menu)
- self.table.itemDoubleClicked.connect(lambda _item: self.scan_selected_host())
- header = self.table.horizontalHeader()
- header.setSectionResizeMode(QHeaderView.Interactive)
- header.setStretchLastSection(True)
- self.table.setColumnWidth(COL_STATUS, 60)
- self.table.setColumnWidth(COL_HOSTNAME, 170)
- self.table.setColumnWidth(COL_IP, 125)
- self.table.setColumnWidth(COL_MAC, 150)
- self.table.setColumnWidth(COL_VENDOR, 190)
- self.table.setColumnWidth(COL_PORTS, 280)
- outer.addWidget(self.table, 1)
+ 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()
- self.summary_label = QLabel("0 hôte")
+ 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("Exporter CSV…", self)
+ 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("Exporter JSON…", self)
+ 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("Quitter", self)
+ quit_action = QAction(themed_icon("application-exit"), "Quitter", self)
quit_action.triggered.connect(self.close)
- file_menu.addAction(export_csv_action)
- file_menu.addAction(export_json_action)
+ file_menu.addActions([export_csv_action, export_json_action])
file_menu.addSeparator()
file_menu.addAction(quit_action)
scan_menu = self.menuBar().addMenu("Scan")
- refresh_action = QAction("Rafraîchir les interfaces", self)
- refresh_action.triggered.connect(self.refresh_interfaces)
- history_action = QAction("Historique…", self)
- history_action.triggered.connect(self.show_history)
- clear_action = QAction("Vider les résultats", self)
- clear_action.triggered.connect(self.clear_results)
- scan_menu.addAction(refresh_action)
- scan_menu.addAction(history_action)
- scan_menu.addAction(clear_action)
+ 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")
- about_action = QAction("À propos", self)
- about_action.triggered.connect(self.show_about)
- help_menu.addAction(about_action)
+ 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()
- visible = [i for i in self.interfaces if not i.is_virtual]
- if not visible:
- visible = self.interfaces
- for iface in visible:
- self.interface_combo.addItem(iface.label, iface)
+ 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(0)
- self._interface_changed(0)
+ 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)", 4000)
+ 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):
- self.target_edit.setText(iface.network)
+ 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
- def start_scan(self) -> 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
- # Protection simple contre un scan accidentel gigantesque.
- if "/" in target:
- net = ipaddress.ip_network(target, strict=False)
- if net.num_addresses > 4096:
- QMessageBox.warning(self, "Réseau trop grand", "La V0.1 limite un scan à 4096 adresses (jusqu'à /20 en IPv4).")
+ 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
- profile = self.profile_combo.currentText()
+ 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.table.setSortingEnabled(False)
- self.table.setRowCount(0)
- self.table.setSortingEnabled(True)
- request = ScanRequest(target=target, profile=profile, interface=self.selected_interface())
+ 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.activity_label.setText("Démarrage…")
+ 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()
- self.activity_label.setText("Arrêt demandé…")
+ 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)
- self.statusBar().showMessage(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
- self._refresh_table()
-
- def _refresh_table(self) -> None:
- selected_ip = self._selected_ip()
- self.table.setSortingEnabled(False)
- self.table.setRowCount(0)
- for host in sorted(self.hosts.values(), key=lambda h: ipaddress.ip_address(h.ip)):
- row = self.table.rowCount()
- self.table.insertRow(row)
- status = QTableWidgetItem("●" if host.status == "up" else "○")
- status.setTextAlignment(Qt.AlignCenter)
- if host.status == "up":
- status.setForeground(QColor("#2e7d32"))
- self.table.setItem(row, COL_STATUS, status)
- self.table.setItem(row, COL_HOSTNAME, QTableWidgetItem(host.hostname))
- ip_item = QTableWidgetItem(host.ip)
- ip_item.setData(Qt.UserRole, host.ip)
- self.table.setItem(row, COL_IP, ip_item)
- self.table.setItem(row, COL_MAC, QTableWidgetItem(host.mac))
- self.table.setItem(row, COL_VENDOR, QTableWidgetItem(host.vendor))
- self.table.setItem(row, COL_PORTS, QTableWidgetItem(host.ports_summary))
- self.table.setItem(row, COL_OS, QTableWidgetItem(host.os_name))
- latency = "" if host.latency_ms is None else f"{host.latency_ms:.1f} ms"
- self.table.setItem(row, COL_LATENCY, QTableWidgetItem(latency))
- self.table.setItem(row, COL_LAST, QTableWidgetItem(host.last_seen))
- if selected_ip == host.ip:
- self.table.selectRow(row)
- self.table.setSortingEnabled(True)
- self.summary_label.setText(f"{len(self.hosts)} hôte(s) actif(s)")
+ # 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
- self.activity_label.setText("Scan terminé")
- if self.hosts:
- try:
- self.store.save_scan(target, profile, list(self.hosts.values()))
- except OSError as exc:
- self.statusBar().showMessage(f"Historique non enregistré : {exc}", 5000)
+ 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()
- def _selected_ip(self) -> str | None:
- row = self.table.currentRow()
- if row < 0:
+ # ---------- 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
- item = self.table.item(row, COL_IP)
- return item.text() if item else None
+ ip = item.data(0, ROLE_IP)
+ return self.hosts.get(str(ip)) if ip else None
def _selected_host(self) -> Host | None:
- ip = self._selected_ip()
- return self.hosts.get(ip) if ip else None
+ return self._host_from_item(self.tree.currentItem())
- def _context_menu(self, pos) -> None:
- item = self.table.itemAt(pos)
- if item is None:
- return
- self.table.selectRow(item.row())
+ 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
- menu = QMenu(self)
- scan = menu.addAction("Scanner les ports (1000 principaux)")
- menu.addSeparator()
- http = menu.addAction("Ouvrir HTTP")
- https = menu.addAction("Ouvrir HTTPS")
- ssh = menu.addAction("Ouvrir SSH dans Konsole")
- ping = menu.addAction("Ping dans Konsole")
- smb = menu.addAction("Ouvrir SMB dans Dolphin")
- rdp = menu.addAction("Ouvrir RDP avec Remmina")
- rdp.setEnabled(shutil.which("remmina") is not None)
- menu.addSeparator()
- copy_ip = menu.addAction("Copier l'adresse IP")
- copy_mac = menu.addAction("Copier l'adresse MAC")
- copy_mac.setEnabled(bool(host.mac))
+ 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)
- chosen = menu.exec(self.table.viewport().mapToGlobal(pos))
- if chosen == scan:
- self.scan_selected_host()
- elif chosen == http:
- QDesktopServices.openUrl(QUrl(f"http://{host.ip}"))
- elif chosen == https:
- QDesktopServices.openUrl(QUrl(f"https://{host.ip}"))
+ 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 == ping:
- self._run_terminal(["ping", host.ip])
elif chosen == smb:
QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/"))
elif chosen == rdp:
- subprocess.Popen(["remmina", "-c", f"rdp://{host.ip}"])
+ 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:
@@ -361,27 +2180,90 @@ class MainWindow(QMainWindow):
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:
- return
+ 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)
+ 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(lambda: self.activity_label.setText("Scan détaillé terminé"))
+ 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:
- self.hosts[result.ip] = result
- self._refresh_table()
+ 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.")
@@ -404,22 +2286,157 @@ class MainWindow(QMainWindow):
HistoryDialog(self.store, self).exec()
def clear_results(self) -> None:
+ """Efface seulement la vue courante, jamais les données persistantes."""
self.hosts.clear()
- self.table.setRowCount(0)
- self.summary_label.setText("0 hôte")
+ 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}