Version initiale : diagnostic réseau AD + jointure de domaine

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>
This commit is contained in:
2026-09-04 11:40:11 +02:00
co-authored by Claude Sonnet 5
commit 1feb72bf17
15 changed files with 1329 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+19
View File
@@ -0,0 +1,19 @@
"""Sélection du backend de jointure de domaine selon la plateforme."""
import platform
from .base import DomainJoinBackend, JoinResult
def get_backend() -> DomainJoinBackend:
if platform.system() == "Windows":
from .windows_addcomputer import WindowsAddComputerBackend
return WindowsAddComputerBackend()
from .linux_realmd import LinuxRealmdBackend
return LinuxRealmdBackend()
__all__ = ["get_backend", "DomainJoinBackend", "JoinResult"]
+47
View File
@@ -0,0 +1,47 @@
"""Interface commune aux backends de jointure de domaine (Linux/Windows)."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass
class JoinResult:
success: bool
message: str
needs_reboot: bool = False
OutputCallback = Callable[[str], None]
class DomainJoinBackend(ABC):
"""Backend de jointure à un domaine Active Directory."""
@abstractmethod
def is_available(self) -> tuple[bool, str]:
"""Indique si les outils nécessaires sont présents. Retourne (disponible, message)."""
@abstractmethod
def join(
self,
domain: str,
username: str,
password: str,
ou: Optional[str] = None,
computer_name: Optional[str] = None,
on_output: Optional[OutputCallback] = None,
) -> JoinResult:
"""Rejoint le domaine. Le mot de passe n'est jamais passé en argument
de ligne de commande : il est transmis via l'entrée standard du
sous-processus pour ne pas apparaître dans la liste des processus."""
def can_install_prerequisites(self) -> bool:
"""True si ce backend sait installer automatiquement les outils manquants."""
return False
def install_prerequisites(self, on_output: Optional[OutputCallback] = None) -> JoinResult:
"""Installe les outils manquants. À surcharger si can_install_prerequisites() est vrai."""
return JoinResult(success=False, message="Installation automatique non prise en charge pour ce backend.")
+134
View File
@@ -0,0 +1,134 @@
"""Backend de jointure de domaine pour Linux, via realmd (`realm join`)."""
from __future__ import annotations
import os
import shutil
import subprocess
from typing import Optional
from .base import DomainJoinBackend, JoinResult, OutputCallback
REQUIRED_PACKAGES = ["realmd", "sssd", "adcli", "samba-common-bin", "packagekit"]
INSTALL_HINT = "sudo apt install " + " ".join(REQUIRED_PACKAGES)
class LinuxRealmdBackend(DomainJoinBackend):
def is_available(self) -> tuple[bool, str]:
if shutil.which("realm") is None:
return False, f"La commande 'realm' est introuvable. Installez-la avec : {INSTALL_HINT}"
return True, "realmd disponible."
def can_install_prerequisites(self) -> bool:
return shutil.which("apt-get") is not None
def install_prerequisites(self, on_output: Optional[OutputCallback] = None) -> JoinResult:
def emit(line: str) -> None:
if on_output:
on_output(line)
if shutil.which("apt-get") is None:
return JoinResult(success=False, message="apt-get est introuvable sur ce système.")
env = os.environ.copy()
env["DEBIAN_FRONTEND"] = "noninteractive"
for cmd in (["apt-get", "update"], ["apt-get", "install", "-y", *REQUIRED_PACKAGES]):
emit(f"$ {' '.join(cmd)}")
try:
process = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=env,
)
except OSError as exc:
return JoinResult(success=False, message=f"Impossible de lancer apt-get : {exc}")
if process.stdout:
for line in process.stdout:
emit(line.rstrip("\n"))
returncode = process.wait()
if returncode != 0:
return JoinResult(
success=False,
message=f"Échec de « {' '.join(cmd)} » (code retour {returncode}). "
"Vérifiez que l'application tourne avec les droits root.",
)
available, _ = self.is_available()
if available:
return JoinResult(success=True, message="Les prérequis ont été installés avec succès.")
return JoinResult(
success=False,
message="Installation terminée mais 'realm' reste introuvable. Vérifiez le journal ci-dessus.",
)
def join(
self,
domain: str,
username: str,
password: str,
ou: Optional[str] = None,
computer_name: Optional[str] = None,
on_output: Optional[OutputCallback] = None,
) -> JoinResult:
available, reason = self.is_available()
if not available:
return JoinResult(success=False, message=reason)
def emit(line: str) -> None:
if on_output:
on_output(line)
if computer_name:
emit(
"Note : realm join utilise le nom d'hôte système du poste comme nom de "
"machine dans l'annuaire. Changez le hostname (hostnamectl) avant la "
f"jointure si vous voulez que la machine apparaisse comme « {computer_name} »."
)
cmd = ["realm", "-v", "join", "--user", username]
if ou:
cmd += ["--computer-ou", ou]
cmd.append(domain)
emit(f"$ realm -v join --user {username}" + (f" --computer-ou {ou}" if ou else "") + f" {domain}")
try:
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
except OSError as exc:
return JoinResult(success=False, message=f"Impossible de lancer realm : {exc}")
try:
process.stdin.write(password + "\n")
process.stdin.close()
except BrokenPipeError:
pass
if process.stdout:
for line in process.stdout:
emit(line.rstrip("\n"))
returncode = process.wait()
if returncode == 0:
return JoinResult(
success=True,
message=f"Le poste a rejoint le domaine {domain} avec succès.",
needs_reboot=True,
)
return JoinResult(
success=False,
message=f"Échec de la jointure au domaine (code retour {returncode}). Voir le journal ci-dessus.",
)
+118
View File
@@ -0,0 +1,118 @@
"""Backend de jointure de domaine pour Windows, via la cmdlet PowerShell Add-Computer."""
from __future__ import annotations
import os
import shutil
import subprocess
from typing import Optional
from .base import DomainJoinBackend, JoinResult, OutputCallback
_PASSWORD_ENV_VAR = "DOMAINJOIN_PWD"
_SUCCESS_MARKER = "JOIN_SUCCESS"
_FAILURE_PREFIX = "JOIN_FAILURE:"
def _ps_quote(value: str) -> str:
"""Échappe une valeur pour l'insérer dans une chaîne PowerShell entre quotes simples."""
return value.replace("'", "''")
class WindowsAddComputerBackend(DomainJoinBackend):
def is_available(self) -> tuple[bool, str]:
if shutil.which("powershell") is None and shutil.which("powershell.exe") is None:
return False, "PowerShell est introuvable sur ce poste."
return True, "PowerShell disponible."
def _build_script(self, domain: str, username: str, ou: Optional[str], computer_name: Optional[str]) -> str:
lines = [
"$ErrorActionPreference = 'Stop'",
f"$domain = '{_ps_quote(domain)}'",
f"$username = '{_ps_quote(username)}'",
f"$password = $env:{_PASSWORD_ENV_VAR}",
"if ([string]::IsNullOrEmpty($password)) { Write-Output 'JOIN_FAILURE: mot de passe manquant.'; exit 2 }",
"$securePwd = ConvertTo-SecureString -String $password -AsPlainText -Force",
"$cred = New-Object System.Management.Automation.PSCredential($username, $securePwd)",
"$params = @{ DomainName = $domain; Credential = $cred; Force = $true; Restart = $false }",
]
if ou:
lines.append(f"$params['OUPath'] = '{_ps_quote(ou)}'")
if computer_name:
lines.append(f"$params['NewName'] = '{_ps_quote(computer_name)}'")
lines += [
"try {",
" Add-Computer @params -ErrorAction Stop",
f" Write-Output '{_SUCCESS_MARKER}'",
"} catch {",
f" Write-Output ('{_FAILURE_PREFIX} ' + $_.Exception.Message)",
" exit 1",
"}",
]
return "\n".join(lines)
def join(
self,
domain: str,
username: str,
password: str,
ou: Optional[str] = None,
computer_name: Optional[str] = None,
on_output: Optional[OutputCallback] = None,
) -> JoinResult:
available, reason = self.is_available()
if not available:
return JoinResult(success=False, message=reason)
def emit(line: str) -> None:
if on_output:
on_output(line)
script = self._build_script(domain, username, ou, computer_name)
emit(f"Exécution de Add-Computer pour le domaine {domain}...")
env = os.environ.copy()
env[_PASSWORD_ENV_VAR] = password
try:
process = subprocess.Popen(
["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=env,
creationflags=subprocess.CREATE_NO_WINDOW,
)
except OSError as exc:
return JoinResult(success=False, message=f"Impossible de lancer PowerShell : {exc}")
finally:
env[_PASSWORD_ENV_VAR] = ""
try:
process.stdin.write(script)
process.stdin.close()
except BrokenPipeError:
pass
output_lines: list[str] = []
if process.stdout:
for line in process.stdout:
line = line.rstrip("\n")
output_lines.append(line)
emit(line)
returncode = process.wait()
full_output = "\n".join(output_lines)
if returncode == 0 and _SUCCESS_MARKER in full_output:
return JoinResult(
success=True,
message=f"Le poste a rejoint le domaine {domain} avec succès. Un redémarrage est requis.",
needs_reboot=True,
)
failure_detail = next((l for l in output_lines if l.strip().startswith(_FAILURE_PREFIX)), None)
message = failure_detail or f"Échec de la jointure au domaine (code retour {returncode})."
return JoinResult(success=False, message=message)
+223
View File
@@ -0,0 +1,223 @@
"""Interface en ligne de commande de JoinDomainGUI.
N'importe volontairement pas PySide6 : la CLI doit pouvoir tourner sur un
poste sans environnement graphique (déploiement scripté, SSH, etc.).
"""
from __future__ import annotations
import argparse
import getpass
import os
import sys
from typing import Sequence
from . import __version__, diagnostics, privileges
from .backend import get_backend
DEFAULT_DOMAIN = "h3adm.lan"
_STATUS_PREFIX = {
diagnostics.Status.OK: "[OK] ",
diagnostics.Status.WARN: "[!!] ",
diagnostics.Status.FAIL: "[XX] ",
}
class _Color:
RESET = "\x1b[0m"
BOLD = "\x1b[1m"
DIM = "\x1b[2m"
RED = "\x1b[31m"
GREEN = "\x1b[32m"
YELLOW = "\x1b[33m"
CYAN = "\x1b[36m"
_STATUS_COLOR = {
diagnostics.Status.OK: _Color.GREEN,
diagnostics.Status.WARN: _Color.YELLOW,
diagnostics.Status.FAIL: _Color.RED,
}
def _enable_windows_ansi() -> None:
"""Active l'interprétation des séquences ANSI dans les consoles Windows classiques."""
if sys.platform != "win32":
return
try:
import ctypes
kernel32 = ctypes.windll.kernel32
enable_virtual_terminal_processing = 0x0004
for std_handle in (-11, -12): # STD_OUTPUT_HANDLE, STD_ERROR_HANDLE
handle = kernel32.GetStdHandle(std_handle)
mode = ctypes.c_uint32()
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
kernel32.SetConsoleMode(handle, mode.value | enable_virtual_terminal_processing)
except Exception:
pass
def _color_enabled(stream) -> bool:
if os.environ.get("NO_COLOR") is not None:
return False
if os.environ.get("FORCE_COLOR") is not None:
return True
try:
return stream.isatty()
except Exception:
return False
def _colorize(text: str, color: str, stream) -> str:
if not _color_enabled(stream):
return text
return f"{color}{text}{_Color.RESET}"
def _print_check(result: diagnostics.CheckResult) -> None:
line = f"{_STATUS_PREFIX[result.status]}{result.name}{result.detail}"
print(_colorize(line, _STATUS_COLOR[result.status], sys.stdout))
def _confirm(prompt: str, assume_yes: bool) -> bool:
if assume_yes:
return True
reply = input(f"{prompt} [o/N] : ").strip().lower()
return reply in ("o", "oui", "y", "yes")
def _resolve_password(args: argparse.Namespace) -> str:
if args.password_stdin:
return sys.stdin.readline().rstrip("\n")
if args.password:
print(
"Attention : --password expose le mot de passe dans l'historique du shell "
"et la liste des processus. Préférez --password-stdin.",
file=sys.stderr,
)
return args.password
return getpass.getpass("Mot de passe : ")
def _print_error(message: str) -> None:
print(_colorize(f"Erreur : {message}", _Color.RED, sys.stderr), file=sys.stderr)
def _print_result(result) -> None:
color = _Color.GREEN if result.success else _Color.RED
print(_colorize(result.message, color, sys.stdout))
def _require_elevation() -> int | None:
if privileges.is_elevated():
return None
_print_error(privileges.elevation_hint())
return 2
def _cmd_diagnose(args: argparse.Namespace) -> int:
results = list(diagnostics.run_diagnostics(args.domain))
for result in results:
_print_check(result)
oks = sum(1 for r in results if r.status == diagnostics.Status.OK)
warns = sum(1 for r in results if r.status == diagnostics.Status.WARN)
fails = sum(1 for r in results if r.status == diagnostics.Status.FAIL)
summary = (
f"\nRésumé : {_colorize(f'{oks} OK', _Color.GREEN, sys.stdout)}, "
f"{_colorize(f'{warns} avertissement(s)', _Color.YELLOW, sys.stdout)}, "
f"{_colorize(f'{fails} échec(s)', _Color.RED, sys.stdout)}."
)
print(summary)
return 1 if fails else 0
def _cmd_join(args: argparse.Namespace) -> int:
if (code := _require_elevation()) is not None:
return code
backend = get_backend()
available, reason = backend.is_available()
if not available:
_print_error(reason)
return 2
password = _resolve_password(args)
if not _confirm(
f"Rejoindre le domaine « {args.domain} » avec l'utilisateur « {args.username} » ?", args.yes
):
print(_colorize("Annulé.", _Color.YELLOW, sys.stdout))
return 1
result = backend.join(
args.domain,
args.username,
password,
ou=args.ou,
computer_name=args.computer_name,
on_output=print,
)
_print_result(result)
if result.success and result.needs_reboot:
print(_colorize("Un redémarrage est nécessaire pour finaliser la jointure.", _Color.YELLOW, sys.stdout))
return 0 if result.success else 1
def _cmd_install_prereqs(args: argparse.Namespace) -> int:
if (code := _require_elevation()) is not None:
return code
backend = get_backend()
if not backend.can_install_prerequisites():
_print_error("Ce backend ne prend pas en charge l'installation automatique des prérequis.")
return 2
if not _confirm("Installer les prérequis système (realmd, sssd, adcli, samba-common-bin, packagekit) ?", args.yes):
print(_colorize("Annulé.", _Color.YELLOW, sys.stdout))
return 1
result = backend.install_prerequisites(on_output=print)
_print_result(result)
return 0 if result.success else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="JoinDomainGUI",
description="Diagnostic réseau et jointure de domaine Active Directory (Linux/Windows).",
)
parser.add_argument("--version", action="version", version=f"JoinDomainGUI {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
p_diag = sub.add_parser("diagnose", help="Lance le diagnostic réseau AD.")
p_diag.add_argument("--domain", default=DEFAULT_DOMAIN, help=f"Domaine à diagnostiquer (défaut : {DEFAULT_DOMAIN}).")
p_diag.set_defaults(func=_cmd_diagnose)
p_join = sub.add_parser("join", help="Rejoint le domaine (nécessite les droits root/administrateur).")
p_join.add_argument("--domain", default=DEFAULT_DOMAIN, help=f"Domaine à rejoindre (défaut : {DEFAULT_DOMAIN}).")
p_join.add_argument("--username", required=True, help="Compte autorisé à joindre le domaine.")
p_join.add_argument("--password", help="Mot de passe (déconseillé : visible dans l'historique/ps). Préférez --password-stdin.")
p_join.add_argument("--password-stdin", action="store_true", help="Lit le mot de passe depuis l'entrée standard.")
p_join.add_argument("--ou", default=None, help="Unité d'organisation cible (optionnel).")
p_join.add_argument("--computer-name", default=None, help="Nom de machine (optionnel).")
p_join.add_argument("-y", "--yes", action="store_true", help="Ne pas demander de confirmation.")
p_join.set_defaults(func=_cmd_join)
p_install = sub.add_parser(
"install-prereqs", help="Installe les outils système requis pour la jointure (Linux/apt uniquement)."
)
p_install.add_argument("-y", "--yes", action="store_true", help="Ne pas demander de confirmation.")
p_install.set_defaults(func=_cmd_install_prereqs)
return parser
def run_cli(argv: Sequence[str]) -> int:
_enable_windows_ansi()
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
+247
View File
@@ -0,0 +1,247 @@
"""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()
+337
View File
@@ -0,0 +1,337 @@
"""Interface graphique PySide6 : diagnostic réseau AD + jointure de domaine."""
from __future__ import annotations
import platform
import socket
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QApplication,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from . import __version__, diagnostics, privileges
from .backend import get_backend
from .worker import DiagnosticsWorker, InstallPrerequisitesWorker, JoinWorker
DEFAULT_DOMAIN = "h3adm.lan"
_STATUS_COLORS = {
diagnostics.Status.OK: QColor("#1e7e34"),
diagnostics.Status.WARN: QColor("#b8860b"),
diagnostics.Status.FAIL: QColor("#c0392b"),
}
class DiagnosticsTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._worker: DiagnosticsWorker | None = None
self._results: list[diagnostics.CheckResult] = []
layout = QVBoxLayout(self)
form = QFormLayout()
self.domain_edit = QLineEdit(DEFAULT_DOMAIN)
form.addRow("Domaine :", self.domain_edit)
layout.addLayout(form)
buttons = QHBoxLayout()
self.run_button = QPushButton("Lancer le diagnostic")
self.run_button.clicked.connect(self._start_diagnostics)
buttons.addWidget(self.run_button)
self.copy_button = QPushButton("Copier le rapport")
self.copy_button.clicked.connect(self._copy_report)
self.copy_button.setEnabled(False)
buttons.addWidget(self.copy_button)
buttons.addStretch()
layout.addLayout(buttons)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Vérification", "Statut", "Détail"])
self.tree.setColumnWidth(0, 260)
self.tree.setColumnWidth(1, 130)
layout.addWidget(self.tree)
self.summary_label = QLabel("")
layout.addWidget(self.summary_label)
def _start_diagnostics(self) -> None:
domain = self.domain_edit.text().strip()
if not domain:
QMessageBox.warning(self, "Domaine manquant", "Veuillez saisir un domaine à diagnostiquer.")
return
self.tree.clear()
self._results = []
self.run_button.setEnabled(False)
self.copy_button.setEnabled(False)
self.summary_label.setText("Diagnostic en cours...")
self._worker = DiagnosticsWorker(domain)
self._worker.check_ready.connect(self._on_check_ready)
self._worker.finished_all.connect(self._on_finished)
self._worker.start()
def _on_check_ready(self, result: diagnostics.CheckResult) -> None:
self._results.append(result)
item = QTreeWidgetItem([result.name, result.status.value, result.detail])
color = _STATUS_COLORS.get(result.status)
if color:
item.setForeground(1, color)
self.tree.addTopLevelItem(item)
def _on_finished(self) -> None:
self.run_button.setEnabled(True)
self.copy_button.setEnabled(True)
ok = sum(1 for r in self._results if r.status == diagnostics.Status.OK)
warn = sum(1 for r in self._results if r.status == diagnostics.Status.WARN)
fail = sum(1 for r in self._results if r.status == diagnostics.Status.FAIL)
self.summary_label.setText(f"Terminé : {ok} OK, {warn} avertissement(s), {fail} échec(s).")
def _copy_report(self) -> None:
lines = [f"Diagnostic réseau pour le domaine {self.domain_edit.text().strip()}", ""]
for r in self._results:
lines.append(f"[{r.status.value}] {r.name}{r.detail}")
QApplication.clipboard().setText("\n".join(lines))
class JoinTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._worker: JoinWorker | None = None
self.backend = get_backend()
layout = QVBoxLayout(self)
form = QFormLayout()
self.domain_edit = QLineEdit(DEFAULT_DOMAIN)
self.username_edit = QLineEdit()
self.username_edit.setPlaceholderText("ex : administrateur@h3adm.lan")
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.ou_edit = QLineEdit()
self.ou_edit.setPlaceholderText("optionnel, ex : OU=Postes,DC=h3adm,DC=lan")
self.computer_name_edit = QLineEdit(socket.gethostname())
form.addRow("Domaine :", self.domain_edit)
form.addRow("Utilisateur :", self.username_edit)
form.addRow("Mot de passe :", self.password_edit)
form.addRow("Unité d'organisation :", self.ou_edit)
form.addRow("Nom de la machine :", self.computer_name_edit)
layout.addLayout(form)
self.prereq_label = QLabel("")
self.prereq_label.setWordWrap(True)
self.prereq_label.setVisible(False)
layout.addWidget(self.prereq_label)
buttons = QHBoxLayout()
self.join_button = QPushButton("Rejoindre le domaine")
self.join_button.clicked.connect(self._start_join)
buttons.addWidget(self.join_button)
self.install_button = QPushButton("Installer les prérequis")
self.install_button.clicked.connect(self._start_install)
self.install_button.setVisible(False)
buttons.addWidget(self.install_button)
buttons.addStretch()
layout.addLayout(buttons)
self.log = QPlainTextEdit()
self.log.setReadOnly(True)
layout.addWidget(self.log)
self._refresh_backend_state()
def _refresh_backend_state(self) -> None:
"""Met à jour l'état des boutons selon les privilèges et la disponibilité du backend."""
elevated = privileges.is_elevated()
available, reason = self.backend.is_available()
self.install_button.setVisible(not available and self.backend.can_install_prerequisites())
self.install_button.setEnabled(elevated)
if not elevated:
self.install_button.setToolTip(
"Droits insuffisants pour installer les prérequis.\n" + privileges.elevation_hint()
)
else:
self.install_button.setToolTip("")
self.prereq_label.setVisible(not available)
if not available:
self.prereq_label.setText("" + reason)
if not elevated:
self.join_button.setEnabled(False)
self.join_button.setToolTip(
"Droits insuffisants pour rejoindre le domaine.\n" + privileges.elevation_hint()
)
elif not available:
self.join_button.setEnabled(False)
self.join_button.setToolTip("Prérequis manquants — installez-les d'abord.")
else:
self.join_button.setEnabled(True)
self.join_button.setToolTip("")
def _start_install(self) -> None:
confirm = QMessageBox.question(
self,
"Confirmer l'installation",
"Installer les paquets système requis pour la jointure de domaine "
"(realmd, sssd, adcli, samba-common-bin, packagekit) ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
self.log.clear()
self.install_button.setEnabled(False)
self.join_button.setEnabled(False)
self._install_worker = InstallPrerequisitesWorker(self.backend)
self._install_worker.output_line.connect(self.log.appendPlainText)
self._install_worker.finished_install.connect(self._on_install_finished)
self._install_worker.start()
def _on_install_finished(self, result) -> None:
if result.success:
QMessageBox.information(self, "Installation terminée", result.message)
else:
QMessageBox.critical(self, "Échec de l'installation", result.message)
self._refresh_backend_state()
def _start_join(self) -> None:
domain = self.domain_edit.text().strip()
username = self.username_edit.text().strip()
password = self.password_edit.text()
ou = self.ou_edit.text().strip() or None
computer_name = self.computer_name_edit.text().strip() or None
if not domain or not username or not password:
QMessageBox.warning(self, "Champs manquants", "Domaine, utilisateur et mot de passe sont requis.")
return
available, reason = self.backend.is_available()
if not available:
QMessageBox.critical(self, "Backend indisponible", reason)
return
confirm = QMessageBox.question(
self,
"Confirmer la jointure",
f"Rejoindre le domaine « {domain} » avec l'utilisateur « {username} » ?\n\n"
"Cette action modifie la configuration système du poste et nécessitera "
"probablement un redémarrage.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
self.log.clear()
self.join_button.setEnabled(False)
self._worker = JoinWorker(self.backend, domain, username, password, ou, computer_name)
self._worker.output_line.connect(self.log.appendPlainText)
self._worker.finished_join.connect(self._on_join_finished)
self._worker.start()
def _on_join_finished(self, result) -> None:
self._refresh_backend_state()
if result.success:
msg = result.message
if result.needs_reboot:
msg += "\n\nUn redémarrage est nécessaire pour finaliser la jointure."
QMessageBox.information(self, "Jointure réussie", msg)
else:
QMessageBox.critical(self, "Échec de la jointure", result.message)
class AboutTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("JoinDomainGUI")
title.setStyleSheet("font-size: 18pt; font-weight: bold;")
layout.addWidget(title)
version = QLabel(f"Version {__version__}")
layout.addWidget(version)
description = QLabel(
"Diagnostic réseau et jointure de domaine Active Directory, "
"pour postes Linux (realmd) et Windows (Add-Computer)."
)
description.setWordWrap(True)
layout.addWidget(description)
backend = get_backend()
elevated = "élevé (root/administrateur)" if privileges.is_elevated() else "standard (non élevé)"
state = QLabel(
f"Plateforme détectée : {platform.system()} {platform.release()}\n"
f"Backend de jointure : {type(backend).__name__}\n"
f"Droits actuels : {elevated}"
)
layout.addWidget(state)
cli_title = QLabel("Utilisation en ligne de commande")
cli_title.setStyleSheet("font-weight: bold; margin-top: 12px;")
layout.addWidget(cli_title)
cli_hint = QPlainTextEdit(
"python main.py diagnose --domain h3adm.lan\n"
"python main.py join --domain h3adm.lan --username administrateur --password-stdin\n"
"python main.py install-prereqs\n"
"python main.py --help"
)
cli_hint.setReadOnly(True)
cli_hint.setMaximumHeight(100)
layout.addWidget(cli_hint)
layout.addStretch()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Diagnostic réseau & Jointure de domaine")
self.resize(760, 620)
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
if not privileges.is_elevated():
banner = QLabel(
"⚠ L'application ne tourne pas avec les droits administrateur/root. "
"Le diagnostic fonctionne, mais la jointure au domaine sera indisponible. "
+ privileges.elevation_hint()
)
banner.setWordWrap(True)
banner.setStyleSheet(
"background-color:#fff3cd; color:#664d03; padding:8px; border:1px solid #ffe69c;"
)
layout.addWidget(banner)
tabs = QTabWidget()
tabs.addTab(DiagnosticsTab(), "Diagnostic réseau")
tabs.addTab(JoinTab(), "Rejoindre le domaine")
tabs.addTab(AboutTab(), "À propos")
layout.addWidget(tabs)
+22
View File
@@ -0,0 +1,22 @@
"""Détection des privilèges d'exécution (root/Administrateur), indépendante de l'OS."""
import ctypes
import os
import platform
def is_elevated() -> bool:
"""True si le processus courant tourne avec les droits root (Linux) ou Administrateur (Windows)."""
if platform.system() == "Windows":
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
return hasattr(os, "geteuid") and os.geteuid() == 0
def elevation_hint() -> str:
"""Message à afficher à l'utilisateur pour élever l'application."""
if platform.system() == "Windows":
return "Relancez l'application via un clic droit → « Exécuter en tant qu'administrateur »."
return "Relancez l'application avec les droits root, par exemple : sudo python3 main.py"
+72
View File
@@ -0,0 +1,72 @@
"""Workers Qt exécutant diagnostics et jointure de domaine en arrière-plan,
pour ne jamais bloquer l'interface graphique."""
from __future__ import annotations
from typing import Optional
from PySide6.QtCore import QThread, Signal
from . import diagnostics
from .backend.base import DomainJoinBackend, JoinResult
class DiagnosticsWorker(QThread):
check_ready = Signal(object) # diagnostics.CheckResult
finished_all = Signal()
def __init__(self, domain: str, parent=None):
super().__init__(parent)
self.domain = domain
def run(self) -> None:
for result in diagnostics.run_diagnostics(self.domain):
self.check_ready.emit(result)
self.finished_all.emit()
class JoinWorker(QThread):
output_line = Signal(str)
finished_join = Signal(object) # backend.base.JoinResult
def __init__(
self,
backend: DomainJoinBackend,
domain: str,
username: str,
password: str,
ou: Optional[str],
computer_name: Optional[str],
parent=None,
):
super().__init__(parent)
self.backend = backend
self.domain = domain
self.username = username
self.password = password
self.ou = ou
self.computer_name = computer_name
def run(self) -> None:
result: JoinResult = self.backend.join(
self.domain,
self.username,
self.password,
ou=self.ou,
computer_name=self.computer_name,
on_output=self.output_line.emit,
)
self.finished_join.emit(result)
class InstallPrerequisitesWorker(QThread):
output_line = Signal(str)
finished_install = Signal(object) # backend.base.JoinResult
def __init__(self, backend: DomainJoinBackend, parent=None):
super().__init__(parent)
self.backend = backend
def run(self) -> None:
result: JoinResult = self.backend.install_prerequisites(on_output=self.output_line.emit)
self.finished_install.emit(result)