feat: sortie de domaine, et corrections issues d'un echec reel de jointure

Sortie de domaine (demandee) :
- Bouton « Quitter le domaine » et commande CLI `leave`, via Remove-Computer
  sous Windows et realm leave sous Linux.
- Nettoyage optionnel des paquets sous Linux. packagekit est exclu de la
  liste : il sert aussi au gestionnaire de logiciels du bureau.
- Le champ « Nom de la machine » passe en lecture seule : il ne peut pas
  etre modifie depuis cette fenetre.

Corrections tirees de la sortie PowerShell rapportee :
- Les barres de progression etaient serialisees en CLIXML et polluaient le
  journal ; les accents arrivaient illisibles (console OEM lue en cp1252).
  Progression desactivee, console en UTF-8, CLIXML residuel filtre.
- Le marqueur interne JOIN_FAILURE: n'apparait plus dans le message affiche.

Diagnostic :
- Ajout du SRV `_ldap._tcp.dc._msdcs`, celui que DsGetDcName utilise pour
  localiser un DC. Son absence donne exactement l'erreur rencontree, et le
  diagnostic ne le verifiait pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 07:11:20 +02:00
co-authored by Claude Opus 5
parent 04a7b5dc64
commit eba4d0b843
9 changed files with 547 additions and 78 deletions
+109 -9
View File
@@ -12,6 +12,12 @@ from .base import DomainJoinBackend, JoinResult, OutputCallback
REQUIRED_PACKAGES = ["realmd", "sssd", "adcli", "samba-common-bin", "packagekit"]
INSTALL_HINT = "sudo apt install " + " ".join(REQUIRED_PACKAGES)
# Paquets retires lors d'une sortie de domaine avec nettoyage. `packagekit` est
# volontairement exclu de cette liste : il est installe comme prerequis mais sert
# aussi au gestionnaire de logiciels du bureau, et le retirer casserait des
# fonctions sans rapport avec le domaine.
REMOVABLE_PACKAGES = ["realmd", "sssd", "adcli", "samba-common-bin"]
class LinuxRealmdBackend(DomainJoinBackend):
def is_available(self) -> tuple[bool, str]:
@@ -22,18 +28,13 @@ class LinuxRealmdBackend(DomainJoinBackend):
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.")
def _run_apt(self, commands: list[list[str]], emit: OutputCallback) -> Optional[JoinResult]:
"""Enchaîne des commandes apt-get. Retourne None si tout a réussi,
sinon le JoinResult d'échec décrivant la commande fautive."""
env = os.environ.copy()
env["DEBIAN_FRONTEND"] = "noninteractive"
for cmd in (["apt-get", "update"], ["apt-get", "install", "-y", *REQUIRED_PACKAGES]):
for cmd in commands:
emit(f"$ {' '.join(cmd)}")
try:
process = subprocess.Popen(
@@ -58,6 +59,21 @@ class LinuxRealmdBackend(DomainJoinBackend):
message=f"Échec de « {' '.join(cmd)} » (code retour {returncode}). "
"Vérifiez que l'application tourne avec les droits root.",
)
return 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.")
failure = self._run_apt(
[["apt-get", "update"], ["apt-get", "install", "-y", *REQUIRED_PACKAGES]], emit
)
if failure:
return failure
available, _ = self.is_available()
if available:
@@ -132,3 +148,87 @@ class LinuxRealmdBackend(DomainJoinBackend):
success=False,
message=f"Échec de la jointure au domaine (code retour {returncode}). Voir le journal ci-dessus.",
)
def can_leave(self) -> bool:
return True
def leave(
self,
domain: str,
username: str,
password: str,
remove_packages: bool = False,
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)
# `realm leave` sait travailler sans identifiants (il se sert du compte
# machine local). Fournir un compte permet en plus de supprimer l'objet
# ordinateur dans l'annuaire ; on ne l'impose donc pas.
cmd = ["realm", "-v", "leave"]
if username:
cmd += ["--user", username]
cmd.append(domain)
emit(f"$ {' '.join(cmd)}")
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:
if username:
process.stdin.write(password + "\n")
process.stdin.close()
except BrokenPipeError:
pass
if process.stdout:
for line in process.stdout:
emit(line.rstrip("\n"))
if process.wait() != 0:
return JoinResult(
success=False,
message=f"Échec de la sortie du domaine {domain}. Voir le journal ci-dessus.",
)
message = f"Le poste a quitté le domaine {domain}."
if remove_packages:
if shutil.which("apt-get") is None:
message += " Les paquets n'ont pas été retirés : apt-get est introuvable."
else:
emit("Retrait des paquets devenus inutiles...")
failure = self._run_apt(
[
["apt-get", "remove", "--purge", "-y", *REMOVABLE_PACKAGES],
["apt-get", "autoremove", "--purge", "-y"],
],
emit,
)
if failure:
# La sortie du domaine, elle, a bien eu lieu : on le dit
# plutot que de faire passer l'ensemble pour un echec.
return JoinResult(
success=True,
message=f"{message} En revanche le nettoyage des paquets a échoué : "
f"{failure.message}",
needs_reboot=True,
)
message += " Paquets retirés : " + ", ".join(REMOVABLE_PACKAGES) + "."
return JoinResult(success=True, message=message, needs_reboot=True)