Files
tuxgyverandClaude Opus 5 eba4d0b843 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>
2026-09-09 07:11:20 +02:00

448 lines
17 KiB
Python

"""Interface graphique PySide6 : diagnostic réseau AD + jointure de domaine."""
from __future__ import annotations
import platform
import socket
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from . import __version__, diagnostics, privileges
from .backend import get_backend
from .backend.linux_realmd import REMOVABLE_PACKAGES
from .worker import DiagnosticsWorker, InstallPrerequisitesWorker, JoinWorker, LeaveWorker
DEFAULT_DOMAIN = "h3adm.lan"
_STATUS_COLORS = {
diagnostics.Status.OK: QColor("#1e7e34"),
diagnostics.Status.WARN: QColor("#b8860b"),
diagnostics.Status.FAIL: QColor("#c0392b"),
}
class DiagnosticsTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._worker: DiagnosticsWorker | None = None
self._results: list[diagnostics.CheckResult] = []
layout = QVBoxLayout(self)
form = QFormLayout()
self.domain_edit = QLineEdit(DEFAULT_DOMAIN)
form.addRow("Domaine :", self.domain_edit)
layout.addLayout(form)
buttons = QHBoxLayout()
self.run_button = QPushButton("Lancer le diagnostic")
self.run_button.clicked.connect(self._start_diagnostics)
buttons.addWidget(self.run_button)
self.copy_button = QPushButton("Copier le rapport")
self.copy_button.clicked.connect(self._copy_report)
self.copy_button.setEnabled(False)
buttons.addWidget(self.copy_button)
buttons.addStretch()
layout.addLayout(buttons)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Vérification", "Statut", "Détail"])
self.tree.setColumnWidth(0, 260)
self.tree.setColumnWidth(1, 130)
layout.addWidget(self.tree)
self.summary_label = QLabel("")
layout.addWidget(self.summary_label)
def _start_diagnostics(self) -> None:
domain = self.domain_edit.text().strip()
if not domain:
QMessageBox.warning(self, "Domaine manquant", "Veuillez saisir un domaine à diagnostiquer.")
return
self.tree.clear()
self._results = []
self.run_button.setEnabled(False)
self.copy_button.setEnabled(False)
self.summary_label.setText("Diagnostic en cours...")
self._worker = DiagnosticsWorker(domain)
self._worker.check_ready.connect(self._on_check_ready)
self._worker.finished_all.connect(self._on_finished)
self._worker.start()
def _on_check_ready(self, result: diagnostics.CheckResult) -> None:
self._results.append(result)
item = QTreeWidgetItem([result.name, result.status.value, result.detail])
color = _STATUS_COLORS.get(result.status)
if color:
item.setForeground(1, color)
self.tree.addTopLevelItem(item)
def _on_finished(self) -> None:
self.run_button.setEnabled(True)
self.copy_button.setEnabled(True)
ok = sum(1 for r in self._results if r.status == diagnostics.Status.OK)
warn = sum(1 for r in self._results if r.status == diagnostics.Status.WARN)
fail = sum(1 for r in self._results if r.status == diagnostics.Status.FAIL)
self.summary_label.setText(f"Terminé : {ok} OK, {warn} avertissement(s), {fail} échec(s).")
def _copy_report(self) -> None:
lines = [f"Diagnostic réseau pour le domaine {self.domain_edit.text().strip()}", ""]
for r in self._results:
lines.append(f"[{r.status.value}] {r.name}{r.detail}")
QApplication.clipboard().setText("\n".join(lines))
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)
form = QFormLayout()
self.domain_edit = QLineEdit(DEFAULT_DOMAIN)
self.username_edit = QLineEdit()
self.username_edit.setPlaceholderText("ex : administrateur@h3adm.lan")
self.password_edit = QLineEdit()
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)
self.prereq_label.setVisible(False)
layout.addWidget(self.prereq_label)
buttons = QHBoxLayout()
self.join_button = QPushButton("Rejoindre le domaine")
self.join_button.clicked.connect(self._start_join)
buttons.addWidget(self.join_button)
self.install_button = QPushButton("Installer les prérequis")
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)
self.log = QPlainTextEdit()
self.log.setReadOnly(True)
layout.addWidget(self.log)
self._refresh_backend_state()
def _refresh_backend_state(self) -> None:
"""Met à jour l'état des boutons selon les privilèges et la disponibilité du backend."""
elevated = privileges.is_elevated()
available, reason = self.backend.is_available()
self.install_button.setVisible(not available and self.backend.can_install_prerequisites())
self.install_button.setEnabled(elevated)
if not elevated:
self.install_button.setToolTip(
"Droits insuffisants pour installer les prérequis.\n" + privileges.elevation_hint()
)
else:
self.install_button.setToolTip("")
self.prereq_label.setVisible(not available)
if not available:
self.prereq_label.setText("⚠ " + reason)
if not elevated:
self.join_button.setEnabled(False)
self.join_button.setToolTip(
"Droits insuffisants pour rejoindre le domaine.\n" + privileges.elevation_hint()
)
elif not available:
self.join_button.setEnabled(False)
self.join_button.setToolTip("Prérequis manquants — installez-les d'abord.")
else:
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,
"Confirmer l'installation",
"Installer les paquets système requis pour la jointure de domaine "
"(realmd, sssd, adcli, samba-common-bin, packagekit) ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
self.log.clear()
self.install_button.setEnabled(False)
self.join_button.setEnabled(False)
self._install_worker = InstallPrerequisitesWorker(self.backend)
self._install_worker.output_line.connect(self.log.appendPlainText)
self._install_worker.finished_install.connect(self._on_install_finished)
self._install_worker.start()
def _on_install_finished(self, result) -> None:
if result.success:
QMessageBox.information(self, "Installation terminée", result.message)
else:
QMessageBox.critical(self, "Échec de l'installation", result.message)
self._refresh_backend_state()
def _start_join(self) -> None:
domain = self.domain_edit.text().strip()
username = self.username_edit.text().strip()
password = self.password_edit.text()
ou = self.ou_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.")
return
available, reason = self.backend.is_available()
if not available:
QMessageBox.critical(self, "Backend indisponible", reason)
return
confirm = QMessageBox.question(
self,
"Confirmer la jointure",
f"Rejoindre le domaine « {domain} » avec l'utilisateur « {username} » ?\n\n"
"Cette action modifie la configuration système du poste et nécessitera "
"probablement un redémarrage.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
self.log.clear()
self.join_button.setEnabled(False)
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:
msg = result.message
if result.needs_reboot:
msg += "\n\nUn redémarrage est nécessaire pour finaliser la jointure."
QMessageBox.information(self, "Jointure réussie", msg)
else:
QMessageBox.critical(self, "Échec de la jointure", result.message)
class AboutTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("JoinDomainGUI")
title.setStyleSheet("font-size: 18pt; font-weight: bold;")
layout.addWidget(title)
version = QLabel(f"Version {__version__}")
layout.addWidget(version)
description = QLabel(
"Diagnostic réseau et jointure de domaine Active Directory, "
"pour postes Linux (realmd) et Windows (Add-Computer)."
)
description.setWordWrap(True)
layout.addWidget(description)
backend = get_backend()
elevated = "élevé (root/administrateur)" if privileges.is_elevated() else "standard (non élevé)"
state = QLabel(
f"Plateforme détectée : {platform.system()} {platform.release()}\n"
f"Backend de jointure : {type(backend).__name__}\n"
f"Droits actuels : {elevated}"
)
layout.addWidget(state)
cli_title = QLabel("Utilisation en ligne de commande")
cli_title.setStyleSheet("font-weight: bold; margin-top: 12px;")
layout.addWidget(cli_title)
cli_hint = QPlainTextEdit(
"python main.py diagnose --domain h3adm.lan\n"
"python main.py join --domain h3adm.lan --username administrateur --password-stdin\n"
"python main.py install-prereqs\n"
"python main.py --help"
)
cli_hint.setReadOnly(True)
cli_hint.setMaximumHeight(100)
layout.addWidget(cli_hint)
layout.addStretch()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Diagnostic réseau & Jointure de domaine")
self.resize(760, 620)
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
if not privileges.is_elevated():
banner = QLabel(
"⚠ L'application ne tourne pas avec les droits administrateur/root. "
"Le diagnostic fonctionne, mais la jointure au domaine sera indisponible. "
+ privileges.elevation_hint()
)
banner.setWordWrap(True)
banner.setStyleSheet(
"background-color:#fff3cd; color:#664d03; padding:8px; border:1px solid #ffe69c;"
)
layout.addWidget(banner)
tabs = QTabWidget()
tabs.addTab(DiagnosticsTab(), "Diagnostic réseau")
tabs.addTab(JoinTab(), "Rejoindre le domaine")
tabs.addTab(AboutTab(), "À propos")
layout.addWidget(tabs)