Application PySide6 (GUI + CLI) fonctionnant sous Linux et Windows : - Diagnostic réseau complet AD (DNS, SRV, ports, NTP, outils requis) - Jointure de domaine via realmd (Linux) ou Add-Computer (Windows) - Installation automatique des prérequis Linux (apt) - CLI colorée (diagnose/join/install-prereqs), sans dépendance PySide6 - Scripts de packaging PyInstaller pour Linux et Windows Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
248 lines
7.8 KiB
Python
248 lines
7.8 KiB
Python
"""Moteur de diagnostic réseau pour une jointure à un domaine Active Directory.
|
|
|
|
Chaque vérification est indépendante de la plateforme et retourne un CheckResult.
|
|
`run_diagnostics()` est un générateur : il yield chaque résultat au fur et à
|
|
mesure, pour permettre à l'appelant (thread worker) de mettre à jour l'UI en
|
|
continu plutôt que d'attendre la fin de tous les checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import platform
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
|
|
try:
|
|
import dns.resolver
|
|
import dns.exception
|
|
|
|
_HAS_DNSPYTHON = True
|
|
except ImportError:
|
|
_HAS_DNSPYTHON = False
|
|
|
|
try:
|
|
import ntplib
|
|
|
|
_HAS_NTPLIB = True
|
|
except ImportError:
|
|
_HAS_NTPLIB = False
|
|
|
|
|
|
class Status(Enum):
|
|
OK = "OK"
|
|
WARN = "AVERTISSEMENT"
|
|
FAIL = "ÉCHEC"
|
|
|
|
|
|
@dataclass
|
|
class CheckResult:
|
|
name: str
|
|
status: Status
|
|
detail: str
|
|
|
|
|
|
# Ports requis pour rejoindre/fonctionner dans un domaine AD.
|
|
AD_PORTS = {
|
|
53: "DNS",
|
|
88: "Kerberos",
|
|
389: "LDAP",
|
|
445: "SMB",
|
|
464: "Kerberos kpasswd",
|
|
636: "LDAPS",
|
|
3268: "LDAP Global Catalog",
|
|
}
|
|
|
|
SRV_RECORDS = [
|
|
"_ldap._tcp.{domain}",
|
|
"_kerberos._tcp.{domain}",
|
|
"_kerberos._tcp.dc._msdcs.{domain}",
|
|
]
|
|
|
|
REQUIRED_LINUX_TOOLS = ["realm", "adcli", "net"]
|
|
|
|
|
|
def _check_hostname() -> CheckResult:
|
|
host = socket.gethostname()
|
|
fqdn = socket.getfqdn()
|
|
if fqdn and fqdn != host and "." in fqdn:
|
|
return CheckResult("Nom d'hôte / FQDN", Status.OK, f"hostname={host}, FQDN={fqdn}")
|
|
return CheckResult(
|
|
"Nom d'hôte / FQDN",
|
|
Status.WARN,
|
|
f"hostname={host}, FQDN non résolu ({fqdn}) — la résolution DNS locale peut être incomplète.",
|
|
)
|
|
|
|
|
|
def _resolve_domain_ips(domain: str) -> list[str]:
|
|
try:
|
|
infos = socket.getaddrinfo(domain, None)
|
|
return sorted({info[4][0] for info in infos})
|
|
except socket.gaierror:
|
|
return []
|
|
|
|
|
|
def _check_dns(domain: str) -> tuple[CheckResult, list[str]]:
|
|
ips = _resolve_domain_ips(domain)
|
|
if ips:
|
|
return CheckResult("Résolution DNS du domaine", Status.OK, f"{domain} → {', '.join(ips)}"), ips
|
|
return (
|
|
CheckResult(
|
|
"Résolution DNS du domaine",
|
|
Status.FAIL,
|
|
f"Impossible de résoudre {domain}. Vérifiez que le DNS du poste pointe vers un contrôleur de domaine.",
|
|
),
|
|
[],
|
|
)
|
|
|
|
|
|
def _check_srv_records(domain: str) -> CheckResult:
|
|
if not _HAS_DNSPYTHON:
|
|
return CheckResult(
|
|
"Enregistrements SRV (LDAP/Kerberos)",
|
|
Status.WARN,
|
|
"Le module dnspython n'est pas installé — vérification ignorée.",
|
|
)
|
|
found, missing = [], []
|
|
for template in SRV_RECORDS:
|
|
qname = template.format(domain=domain)
|
|
try:
|
|
dns.resolver.resolve(qname, "SRV", lifetime=3.0)
|
|
found.append(qname)
|
|
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout, dns.resolver.NoNameservers):
|
|
missing.append(qname)
|
|
if not missing:
|
|
return CheckResult("Enregistrements SRV (LDAP/Kerberos)", Status.OK, f"{len(found)}/{len(found)} trouvés")
|
|
if found:
|
|
return CheckResult(
|
|
"Enregistrements SRV (LDAP/Kerberos)",
|
|
Status.WARN,
|
|
f"Manquants : {', '.join(missing)}",
|
|
)
|
|
return CheckResult(
|
|
"Enregistrements SRV (LDAP/Kerberos)",
|
|
Status.FAIL,
|
|
"Aucun enregistrement SRV trouvé — le DNS ne semble pas être celui du domaine AD.",
|
|
)
|
|
|
|
|
|
def _ping(host: str, timeout_s: int = 2) -> bool:
|
|
is_windows = platform.system() == "Windows"
|
|
if is_windows:
|
|
cmd = ["ping", "-n", "1", "-w", str(timeout_s * 1000), host]
|
|
else:
|
|
cmd = ["ping", "-c", "1", "-W", str(timeout_s), host]
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=timeout_s + 2,
|
|
creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0,
|
|
)
|
|
return result.returncode == 0
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
return False
|
|
|
|
|
|
def _check_ping(domain: str, ips: list[str]) -> CheckResult:
|
|
targets = ips or [domain]
|
|
reachable = [ip for ip in targets if _ping(ip)]
|
|
if reachable:
|
|
return CheckResult("Ping du/des contrôleur(s) de domaine", Status.OK, f"Répond : {', '.join(reachable)}")
|
|
return CheckResult(
|
|
"Ping du/des contrôleur(s) de domaine",
|
|
Status.WARN,
|
|
"Aucune réponse au ping (peut être normal si ICMP est filtré par un pare-feu).",
|
|
)
|
|
|
|
|
|
def _check_ports(ips: list[str]) -> CheckResult:
|
|
if not ips:
|
|
return CheckResult("Ports AD (53/88/389/445/464/636/3268)", Status.FAIL, "Pas d'IP de contrôleur à tester.")
|
|
ip = ips[0]
|
|
open_ports, closed_ports = [], []
|
|
for port, name in AD_PORTS.items():
|
|
try:
|
|
with socket.create_connection((ip, port), timeout=2):
|
|
open_ports.append(f"{port}/{name}")
|
|
except OSError:
|
|
closed_ports.append(f"{port}/{name}")
|
|
if not closed_ports:
|
|
return CheckResult("Ports AD (53/88/389/445/464/636/3268)", Status.OK, f"Tous ouverts sur {ip}")
|
|
if open_ports:
|
|
return CheckResult(
|
|
"Ports AD (53/88/389/445/464/636/3268)",
|
|
Status.WARN,
|
|
f"Fermés/filtrés sur {ip} : {', '.join(closed_ports)}",
|
|
)
|
|
return CheckResult(
|
|
"Ports AD (53/88/389/445/464/636/3268)",
|
|
Status.FAIL,
|
|
f"Aucun port AD accessible sur {ip} — connectivité réseau ou pare-feu à vérifier.",
|
|
)
|
|
|
|
|
|
def _check_ntp(domain: str, ips: list[str]) -> CheckResult:
|
|
if not _HAS_NTPLIB:
|
|
return CheckResult(
|
|
"Synchronisation horaire (NTP)",
|
|
Status.WARN,
|
|
"Le module ntplib n'est pas installé — vérification ignorée.",
|
|
)
|
|
targets = ips or [domain]
|
|
client = ntplib.NTPClient()
|
|
for target in targets:
|
|
try:
|
|
response = client.request(target, port=123, version=3, timeout=2)
|
|
drift = abs(response.offset)
|
|
if drift > 300:
|
|
return CheckResult(
|
|
"Synchronisation horaire (NTP)",
|
|
Status.FAIL,
|
|
f"Dérive de {drift:.1f}s avec {target} — Kerberos échouera (limite : 300s).",
|
|
)
|
|
if drift > 30:
|
|
return CheckResult(
|
|
"Synchronisation horaire (NTP)", Status.WARN, f"Dérive de {drift:.1f}s avec {target}."
|
|
)
|
|
return CheckResult("Synchronisation horaire (NTP)", Status.OK, f"Dérive de {drift:.1f}s avec {target}.")
|
|
except Exception:
|
|
continue
|
|
return CheckResult(
|
|
"Synchronisation horaire (NTP)",
|
|
Status.WARN,
|
|
"Aucun contrôleur n'a répondu au NTP (port 123) — vérification impossible.",
|
|
)
|
|
|
|
|
|
def _check_linux_tools() -> CheckResult:
|
|
missing = [tool for tool in REQUIRED_LINUX_TOOLS if shutil.which(tool) is None]
|
|
if not missing:
|
|
return CheckResult("Outils requis (realm/adcli/net)", Status.OK, "Tous les outils sont installés.")
|
|
return CheckResult(
|
|
"Outils requis (realm/adcli/net)",
|
|
Status.FAIL,
|
|
"Manquants : " + ", ".join(missing) + ". Installez-les avec : "
|
|
"sudo apt install realmd sssd adcli samba-common-bin packagekit",
|
|
)
|
|
|
|
|
|
def run_diagnostics(domain: str):
|
|
"""Générateur qui exécute tous les checks et yield chaque CheckResult au fur et à mesure."""
|
|
yield _check_hostname()
|
|
|
|
dns_result, ips = _check_dns(domain)
|
|
yield dns_result
|
|
|
|
yield _check_srv_records(domain)
|
|
yield _check_ping(domain, ips)
|
|
yield _check_ports(ips)
|
|
yield _check_ntp(domain, ips)
|
|
|
|
if platform.system() != "Windows":
|
|
yield _check_linux_tools()
|