From a59c0b370ba27cad48293c5a4a34ebba980bf8dd Mon Sep 17 00:00:00 2001 From: hcornet Date: Sat, 12 Sep 2026 20:51:10 +0200 Subject: [PATCH] =?UTF-8?q?1.0.6=20=E2=80=94=20auto-controle,=20avancement?= =?UTF-8?q?=20et=20battement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 ++++++++ VERSION | 2 +- backend/pyproject.toml | 2 +- backend/src/limier/__init__.py | 2 +- backend/src/limier/entrypoints/worker.py | 7 ++- backend/src/limier/jobs/tasks.py | 75 ++++++++++++++++++++--- deploy/.env.example | 2 +- deploy/docker-compose.yml | 17 +++-- deploy/essai/LISEZMOI.md | 6 +- frontend/package.json | 2 +- frontend/src/components/CarteResultat.tsx | 6 ++ frontend/src/pages/PageHistorique.tsx | 74 +++++++++++++++++++--- 12 files changed, 189 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9314b2..148eefa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ Format inspiré de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). +## [1.0.6] — 2026-09-12 + +### Corrigé +- **L'auto-contrôle nocturne échouait systématiquement.** Le délai global des + travaux (600 s) s'appliquait aussi à lui, alors qu'il teste 3 000 sites avec + deux pseudonymes chacun — environ 6 000 requêtes. Il mourait sur + `TimeoutError` sans jamais enregistrer la base corrigée. Délai porté à 4 h + pour ce seul travail. +- **L'auto-contrôle faussait son propre verdict.** À 10 requêtes simultanées, + les résolveurs publics saturaient (`Timeout while contacting DNS servers`) et + des sites parfaitement sains étaient désactivés. Concurrence réduite à 4. +- **Résolveurs DNS publics sur le worker.** Le DNS du homelab renvoyait + linkedin.com, vk.com, pinterest.com et tumblr.com en `0.0.0.0` : ces + plateformes, parmi les mieux classées, n'étaient jamais interrogées. Le taux + de vérifications en échec passe de 40 % à 26 %. +- Champs de profil encore affichés en anglais (`favourites_count`, `image_bg`, + `heart_count`…) désormais traduits. + +### Ajouté +- **Avancement par ligne dans l'historique**, avec barre de progression et + actualisation automatique toutes les trois secondes tant qu'une recherche est + en attente ou en cours. L'actualisation s'arrête d'elle-même ensuite. +- **Battement journalisé** pendant les travaux longs : `recherche_en_cours` + toutes les 30 s et `auto_controle_en_cours` toutes les minutes, avec le + temps écoulé. Sans eux, un travail d'une heure ne produisait aucune ligne et + devenait indiscernable d'un worker figé. + ## [1.0.5] — 2026-09-12 ### Corrigé diff --git a/VERSION b/VERSION index 90a27f9..af0b7dd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.5 +1.0.6 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b058243..171ddd6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "limier" -version = "1.0.5" +version = "1.0.6" description = "Plateforme OSINT auto-hébergée : pseudonyme, nom, e-mail, domaine" requires-python = ">=3.12,<3.14" license = { text = "MIT" } diff --git a/backend/src/limier/__init__.py b/backend/src/limier/__init__.py index d399aff..b541fdb 100644 --- a/backend/src/limier/__init__.py +++ b/backend/src/limier/__init__.py @@ -1,3 +1,3 @@ """Limier — plateforme OSINT auto-hébergée.""" -__version__ = "1.0.5" +__version__ = "1.0.6" diff --git a/backend/src/limier/entrypoints/worker.py b/backend/src/limier/entrypoints/worker.py index ce45f46..7587ae4 100644 --- a/backend/src/limier/entrypoints/worker.py +++ b/backend/src/limier/entrypoints/worker.py @@ -14,6 +14,7 @@ from typing import ClassVar from arq import cron from arq.connections import RedisSettings +from arq.worker import func from limier.config import get_settings from limier.jobs.queue import FILE_RECHERCHES, parametres_redis @@ -63,7 +64,11 @@ class WorkerSettings: # au niveau de la classe, d'où les annotations ClassVar. functions: ClassVar[list] = [ executer_recherche, - auto_controle_sites, + # Délai propre : l'auto-contrôle teste 3 000 sites avec deux + # pseudonymes chacun, soit environ 6 000 requêtes. Le délai global de + # 600 s le faisait échouer systématiquement sur TimeoutError, sans + # jamais enregistrer la base corrigée. + func(auto_controle_sites, timeout=4 * 3600), purger_donnees, relancer_recherches_bloquees, ] diff --git a/backend/src/limier/jobs/tasks.py b/backend/src/limier/jobs/tasks.py index 6f019b4..9cfb0de 100644 --- a/backend/src/limier/jobs/tasks.py +++ b/backend/src/limier/jobs/tasks.py @@ -39,6 +39,21 @@ from limier.jobs import progress log = structlog.get_logger(__name__) +CONCURRENCE_AUTO_CONTROLE = 4 +"""Requêtes simultanées pendant l'auto-contrôle. + +Bien plus bas que pour une recherche : le contrôle émet deux fois plus de +requêtes que de sites, et une concurrence élevée sature la résolution DNS +sortante. Les délais dépassés qui en résultent font désactiver des sites sains. +""" + +INTERVALLE_BATTEMENT = 30.0 +"""Fréquence du battement journalisé pendant une recherche, en secondes. + +Permet de distinguer un travail long d'un worker figé : l'horodatage d'une +ligne ne dit rien tant qu'aucune ligne n'est écrite. +""" + INTERVALLE_ECRITURE = 2.0 """Fréquence de mise à jour du compteur de progression en base, en secondes. @@ -103,6 +118,8 @@ async def executer_recherche(ctx: dict[str, Any], recherche_id: str) -> dict[str resultats = 0 faits = 0 derniere_ecriture = 0.0 + debut_horloge = asyncio.get_running_loop().time() + dernier_battement = debut_horloge erreur: str | None = None try: @@ -118,6 +135,14 @@ async def executer_recherche(ctx: dict[str, Any], recherche_id: str) -> dict[str if maintenant - derniere_ecriture > INTERVALLE_ECRITURE: derniere_ecriture = maintenant await _ecrire_progression(identifiant, faits, resultats) + if maintenant - dernier_battement > INTERVALLE_BATTEMENT: + dernier_battement = maintenant + log.info( + "recherche_en_cours", + avancement=f"{faits}/{total_estime}", + resultats=resultats, + secondes_ecoulees=round(maintenant - debut_horloge), + ) elif isinstance(evenement, NoticeEvent) and evenement.level == "error": log.warning("moteur_signale_erreur", message=evenement.message) @@ -230,16 +255,38 @@ async def auto_controle_sites(ctx: dict[str, Any], auto_desactiver: bool = True) db = database.charger_base(forcer=True) sites = db.ranked_sites_dict(top=settings.username_top_sites_deep, disabled=True) - log.info("auto_controle_demarre", sites=len(sites)) - resultat = await self_check( - db, - sites, - stdlog.getLogger("limier.selfcheck"), - silent=True, - max_connections=10, - auto_disable=auto_desactiver, - no_progressbar=True, - ) + debut = datetime.now(UTC) + log.info("auto_controle_demarre", sites=len(sites), concurrence=CONCURRENCE_AUTO_CONTROLE) + + # Battement périodique : sans lui, un contrôle d'une heure ne produit + # aucune ligne de journal et devient indiscernable d'un worker figé. + async def battre() -> None: + while True: + await asyncio.sleep(60) + ecoule = (datetime.now(UTC) - debut).total_seconds() + log.info( + "auto_controle_en_cours", + secondes_ecoulees=round(ecoule), + debute_a=debut.isoformat(), + ) + + batteur = asyncio.create_task(battre()) + try: + resultat = await self_check( + db, + sites, + stdlog.getLogger("limier.selfcheck"), + silent=True, + # Concurrence volontairement basse : à 10, les résolveurs publics + # saturent et renvoient « Timeout while contacting DNS servers ». + # Le contrôle désactivait alors des sites parfaitement sains — il + # faussait son propre verdict. + max_connections=CONCURRENCE_AUTO_CONTROLE, + auto_disable=auto_desactiver, + no_progressbar=True, + ) + finally: + batteur.cancel() if resultat.get("needs_update") and auto_desactiver: chemin = database.chemin_base() @@ -248,10 +295,18 @@ async def auto_controle_sites(ctx: dict[str, Any], auto_desactiver: bool = True) log.info("base_sites_mise_a_jour", chemin=chemin) stats = database.statistiques() + duree = (datetime.now(UTC) - debut).total_seconds() + log.info( + "auto_controle_termine", + secondes=round(duree), + sites_actifs=stats["actifs"], + sites_desactives=stats["desactives"], + ) return { "mise_a_jour": bool(resultat.get("needs_update")), "sites_actifs": stats["actifs"], "sites_desactives": stats["desactives"], + "secondes": round(duree), } diff --git a/deploy/.env.example b/deploy/.env.example index abe765c..b857661 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -7,7 +7,7 @@ # --------------------------------------------------------------------------- REGISTRY=registry.tips-of-mine.com -TAG=1.0.5 +TAG=1.0.6 LIMIER_HOST=limier.tips-of-mine.com LIMIER_PUBLIC_URL=https://limier.tips-of-mine.com LIMIER_ENVIRONMENT=prod diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0405e68..ceb0585 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -56,7 +56,7 @@ x-limier-env: &limier-env services: limier-api: - image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.5} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.6} container_name: limier-api restart: unless-stopped command: ["limier-api"] @@ -99,7 +99,7 @@ services: - "homepage.description=Plateforme OSINT" limier-worker: - image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.5} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.6} container_name: limier-worker restart: unless-stopped command: ["limier-worker"] @@ -113,6 +113,15 @@ services: limier-postgres: { condition: service_healthy } limier-redis: { condition: service_healthy } networks: [back_network_limier] + # Résolveurs publics explicites. Le DNS du homelab (Technitium) filtre + # certains domaines par liste de blocage et les renvoie en 0.0.0.0 — + # linkedin.com, vk.com, pinterest.com, tumblr.com entre autres. Ces + # plateformes, parmi les mieux classées de la base, n'étaient jamais + # interrogées. Seul le worker est concerné : c'est lui qui sort vers les + # sites tiers, l'API et l'interface gardent le DNS interne. + dns: + - 1.1.1.1 + - 9.9.9.9 # Le worker n'écoute sur aucun port : la sonde héritée de l'image commune, # qui interroge /api/sante, ne peut jamais réussir et le ferait passer en # « unhealthy » alors qu'il fonctionne. On la désactive explicitement. @@ -122,7 +131,7 @@ services: - "com.centurylinklabs.watchtower.enable=true" limier-web: - image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-frontend:${TAG:-1.0.5} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-frontend:${TAG:-1.0.6} container_name: limier-web restart: unless-stopped volumes: @@ -205,7 +214,7 @@ services: # ``--profile``, pour que l'installation par défaut reste minimale. limier-holehe: - image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-holehe:${TAG:-1.0.5} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-holehe:${TAG:-1.0.6} container_name: limier-holehe restart: unless-stopped profiles: ["holehe"] diff --git a/deploy/essai/LISEZMOI.md b/deploy/essai/LISEZMOI.md index f40b0c2..f201b4c 100644 --- a/deploy/essai/LISEZMOI.md +++ b/deploy/essai/LISEZMOI.md @@ -12,7 +12,7 @@ Ni base de données, ni Redis, ni Authentik. On appelle le moteur directement. cd /opt # ou n'importe où, il suffit d'avoir essai_moteur.py sous la main docker run --rm -v "$PWD/essai_moteur.py:/tmp/essai.py" \ - registry.tips-of-mine.com/limier/limier-backend:1.0.5 \ + registry.tips-of-mine.com/limier/limier-backend:1.0.6 \ python /tmp/essai.py domain tips-of-mine.com ``` @@ -24,7 +24,7 @@ Le moteur pseudonyme, en version courte : ```bash docker run --rm -v "$PWD/essai_moteur.py:/tmp/essai.py" \ -e LIMIER_USERNAME_TOP_SITES=80 \ - registry.tips-of-mine.com/limier/limier-backend:1.0.5 \ + registry.tips-of-mine.com/limier/limier-backend:1.0.6 \ python /tmp/essai.py username soxoj ``` @@ -33,7 +33,7 @@ Et la recherche par nom, qui montre les variantes déduites : ```bash docker run --rm -v "$PWD/essai_moteur.py:/tmp/essai.py" \ -e LIMIER_USERNAME_TOP_SITES=60 \ - registry.tips-of-mine.com/limier/limier-backend:1.0.5 \ + registry.tips-of-mine.com/limier/limier-backend:1.0.6 \ python /tmp/essai.py name "Hubert Cornet" ``` diff --git a/frontend/package.json b/frontend/package.json index 2d0dda2..aa7e30a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "limier-frontend", - "version": "1.0.5", + "version": "1.0.6", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/CarteResultat.tsx b/frontend/src/components/CarteResultat.tsx index 29faba7..8aa5120 100644 --- a/frontend/src/components/CarteResultat.tsx +++ b/frontend/src/components/CarteResultat.tsx @@ -33,6 +33,12 @@ const NOMS_CHAMPS: Record = { public_repos: "Dépôts", repos: "Dépôts", created_at: "Inscription", + favourites_count: "Favoris", + image_bg: "Bannière", + image: "Image", + heart_count: "Mentions J'aime", + video_count: "Vidéos", + is_verified: "Compte vérifié", reg_date: "Inscription", registration_date: "Inscription", uid: "Identifiant interne", diff --git a/frontend/src/pages/PageHistorique.tsx b/frontend/src/pages/PageHistorique.tsx index fbc29a4..27fac8b 100644 --- a/frontend/src/pages/PageHistorique.tsx +++ b/frontend/src/pages/PageHistorique.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api, type ResumeRecherche, type DetailRecherche } from "../lib/api"; import { CarteResultat } from "../components/CarteResultat"; @@ -17,17 +17,44 @@ const STATUTS: Record = { cancelled: "annulée", }; +/** États pour lesquels la ligne bouge encore et mérite d'être rafraîchie. */ +const EN_COURS = new Set(["queued", "running"]); + +const INTERVALLE_RAFRAICHISSEMENT = 3000; + export function PageHistorique() { const [recherches, setRecherches] = useState(null); const [ouverte, setOuverte] = useState(null); const [erreur, setErreur] = useState(null); + const minuterie = useRef(null); + + const charger = useCallback(async () => { + try { + setRecherches(await api.historique()); + setErreur(null); + } catch (e) { + setErreur(e instanceof Error ? e.message : "Erreur"); + } + }, []); useEffect(() => { - api - .historique() - .then(setRecherches) - .catch((e) => setErreur(e.message)); - }, []); + void charger(); + }, [charger]); + + // Actualisation automatique tant qu'une recherche est en attente ou en cours. + // On s'arrête dès que tout est terminé : interroger l'API toutes les trois + // secondes sans raison est inutile pour l'utilisateur et pour le serveur. + useEffect(() => { + const bouge = recherches?.some((r) => EN_COURS.has(r.status)) ?? false; + if (!bouge || ouverte) { + if (minuterie.current) window.clearTimeout(minuterie.current); + return; + } + minuterie.current = window.setTimeout(() => void charger(), INTERVALLE_RAFRAICHISSEMENT); + return () => { + if (minuterie.current) window.clearTimeout(minuterie.current); + }; + }, [recherches, ouverte, charger]); async function ouvrir(id: string) { try { @@ -37,6 +64,26 @@ export function PageHistorique() { } } + function avancement(r: ResumeRecherche) { + if (!EN_COURS.has(r.status)) { + return {STATUTS[r.status] ?? r.status}; + } + if (r.status === "queued" || r.progress_total === 0) { + return en attente…; + } + const pourcent = Math.min(100, Math.round((r.progress_done / r.progress_total) * 100)); + return ( + <> +
+
+
+ + {r.progress_done} / {r.progress_total} ({pourcent} %) + + + ); + } + async function supprimer(id: string) { await api.supprimer(id); setRecherches((r) => r?.filter((x) => x.id !== id) ?? null); @@ -70,10 +117,17 @@ export function PageHistorique() { return ( <> -

Historique

+
+

Historique

+
+ +
+

Les recherches sont conservées puis supprimées automatiquement selon la politique de - l'instance. + l'instance. Les recherches en cours s'actualisent toutes les trois secondes.

{recherches.length === 0 ? ( @@ -85,7 +139,7 @@ export function PageHistorique() { Terme Type - État + Avancement Résultats Date @@ -104,7 +158,7 @@ export function PageHistorique() { {TYPES[r.kind] ?? r.kind} - {STATUTS[r.status] ?? r.status} + {avancement(r)} {r.findings_count} {new Date(r.created_at).toLocaleString("fr-FR", {