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
+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)