From eba4d0b843fedcbffc267e122036042f3aa1bc0b Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 9 Sep 2026 07:11:20 +0200 Subject: [PATCH] feat: sortie de domaine, et corrections issues d'un echec reel de jointure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 31 +++ README.md | 11 +- domainjoin/backend/base.py | 20 ++ domainjoin/backend/linux_realmd.py | 118 ++++++++++- domainjoin/backend/windows_addcomputer.py | 240 ++++++++++++++++------ domainjoin/cli.py | 53 +++++ domainjoin/diagnostics.py | 5 + domainjoin/gui.py | 116 ++++++++++- domainjoin/worker.py | 31 +++ 9 files changed, 547 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc06dc..baa840c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [Non publié] + +### Added +- Bouton « Quitter le domaine » dans l'interface et commande `leave` en ligne de + commande. Sous Windows via `Remove-Computer` (le poste rejoint le groupe de + travail WORKGROUP), sous Linux via `realm leave`. +- Option de nettoyage à la sortie du domaine sous Linux (case à cocher, ou + `--remove-packages`) : désinstalle `realmd`, `sssd`, `adcli` et + `samba-common-bin`. `packagekit` est délibérément conservé — il est installé + comme prérequis mais sert aussi au gestionnaire de logiciels du bureau. +- Vérification de l'enregistrement SRV `_ldap._tcp.dc._msdcs`, celui que Windows + interroge (DsGetDcName) pour localiser un contrôleur lors d'une jointure. Son + absence provoque « Le domaine spécifié n'existe pas ou n'a pas pu être + contacté » alors même que le domaine résout et que les DC répondent au ping : + le diagnostic ne pouvait pas le signaler. + +### Changed +- Le champ « Nom de la machine » est désormais affiché en lecture seule : il ne + peut pas être modifié depuis cette fenêtre (sous Linux `realm join` reprend le + nom d'hôte système, sous Windows le renommage relève des paramètres système). + Une infobulle indique la commande à utiliser selon la plateforme. + +### Fixed +- La sortie de PowerShell était polluée par la sérialisation `#< CLIXML` de ses + barres de progression, et les accents des messages d'erreur arrivaient + illisibles (console en codepage OEM lue comme du cp1252). Les indicateurs de + progression sont désactivés, la console bascule en UTF-8, et les fragments + CLIXML résiduels sont filtrés. +- Le marqueur interne `JOIN_FAILURE:` n'est plus affiché en préfixe du message + d'erreur montré à l'utilisateur. + ## [0.1.4] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index a34bdda..594d63e 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,12 @@ le backend étant choisi automatiquement selon la plateforme. (LDAP / Kerberos), ping des contrôleurs de domaine, test des ports AD (53, 88, 389, 445, 464, 636, 3268), synchronisation NTP et présence des outils système requis. -- **Jointure de domaine** avec unité d'organisation et nom de machine - optionnels, et détection du besoin de redémarrage. +- **Jointure de domaine** avec unité d'organisation optionnelle et détection du + besoin de redémarrage. Le nom de la machine est affiché mais non modifiable : + il vient du nom d'hôte système (Linux) ou des paramètres système (Windows). +- **Sortie de domaine** (`Remove-Computer` sous Windows, `realm leave` sous + Linux), avec sous Linux une option de désinstallation des paquets devenus + inutiles. - **Installation des prérequis Linux** manquants en un clic ou une commande (`realmd`, `sssd`, `adcli`, `samba-common-bin`, `packagekit` via `apt-get`). - **Interface graphique** à trois onglets : Diagnostic réseau, Rejoindre le @@ -54,6 +58,9 @@ make diagnose DOMAIN=exemple.lan echo 'motdepasse' | sudo python main.py join \ --domain exemple.lan --username administrateur --password-stdin +# Sortie de domaine (--username optionnel sous Linux, requis sous Windows) +sudo python main.py leave --domain exemple.lan --remove-packages + # Installation des prérequis système (Linux / apt uniquement) sudo python main.py install-prereqs ``` diff --git a/domainjoin/backend/base.py b/domainjoin/backend/base.py index 8b814c5..6cd94b6 100644 --- a/domainjoin/backend/base.py +++ b/domainjoin/backend/base.py @@ -38,6 +38,26 @@ class DomainJoinBackend(ABC): 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_leave(self) -> bool: + """True si ce backend sait faire quitter le domaine au poste.""" + return False + + def leave( + self, + domain: str, + username: str, + password: str, + remove_packages: bool = False, + on_output: Optional[OutputCallback] = None, + ) -> JoinResult: + """Fait quitter le domaine au poste. Comme pour join(), le mot de passe + ne passe jamais par la ligne de commande. + + `remove_packages` demande en plus de désinstaller les paquets système + devenus inutiles — sans effet sur les backends qui n'en installent pas. + """ + return JoinResult(success=False, message="Sortie de domaine non prise en charge pour ce backend.") + def can_install_prerequisites(self) -> bool: """True si ce backend sait installer automatiquement les outils manquants.""" return False diff --git a/domainjoin/backend/linux_realmd.py b/domainjoin/backend/linux_realmd.py index f8104d0..521be91 100644 --- a/domainjoin/backend/linux_realmd.py +++ b/domainjoin/backend/linux_realmd.py @@ -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) diff --git a/domainjoin/backend/windows_addcomputer.py b/domainjoin/backend/windows_addcomputer.py index d2214ed..981efa2 100644 --- a/domainjoin/backend/windows_addcomputer.py +++ b/domainjoin/backend/windows_addcomputer.py @@ -1,4 +1,5 @@ -"""Backend de jointure de domaine pour Windows, via la cmdlet PowerShell Add-Computer.""" +"""Backend de jointure/sortie de domaine pour Windows, via les cmdlets PowerShell +`Add-Computer` et `Remove-Computer`.""" from __future__ import annotations @@ -11,8 +12,12 @@ from typing import Optional from .base import DomainJoinBackend, JoinResult, OutputCallback _PASSWORD_ENV_VAR = "DOMAINJOIN_PWD" -_SUCCESS_MARKER = "JOIN_SUCCESS" -_FAILURE_PREFIX = "JOIN_FAILURE:" +_SUCCESS_MARKER = "DOMAINJOIN_OK" +_FAILURE_PREFIX = "DOMAINJOIN_ERROR:" + +# En-tête du flux d'objets sérialisés que PowerShell émet sur sa sortie d'erreur +# lorsqu'elle est redirigée. Ce n'est pas un message pour l'utilisateur. +_CLIXML_HEADER = "#< CLIXML" def _ps_quote(value: str) -> str: @@ -27,65 +32,98 @@ def _encode_command(script: str) -> str: `-Command -` fait analyser l'entrée standard ligne par ligne : la dernière instruction d'un bloc `try`/`catch` restait en attente dans le parseur et n'était jamais exécutée à la fermeture de stdin — PowerShell rendait alors - la main avec le code 0 sans avoir rien fait, et la jointure echouait sans - le moindre message. Le mot de passe reste hors du script (variable - d'environnement) : la ligne de commande ne l'expose pas. + la main avec le code 0 sans avoir rien fait. Le mot de passe reste hors du + script (variable d'environnement) : la ligne de commande ne l'expose pas. """ return base64.b64encode(script.encode("utf-16-le")).decode("ascii") +def _is_noise(line: str) -> bool: + """Écarte la sérialisation CLIXML que PowerShell mêle à sa sortie.""" + stripped = line.strip() + return stripped.startswith(_CLIXML_HEADER) or stripped.startswith(" list[str]: + return [ + f"$username = '{_ps_quote(username)}'", + f"$password = $env:{_PASSWORD_ENV_VAR}", + "if ([string]::IsNullOrEmpty($password)) { " + f"Write-Output '{_FAILURE_PREFIX} mot de passe manquant.'; exit 2 }}", + "$securePwd = ConvertTo-SecureString -String $password -AsPlainText -Force", + "$cred = New-Object System.Management.Automation.PSCredential($username, $securePwd)", + ] + + +def _guarded(body: list[str]) -> list[str]: + return [ + "try {", + *[f" {line}" for line in body], + f" Write-Output '{_SUCCESS_MARKER}'", + "} catch {", + f" Write-Output ('{_FAILURE_PREFIX} ' + $_.Exception.Message)", + " exit 1", + "}", + ] + + 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: + # ------------------------------------------------------------------ scripts + + def _join_script(self, domain: str, username: str, ou: Optional[str], computer_name: Optional[str]) -> str: lines = [ - "$ErrorActionPreference = 'Stop'", + *_PREAMBLE, 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)", + *_credential_lines(username), "$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", - "}", - ] + lines += _guarded(["Add-Computer @params -ErrorAction Stop"]) 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 _leave_script(self, username: str, workgroup: str) -> str: + lines = [ + *_PREAMBLE, + *_credential_lines(username), + f"$workgroup = '{_ps_quote(workgroup)}'", + ] + lines += _guarded( + [ + "Remove-Computer -UnjoinDomainCredential $cred -WorkgroupName $workgroup " + "-Force -Restart:$false -ErrorAction Stop | Out-Null" + ] + ) + return "\n".join(lines) - def emit(line: str) -> None: - if on_output: - on_output(line) + # ------------------------------------------------------------------ execution - script = self._build_script(domain, username, ou, computer_name) - emit(f"Exécution de Add-Computer pour le domaine {domain}...") + def _run_powershell( + self, script: str, password: str, emit: OutputCallback + ) -> tuple[Optional[int], list[str]]: + """Exécute un script PowerShell et retourne (code retour, lignes utiles). + Un code retour None signale que PowerShell n'a pas pu être lancé ; le + motif est alors la première ligne retournée. + """ env = os.environ.copy() env[_PASSWORD_ENV_VAR] = password @@ -104,42 +142,116 @@ class WindowsAddComputerBackend(DomainJoinBackend): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + encoding="utf-8", + errors="replace", bufsize=1, env=env, creationflags=subprocess.CREATE_NO_WINDOW, ) except OSError as exc: - return JoinResult(success=False, message=f"Impossible de lancer PowerShell : {exc}") + return None, [f"Impossible de lancer PowerShell : {exc}"] finally: + # Le processus fils a déjà reçu sa copie de l'environnement : on + # efface le mot de passe de la nôtre sans l'en priver. env[_PASSWORD_ENV_VAR] = "" - output_lines: list[str] = [] + lines: list[str] = [] if process.stdout: - for line in process.stdout: - line = line.rstrip("\n") - output_lines.append(line) + for raw in process.stdout: + line = raw.rstrip("\n") + if _is_noise(line): + continue + lines.append(line) emit(line) + return process.wait(), lines - returncode = process.wait() - full_output = "\n".join(output_lines) + def _interpret( + self, + returncode: Optional[int], + lines: list[str], + success_message: str, + needs_reboot: bool, + ) -> JoinResult: + if returncode is None: + return JoinResult(success=False, message=lines[0]) - 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, + if returncode == 0 and any(line.strip() == _SUCCESS_MARKER for line in lines): + return JoinResult(success=True, message=success_message, needs_reboot=needs_reboot) + + detail = next((l for l in lines if l.strip().startswith(_FAILURE_PREFIX)), None) + if detail: + # Le marqueur est un détail d'implémentation : l'utilisateur ne voit + # que le message d'erreur de PowerShell. + return JoinResult(success=False, message=detail.strip()[len(_FAILURE_PREFIX) :].strip()) + + # Sans ce report de la sortie brute, un echec inattendu se resumerait a + # « code retour 0 », indiagnosticable. Le mot de passe n'y apparait + # jamais : il ne transite que par l'environnement du sous-processus. + output = "\n".join(l for l in lines if l.strip() and l.strip() != _SUCCESS_MARKER) + return JoinResult( + success=False, + message=f"Échec (code retour {returncode}).\nSortie de PowerShell : " + f"{output.strip() or 'aucune sortie produite par PowerShell'}", + ) + + # ------------------------------------------------------------------ API + + 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) + + emit: OutputCallback = on_output or (lambda _line: None) + emit(f"Exécution de Add-Computer pour le domaine {domain}...") + + returncode, lines = self._run_powershell( + self._join_script(domain, username, ou, computer_name), password, emit + ) + return self._interpret( + returncode, + lines, + f"Le poste a rejoint le domaine {domain} avec succès. Un redémarrage est requis.", + needs_reboot=True, + ) + + 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) + + emit: OutputCallback = on_output or (lambda _line: None) + if remove_packages: + emit( + "Note : la désinstallation de paquets ne concerne que Linux. " + "Aucun composant Windows n'est retiré." ) + emit(f"Exécution de Remove-Computer pour quitter le domaine {domain}...") - failure_detail = next((l for l in output_lines if l.strip().startswith(_FAILURE_PREFIX)), None) - if failure_detail: - message = failure_detail - else: - # Sans ce report de la sortie brute, un echec inattendu se resumait a - # « code retour 0 », indiagnosticable. Le mot de passe n'apparait - # jamais dans cette sortie : il ne transite que par l'environnement. - detail = full_output.strip() or "aucune sortie produite par PowerShell" - message = ( - f"Échec de la jointure au domaine (code retour {returncode}).\n" - f"Sortie de PowerShell : {detail}" - ) - return JoinResult(success=False, message=message) + returncode, lines = self._run_powershell( + self._leave_script(username, "WORKGROUP"), password, emit + ) + return self._interpret( + returncode, + lines, + f"Le poste a quitté le domaine {domain} et rejoint le groupe de travail " + "WORKGROUP. Un redémarrage est requis.", + needs_reboot=True, + ) diff --git a/domainjoin/cli.py b/domainjoin/cli.py index b658fba..d36407f 100644 --- a/domainjoin/cli.py +++ b/domainjoin/cli.py @@ -166,6 +166,41 @@ def _cmd_join(args: argparse.Namespace) -> int: return 0 if result.success else 1 +def _cmd_leave(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 + if not backend.can_leave(): + _print_error("Ce backend ne prend pas en charge la sortie de domaine.") + return 2 + + password = _resolve_password(args) if args.username else "" + + warning = f"Faire quitter le domaine « {args.domain} » à ce poste ?" + if args.remove_packages: + warning += " Les paquets système liés au domaine seront également désinstallés." + if not _confirm(warning, args.yes): + print(_colorize("Annulé.", _Color.YELLOW, sys.stdout)) + return 1 + + result = backend.leave( + args.domain, + args.username or "", + password, + remove_packages=args.remove_packages, + on_output=print, + ) + _print_result(result) + if result.success and result.needs_reboot: + print(_colorize("Un redémarrage est nécessaire pour finaliser la sortie.", _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 @@ -207,6 +242,24 @@ def build_parser() -> argparse.ArgumentParser: p_join.add_argument("-y", "--yes", action="store_true", help="Ne pas demander de confirmation.") p_join.set_defaults(func=_cmd_join) + p_leave = sub.add_parser("leave", help="Quitte le domaine (nécessite les droits root/administrateur).") + p_leave.add_argument("--domain", default=DEFAULT_DOMAIN, help=f"Domaine à quitter (défaut : {DEFAULT_DOMAIN}).") + p_leave.add_argument( + "--username", + default=None, + help="Compte autorisé à retirer la machine de l'annuaire. Optionnel sous Linux " + "(realm leave sait utiliser le compte machine local), requis sous Windows.", + ) + p_leave.add_argument("--password", help="Mot de passe (déconseillé). Préférez --password-stdin.") + p_leave.add_argument("--password-stdin", action="store_true", help="Lit le mot de passe depuis l'entrée standard.") + p_leave.add_argument( + "--remove-packages", + action="store_true", + help="Désinstalle aussi les paquets système devenus inutiles (Linux/apt uniquement).", + ) + p_leave.add_argument("-y", "--yes", action="store_true", help="Ne pas demander de confirmation.") + p_leave.set_defaults(func=_cmd_leave) + p_install = sub.add_parser( "install-prereqs", help="Installe les outils système requis pour la jointure (Linux/apt uniquement)." ) diff --git a/domainjoin/diagnostics.py b/domainjoin/diagnostics.py index 8ff68a0..f57b390 100644 --- a/domainjoin/diagnostics.py +++ b/domainjoin/diagnostics.py @@ -59,6 +59,11 @@ AD_PORTS = { SRV_RECORDS = [ "_ldap._tcp.{domain}", "_kerberos._tcp.{domain}", + # C'est ce dernier enregistrement que Windows interroge (DsGetDcName) pour + # localiser un controleur de domaine lors d'une jointure. Son absence donne + # « Le domaine specifie n'existe pas ou n'a pas pu etre contacte », alors + # meme que le domaine resout et que les DC repondent au ping. + "_ldap._tcp.dc._msdcs.{domain}", "_kerberos._tcp.dc._msdcs.{domain}", ] diff --git a/domainjoin/gui.py b/domainjoin/gui.py index b3a29a5..0bca9d3 100644 --- a/domainjoin/gui.py +++ b/domainjoin/gui.py @@ -9,6 +9,7 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QApplication, + QCheckBox, QFormLayout, QHBoxLayout, QLabel, @@ -26,7 +27,8 @@ from PySide6.QtWidgets import ( from . import __version__, diagnostics, privileges from .backend import get_backend -from .worker import DiagnosticsWorker, InstallPrerequisitesWorker, JoinWorker +from .backend.linux_realmd import REMOVABLE_PACKAGES +from .worker import DiagnosticsWorker, InstallPrerequisitesWorker, JoinWorker, LeaveWorker DEFAULT_DOMAIN = "h3adm.lan" @@ -114,6 +116,7 @@ class JoinTab(QWidget): def __init__(self, parent=None): super().__init__(parent) self._worker: JoinWorker | None = None + self._leave_worker: LeaveWorker | None = None self.backend = get_backend() layout = QVBoxLayout(self) @@ -126,14 +129,37 @@ class JoinTab(QWidget): self.password_edit.setEchoMode(QLineEdit.EchoMode.Password) self.ou_edit = QLineEdit() self.ou_edit.setPlaceholderText("optionnel, ex : OU=Postes,DC=h3adm,DC=lan") + # Le nom de machine ne se change pas depuis cette fenetre : sous Linux + # `realm join` reprend le hostname systeme, et sous Windows le renommage + # releve des parametres systeme. Le champ reste affiche, en lecture + # seule, pour montrer le nom sous lequel le poste apparaitra. self.computer_name_edit = QLineEdit(socket.gethostname()) + self.computer_name_edit.setEnabled(False) + self.computer_name_edit.setToolTip( + "Le nom de la machine ne peut pas être modifié depuis cette fenêtre.\n" + + ( + "Renommez le poste depuis les paramètres système de Windows, puis redémarrez." + if platform.system() == "Windows" + else "Changez le nom d'hôte avec : sudo hostnamectl set-hostname " + ) + ) 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) + + self.remove_packages_check = QCheckBox( + "Désinstaller aussi les paquets système du domaine en quittant" + ) + self.remove_packages_check.setVisible(platform.system() != "Windows") + self.remove_packages_check.setToolTip( + "Retire " + ", ".join(REMOVABLE_PACKAGES) + ".\n" + "packagekit est conservé : il sert aussi au gestionnaire de logiciels." + ) layout.addLayout(form) + layout.addWidget(self.remove_packages_check) self.prereq_label = QLabel("") self.prereq_label.setWordWrap(True) @@ -148,6 +174,9 @@ class JoinTab(QWidget): self.install_button.clicked.connect(self._start_install) self.install_button.setVisible(False) buttons.addWidget(self.install_button) + self.leave_button = QPushButton("Quitter le domaine") + self.leave_button.clicked.connect(self._start_leave) + buttons.addWidget(self.leave_button) buttons.addStretch() layout.addLayout(buttons) @@ -187,6 +216,21 @@ class JoinTab(QWidget): self.join_button.setEnabled(True) self.join_button.setToolTip("") + can_leave = self.backend.can_leave() + self.leave_button.setVisible(can_leave) + self.remove_packages_check.setVisible(can_leave and platform.system() != "Windows") + if not elevated: + self.leave_button.setEnabled(False) + self.leave_button.setToolTip( + "Droits insuffisants pour quitter le domaine.\n" + privileges.elevation_hint() + ) + elif not available: + self.leave_button.setEnabled(False) + self.leave_button.setToolTip("Prérequis manquants — installez-les d'abord.") + else: + self.leave_button.setEnabled(True) + self.leave_button.setToolTip("") + def _start_install(self) -> None: confirm = QMessageBox.question( self, @@ -220,7 +264,6 @@ class JoinTab(QWidget): 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.") @@ -246,11 +289,78 @@ class JoinTab(QWidget): self.log.clear() self.join_button.setEnabled(False) - self._worker = JoinWorker(self.backend, domain, username, password, ou, computer_name) + self._worker = JoinWorker(self.backend, domain, username, password, ou, None) self._worker.output_line.connect(self.log.appendPlainText) self._worker.finished_join.connect(self._on_join_finished) self._worker.start() + def _start_leave(self) -> None: + domain = self.domain_edit.text().strip() + username = self.username_edit.text().strip() + password = self.password_edit.text() + remove_packages = self.remove_packages_check.isChecked() and self.remove_packages_check.isVisible() + + if not domain: + QMessageBox.warning(self, "Champ manquant", "Le domaine est requis.") + return + + # Sous Windows, Remove-Computer exige un compte autorise a retirer + # l'objet ordinateur ; sous Linux, realm leave s'en passe. + if platform.system() == "Windows" and (not username or not password): + QMessageBox.warning( + self, + "Identifiants requis", + "Sous Windows, quitter le domaine demande un compte du domaine " + "autorisé à retirer cette machine de l'annuaire.", + ) + return + + available, reason = self.backend.is_available() + if not available: + QMessageBox.critical(self, "Backend indisponible", reason) + return + + details = ( + f"Faire quitter le domaine « {domain} » à ce poste ?\n\n" + "Les comptes du domaine ne pourront plus s'y connecter et un " + "redémarrage sera nécessaire." + ) + if remove_packages: + details += ( + "\n\nLes paquets suivants seront également désinstallés :\n" + + ", ".join(REMOVABLE_PACKAGES) + + "\n\nAssurez-vous qu'aucune session de compte du domaine n'est ouverte." + ) + + confirm = QMessageBox.question( + self, + "Confirmer la sortie du domaine", + details, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if confirm != QMessageBox.StandardButton.Yes: + return + + self.log.clear() + self.join_button.setEnabled(False) + self.leave_button.setEnabled(False) + + self._leave_worker = LeaveWorker(self.backend, domain, username, password, remove_packages) + self._leave_worker.output_line.connect(self.log.appendPlainText) + self._leave_worker.finished_leave.connect(self._on_leave_finished) + self._leave_worker.start() + + def _on_leave_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 sortie." + QMessageBox.information(self, "Sortie du domaine réussie", msg) + else: + QMessageBox.critical(self, "Échec de la sortie du domaine", result.message) + def _on_join_finished(self, result) -> None: self._refresh_backend_state() if result.success: diff --git a/domainjoin/worker.py b/domainjoin/worker.py index 020af3f..5b87ec7 100644 --- a/domainjoin/worker.py +++ b/domainjoin/worker.py @@ -70,3 +70,34 @@ class InstallPrerequisitesWorker(QThread): def run(self) -> None: result: JoinResult = self.backend.install_prerequisites(on_output=self.output_line.emit) self.finished_install.emit(result) + + +class LeaveWorker(QThread): + output_line = Signal(str) + finished_leave = Signal(object) # backend.base.JoinResult + + def __init__( + self, + backend: DomainJoinBackend, + domain: str, + username: str, + password: str, + remove_packages: bool, + parent=None, + ): + super().__init__(parent) + self.backend = backend + self.domain = domain + self.username = username + self.password = password + self.remove_packages = remove_packages + + def run(self) -> None: + result: JoinResult = self.backend.leave( + self.domain, + self.username, + self.password, + remove_packages=self.remove_packages, + on_output=self.output_line.emit, + ) + self.finished_leave.emit(result)