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:
@@ -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"]
|
||||
@@ -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.")
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user