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
+20
View File
@@ -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
+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)
+176 -64
View File
@@ -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("<Objs ")
_PREAMBLE = [
"$ErrorActionPreference = 'Stop'",
# Sans cela, les barres de progression (« Préparation des modules… ») sont
# serialisees en CLIXML sur la sortie d'erreur et polluent le journal.
"$ProgressPreference = 'SilentlyContinue'",
# La console Windows sort en codepage OEM (850/437) : sans cette bascule en
# UTF-8, les accents des messages d'erreur arrivent illisibles cote Python.
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
]
def _credential_lines(username: str) -> 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,
)
+53
View File
@@ -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)."
)
+5
View File
@@ -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}",
]
+113 -3
View File
@@ -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 <nom>"
)
)
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:
+31
View File
@@ -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)