2 Commits
Author SHA1 Message Date
tuxgyverandClaude Opus 5 6fd7e71082 chore: version 0.1.5
Livre la sortie de domaine (bouton et commande `leave`), le nettoyage
optionnel des paquets sous Linux, le champ « Nom de la machine » en lecture
seule, l'assainissement de la sortie PowerShell et le controle du SRV
`_ldap._tcp.dc._msdcs`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:15:35 +02:00
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
10 changed files with 548 additions and 79 deletions
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## [0.1.5] - 2026-09-09
### 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
+9 -2
View File
@@ -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
```
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.4"
__version__ = "0.1.5"
+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)
+173 -61
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:
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=True,
message=f"Le poste a rejoint le domaine {domain} avec succès. Un redémarrage est requis.",
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,
)
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}"
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}...")
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,
)
return JoinResult(success=False, message=message)
+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)