diff --git a/app/app_info.py b/app/app_info.py
index 957782d..54cf4d6 100644
--- a/app/app_info.py
+++ b/app/app_info.py
@@ -118,6 +118,264 @@ TECH_STACK = [
]
+# ── Mode d'emploi ────────────────────────────────────────────────────────
+HOWTO_FIRST_INTRO = (
+ "Huit étapes entre une installation nue et un tunnel qui monte. "
+ "L'ordre compte : le serveur ne peut pas accepter le poste tant qu'il "
+ "n'a pas reçu sa clé publique."
+)
+HOWTO_FIRST = [
+ "Ouvrir ⚙️ Administrateur depuis la fenêtre principale.",
+ "Onglet WireGuard → Tunnel & profils : renseigner l'adresse du "
+ "serveur, le port UDP et la clé publique du serveur.",
+ "Onglet WireGuard → Clés : cliquer sur Générer une nouvelle "
+ "paire de clés, puis transmettre la clé publique affichée à "
+ "l'administrateur du serveur — sans elle, le serveur refusera la "
+ "connexion.",
+ "Retour sur Tunnel & profils : saisir l'adresse IP du client "
+ "et les IPs autorisées fournies par l'administrateur.",
+ "Cocher Le serveur utilise une clé pré-partagée uniquement si le "
+ "serveur en déclare une pour ce poste.",
+ "Toujours sur Tunnel & profils, renseigner l'Identifiant "
+ "VPN : le nom du compte tel qu'il figure sur le serveur. Laissé "
+ "vide, aucun code d'accès n'est demandé à la connexion — le serveur est "
+ "alors traité comme permissif.",
+ "Cliquer sur Enregistrer et fermer.",
+ "Se connecter depuis la fenêtre principale ou le menu du systray, puis "
+ "saisir le code d'accès à 6 chiffres quand il est demandé.",
+]
+
+HOWTO_DAILY = [
+ (
+ "Changer de serveur",
+ "Onglet WireGuard → Tunnel & profils : sélectionner un "
+ "profil puis ✅ Charger.",
+ ),
+ (
+ "Reprendre une configuration reçue",
+ "Même onglet, 📂 Importer un .conf : clés, adresses et clé "
+ "pré-partagée sont repris automatiquement. Les champs issus de "
+ "l'import se verrouillent, pour ne pas diverger en silence du pair "
+ "déjà déclaré côté serveur.",
+ ),
+ (
+ "Configurer un téléphone",
+ "📱 QR Code, à scanner depuis l'application WireGuard mobile.",
+ ),
+]
+
+HOWTO_TROUBLE = [
+ (
+ "Commencer toujours par le diagnostic",
+ "Onglet Test connexion → Chaîne de connexion, puis 🚀 Lancer "
+ "le test de connexion. Le rapport nomme l'étape qui bloque au "
+ "lieu de laisser deviner.",
+ ),
+ (
+ "Le tunnel monte mais rien ne circule",
+ "Vérifier que les IPs autorisées ne contiennent pas le réseau "
+ "local par lequel le serveur est joint : la passerelle serait routée "
+ "dans le tunnel, qui a justement besoin d'elle.",
+ ),
+ (
+ "Plus aucun nom ne se résout après une coupure",
+ "Onglet Test connexion → Fuite DNS, puis 🛠️ Réparer le DNS "
+ "(tunnel arrêté).",
+ ),
+ (
+ "Une fenêtre d'authentification à chaque connexion",
+ "Onglet Sécurité, sous-onglet Droits sudo sous Linux ou "
+ "Élévation sous Windows : il nomme ce qui manque et comment "
+ "l'accorder.",
+ ),
+ (
+ "Le code d'accès est refusé",
+ "Le code est validé par le serveur, pas par ce poste : un refus vient "
+ "du serveur et son message est repris tel quel dans le journal.",
+ ),
+]
+
+# ── Prérequis ────────────────────────────────────────────────────────────
+REQUIREMENTS = [
+ ("🐧 Linux — paquets", [
+ (
+ "wireguard-tools (indispensable)",
+ "Fournit wg et wg-quick, qui montent et "
+ "démontent le tunnel.
"
+ "sudo apt install wireguard-tools — Debian, Ubuntu"
+ "
sudo dnf install wireguard-tools — Fedora",
+ ),
+ (
+ "iproute2 (indispensable)",
+ "ip sert à supprimer une interface restée en place "
+ "après un arrêt brutal.",
+ ),
+ (
+ "coreutils (indispensable)",
+ "install dépose la configuration dans "
+ "/etc/wireguard avec le bon propriétaire et les bonnes "
+ "permissions, en une seule commande privilégiée.",
+ ),
+ (
+ "resolvconf ou systemd-resolved (optionnel)",
+ "Nécessaire pour que la directive DNS du tunnel soit appliquée. "
+ "En leur absence, le tunnel monte sans toucher au DNS du système "
+ "plutôt que d'échouer à mi-course.",
+ ),
+ ]),
+ ("🪟 Windows", [
+ (
+ "WireGuard for Windows (indispensable)",
+ "wireguard.exe installe et retire le service du "
+ "tunnel. L'installeur officiel est embarqué dans le programme "
+ "d'installation de WGSecure, qui le lance si WireGuard est absent "
+ "du poste.",
+ ),
+ (
+ "Droits administrateur",
+ "Exigés pour installer ou retirer le service. Le service n'est "
+ "installé qu'une fois : les démarrages suivants n'ouvrent plus "
+ "de fenêtre UAC.",
+ ),
+ ]),
+ ("🔌 Ports réseau", [
+ (
+ "UDP 51820 — sortant, poste client",
+ "Port par défaut du serveur WireGuard ; ajustable dans l'onglet "
+ "Tunnel & profils. C'est le seul flux que l'application émet "
+ "vers le serveur.",
+ ),
+ (
+ "UDP 51820 — entrant, côté serveur",
+ "Le serveur doit écouter dessus, et le routeur doit y rediriger "
+ "le trafic venant d'Internet. Sans cette redirection, le "
+ "handshake n'aboutit jamais et rien ne le signale : WireGuard "
+ "ignore silencieusement les paquets qu'il ne peut pas "
+ "authentifier.",
+ ),
+ (
+ "Filtrage sur le port source",
+ "Un pare-feu ne doit pas exiger un port source particulier : un "
+ "client WireGuard émet depuis un port éphémère aléatoire. Le "
+ "diagnostic détecte ce cas.",
+ ),
+ (
+ "UDP/TCP 53 — DNS",
+ "Résolution du nom du serveur avant connexion, puis résolutions "
+ "dans le tunnel une fois monté.",
+ ),
+ ]),
+ ("🔑 Privilèges", [
+ (
+ "Linux — root, sudo ou pkexec",
+ "Demandés commande par commande, au moment du besoin. Une règle "
+ "sudo évite la fenêtre d'authentification répétée ; le sous-onglet "
+ "Sécurité → Droits sudo indique lesquelles manquent.",
+ ),
+ (
+ "Windows — administrateur",
+ "Pour l'installation et le retrait du service du tunnel.",
+ ),
+ ]),
+ ("🐍 Depuis les sources", [
+ (
+ "Python 3.11 ou supérieur",
+ "Dépendances : PyQt6, cryptography, qrcode, Pillow, bcrypt. Les "
+ "binaires distribués les embarquent : rien à installer.",
+ ),
+ ]),
+]
+
+# ── Sécurité ─────────────────────────────────────────────────────────────
+SECURITY = [
+ ("Authentification", [
+ (
+ "Mot de passe administrateur haché avec bcrypt, coût 12",
+ "Le mot de passe n'est jamais stocké, même chiffré : seule son "
+ "empreinte l'est, avec un sel propre à chaque enregistrement.",
+ ),
+ (
+ "Vérification résistante à l'analyse temporelle",
+ "bcrypt.checkpw compare l'empreinte sans laisser sa "
+ "durée trahir le nombre de caractères corrects. Les anciennes "
+ "empreintes SHA-256, encore acceptées le temps d'une migration, "
+ "passent par secrets.compare_digest pour la même "
+ "raison.",
+ ),
+ (
+ "Code d'accès validé par le serveur",
+ "Le code à 6 chiffres part au serveur, seul détenteur du secret "
+ "et seul point où l'accès est réellement ouvert. L'ancien "
+ "vérificateur local ne verrouillait que sa propre fenêtre : le "
+ "secret vivait sur le poste, et le contourner suffisait. Un "
+ "secret hérité d'une version ≤ 0.8.0 est effacé au démarrage.",
+ ),
+ ]),
+ ("Cryptographie", [
+ (
+ "Clés Curve25519 générées par la bibliothèque cryptography",
+ "Échange X25519, l'algorithme même de WireGuard. L'application ne "
+ "dépend pas du binaire wg pour produire ses clés.",
+ ),
+ (
+ "Aléa fourni par le système d'exploitation",
+ "Clés pré-partagées et index de session tirés de "
+ "os.urandom, jamais d'un générateur pseudo-aléatoire "
+ "ordinaire.",
+ ),
+ (
+ "Clé pré-partagée de 32 octets",
+ "Longueur imposée par WireGuard ; le format est vérifié avant "
+ "d'être accepté.",
+ ),
+ ]),
+ ("Protection des secrets au repos", [
+ (
+ "Configuration du tunnel en 0600",
+ "Le fichier contenant la clé privée n'est lisible que par son "
+ "propriétaire, y compris le fichier temporaire employé pour "
+ "l'installer dans /etc/wireguard — créé directement en 0600, "
+ "jamais lisible par d'autres, même brièvement.",
+ ),
+ (
+ "Clés privées masquées à l'écran",
+ "Affichées en points, révélées seulement sur demande explicite.",
+ ),
+ (
+ "Export .conf également restreint",
+ "Un fichier exporté reçoit les mêmes permissions que la "
+ "configuration d'origine.",
+ ),
+ ]),
+ ("Réduction de la surface d'attaque", [
+ (
+ "Privilèges demandés au coup par coup",
+ "L'application ne tourne pas en permanence avec les droits "
+ "d'administration : chaque commande privilégiée est élevée "
+ "séparément, par sudo puis pkexec.",
+ ),
+ (
+ "Aucun appel passé par un interpréteur de commandes",
+ "Les commandes système sont invoquées sous forme de liste "
+ "d'arguments, ce qui exclut l'injection par le contenu d'un champ "
+ "de configuration.",
+ ),
+ (
+ "Sonde de handshake sans état",
+ "Le test de connexion négocie une poignée de main puis jette "
+ "immédiatement la session : il ne monte aucun tunnel et ne "
+ "conserve aucun secret dérivé.",
+ ),
+ (
+ "Nettoyage après un arrêt brutal",
+ "Interface orpheline et configuration DNS résiduelle sont "
+ "retirées, pour ne pas laisser le système dans un état "
+ "intermédiaire.",
+ ),
+ ]),
+]
+
+
def parse_changelog() -> list[dict]:
"""Lit CHANGELOG.md : titres `## [x.y.z] — date`, sous-titres `### ...`
et puces `- ...` (avec leurs lignes de continuation) uniquement — le
diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py
index d3d22c9..fa2d4bb 100644
--- a/app/ui/admin_window.py
+++ b/app/ui/admin_window.py
@@ -958,7 +958,7 @@ class AdminWindow(QDialog):
# Onglet À propos
# ------------------------------------------------------------------ #
def _tab_about(self) -> QWidget:
- """Page « À propos », en cinq sous-onglets (modèle QuiOulé/TuxMarket).
+ """Page « À propos », en huit sous-onglets (modèle QuiOulé/TuxMarket).
Le contenu (fonctions, garanties, pile technique, auteur) vit dans
`app/app_info.py`, séparé de sa présentation ici — le rendre
@@ -974,11 +974,14 @@ class AdminWindow(QDialog):
sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS)
- sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions")
- sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties")
- sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique")
- sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal")
- sub.addTab(self._subtab_about_author(app_info), "👤 Auteur")
+ sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions")
+ sub.addTab(self._subtab_about_howto(app_info), "📖 Mode d'emploi")
+ sub.addTab(self._subtab_about_requirements(app_info), "📦 Prérequis")
+ sub.addTab(self._subtab_about_security(app_info), "🔐 Sécurité")
+ sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties")
+ sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique")
+ sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal")
+ sub.addTab(self._subtab_about_author(app_info), "👤 Auteur")
lay.addWidget(sub)
return w
@@ -1105,6 +1108,29 @@ class AdminWindow(QDialog):
area.setWidget(inner)
return area
+ def _about_group_label(self, text: str) -> QLabel:
+ """Intitulé séparant deux groupes de cartes dans une même liste.
+
+ Prérequis et Sécurité alignent une vingtaine de cartes chacun : sans
+ rien pour marquer où finit « Linux » et où commence « Windows », la
+ liste se lit comme un seul bloc indifférencié.
+ """
+ lbl = QLabel(text)
+ lbl.setWordWrap(True)
+ lbl.setStyleSheet(
+ f"color: {theme.ACCENT_LIGHT}; font-size: 13px; font-weight: bold;"
+ " background: transparent; margin-top: 6px;"
+ )
+ return lbl
+
+ def _about_grouped(self, groups: list[tuple[str, list[tuple[str, str]]]]) -> QWidget:
+ """Liste de cartes découpée en groupes titrés."""
+ items: list[QWidget] = []
+ for title, entries in groups:
+ items.append(self._about_group_label(title))
+ items += [self._about_card(body, title=head) for head, body in entries]
+ return self._about_card_list(items)
+
# ── Sous-onglet : fonctions ───────────────────────────────────────────
def _subtab_about_features(self, info) -> QWidget:
page, v = self._test_page()
@@ -1112,6 +1138,33 @@ class AdminWindow(QDialog):
v.addWidget(self._about_card_list(cards))
return page
+ # ── Sous-onglet : mode d'emploi ───────────────────────────────────────
+ def _subtab_about_howto(self, info) -> QWidget:
+ page, v = self._test_page()
+ v.addWidget(self._hint(info.HOWTO_FIRST_INTRO))
+ items: list[QWidget] = [
+ self._about_card(step, icon=f"{i}\u20e3")
+ for i, step in enumerate(info.HOWTO_FIRST, 1)
+ ]
+ items.append(self._about_group_label("Au quotidien"))
+ items += [self._about_card(body, title=head) for head, body in info.HOWTO_DAILY]
+ items.append(self._about_group_label("Quand la connexion échoue"))
+ items += [self._about_card(body, title=head) for head, body in info.HOWTO_TROUBLE]
+ v.addWidget(self._about_card_list(items))
+ return page
+
+ # ── Sous-onglet : prérequis ───────────────────────────────────────────
+ def _subtab_about_requirements(self, info) -> QWidget:
+ page, v = self._test_page()
+ v.addWidget(self._about_grouped(info.REQUIREMENTS))
+ return page
+
+ # ── Sous-onglet : sécurité ────────────────────────────────────────────
+ def _subtab_about_security(self, info) -> QWidget:
+ page, v = self._test_page()
+ v.addWidget(self._about_grouped(info.SECURITY))
+ return page
+
# ── Sous-onglet : garanties ───────────────────────────────────────────
def _subtab_about_guarantees(self, info) -> QWidget:
page, v = self._test_page()