Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd7e71082 | ||
|
|
eba4d0b843 | ||
|
|
04a7b5dc64 | ||
|
|
3d4d6de0f8 | ||
|
|
bc080fd76a | ||
|
|
59b6c75942 | ||
|
|
599192fd67 |
@@ -5,3 +5,5 @@ dist/
|
|||||||
*.spec
|
*.spec
|
||||||
.build-venv-linux/
|
.build-venv-linux/
|
||||||
.build-venv-windows/
|
.build-venv-windows/
|
||||||
|
.build-wine/
|
||||||
|
.build-wine-cache/
|
||||||
|
|||||||
@@ -1,5 +1,67 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
- La jointure de domaine sous Windows échouait sans aucun message, avec un code
|
||||||
|
retour 0. Le script PowerShell était transmis sur l'entrée standard de
|
||||||
|
`powershell -Command -`, qui analyse stdin ligne par ligne : le bloc
|
||||||
|
`try`/`catch` final, dépourvu de saut de ligne terminal, restait en attente
|
||||||
|
dans le parseur et n'était jamais exécuté. PowerShell rendait donc la main
|
||||||
|
sans avoir tenté la jointure. Le script est désormais transmis d'un bloc via
|
||||||
|
`-EncodedCommand`.
|
||||||
|
- En l'absence de marqueur de succès comme d'échec, le message d'erreur se
|
||||||
|
réduisait à « code retour 0 », impossible à diagnostiquer. La sortie brute de
|
||||||
|
PowerShell est maintenant reportée à l'utilisateur (le mot de passe ne
|
||||||
|
transitant que par l'environnement, il n'y apparaît jamais).
|
||||||
|
|
||||||
|
## [0.1.3] - 2026-09-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Le binaire Windows produit sous Wine ne démarrait pas (« could not initialize
|
||||||
|
Qt platform plugin "windows" ») : le hook PySide6 de PyInstaller importe
|
||||||
|
QtCore pour localiser les plugins Qt, or Qt 6.10+ lie `Qt6Core.dll` à l'ICU
|
||||||
|
de Windows (`icuuc.dll`), absent de Wine. L'import échouait et aucun plugin
|
||||||
|
n'était embarqué. La compilation Windows épingle désormais `PySide6<6.10` et
|
||||||
|
vérifie explicitement l'import de PySide6 puis la présence de `qwindows.dll`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Makefile` avec aide auto-documentée par catégories (`make help`).
|
||||||
|
- `packaging/build_windows_wine.sh` : production du `.exe` Windows depuis Linux.
|
||||||
|
- `README.md`.
|
||||||
|
- `make publish` : pousse branche et tags vers tous les dépôts Gitea configurés.
|
||||||
|
|
||||||
## [0.1.2] - 2026-09-04
|
## [0.1.2] - 2026-09-04
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# JoinDomainGUI — diagnostic reseau AD + jointure de domaine
|
||||||
|
# `make` seul (ou `make help`) affiche les cibles disponibles.
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
VENV := .build-venv-linux
|
||||||
|
PY := $(VENV)/bin/python
|
||||||
|
PIP := $(VENV)/bin/pip
|
||||||
|
VERSION := $(shell sed -n 's/^__version__ = "\(.*\)"/\1/p' domainjoin/__init__.py)
|
||||||
|
DOMAIN ?= h3adm.lan
|
||||||
|
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
|
REMOTES := $(shell git remote)
|
||||||
|
|
||||||
|
##@ 📦 Installation
|
||||||
|
|
||||||
|
$(VENV)/bin/python:
|
||||||
|
python3 -m venv --system-site-packages $(VENV)
|
||||||
|
$(PIP) install --upgrade pip -q
|
||||||
|
$(PIP) install -q -r requirements.txt
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install: $(VENV)/bin/python ## Cree l'environnement virtuel et installe les dependances
|
||||||
|
@echo "✅ Environnement pret : $(VENV)"
|
||||||
|
|
||||||
|
##@ 🚀 Utilisation
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run: install ## Lance l'interface graphique
|
||||||
|
$(PY) main.py
|
||||||
|
|
||||||
|
.PHONY: diagnose
|
||||||
|
diagnose: install ## Lance le diagnostic reseau en CLI (make diagnose DOMAIN=exemple.lan)
|
||||||
|
$(PY) main.py diagnose --domain $(DOMAIN)
|
||||||
|
|
||||||
|
##@ 🏗️ Compilation
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: build-linux build-windows ## Compile les binaires Linux et Windows
|
||||||
|
|
||||||
|
.PHONY: build-linux
|
||||||
|
build-linux: ## Compile le binaire Linux autonome (dist/JoinDomainGUI)
|
||||||
|
./packaging/build_linux.sh
|
||||||
|
|
||||||
|
.PHONY: build-windows
|
||||||
|
build-windows: ## Compile le .exe Windows depuis Linux via Wine (dist/windows/JoinDomainGUI.exe)
|
||||||
|
./packaging/build_windows_wine.sh
|
||||||
|
|
||||||
|
##@ 🚢 Publication
|
||||||
|
|
||||||
|
.PHONY: remotes
|
||||||
|
remotes: ## Liste les depots distants configures
|
||||||
|
@git remote -v | awk '$$3 == "(push)" { printf " %-10s %s\n", $$1, $$2 }'
|
||||||
|
|
||||||
|
.PHONY: publish
|
||||||
|
publish: ## Pousse la branche courante et les tags vers tous les depots (Gitea)
|
||||||
|
@git diff --quiet HEAD || { echo "❌ Modifications non commitees — commit avant de publier."; exit 1; }
|
||||||
|
@for remote in $(REMOTES); do \
|
||||||
|
echo "🚀 $$remote ← $(BRANCH)"; \
|
||||||
|
git push $$remote $(BRANCH) || exit 1; \
|
||||||
|
git push $$remote --tags || exit 1; \
|
||||||
|
done
|
||||||
|
@echo "✅ Publie sur : $(REMOTES)"
|
||||||
|
|
||||||
|
##@ 🧹 Nettoyage
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean: ## Supprime les artefacts de compilation et les caches Python
|
||||||
|
rm -rf build dist
|
||||||
|
find . -path ./$(VENV) -prune -o -name '__pycache__' -type d -print0 | xargs -0 rm -rf
|
||||||
|
|
||||||
|
.PHONY: distclean
|
||||||
|
distclean: clean ## Nettoyage complet : + environnements virtuels, prefixe Wine et telechargements
|
||||||
|
rm -rf $(VENV) .build-venv-windows .build-wine .build-wine-cache
|
||||||
|
|
||||||
|
##@ ℹ️ Aide
|
||||||
|
|
||||||
|
.PHONY: version
|
||||||
|
version: ## Affiche la version courante
|
||||||
|
@echo "JoinDomainGUI $(VERSION)"
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Affiche cette aide
|
||||||
|
@echo "JoinDomainGUI $(VERSION)"
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} \
|
||||||
|
/^[a-zA-Z0-9_.-]+:.*?##/ { printf " \033[36m%-16s\033[0m %s\n", $$1, $$2 } \
|
||||||
|
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)
|
||||||
|
@echo ""
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# JoinDomainGUI
|
||||||
|
|
||||||
|
Outil de **diagnostic réseau Active Directory** et de **jointure de domaine**,
|
||||||
|
avec interface graphique (PySide6) et interface en ligne de commande.
|
||||||
|
Fonctionne sous **Linux** (`realmd`) et sous **Windows** (PowerShell `Add-Computer`),
|
||||||
|
le backend étant choisi automatiquement selon la plateforme.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- **Diagnostic réseau AD** : résolution DNS du domaine, enregistrements SRV
|
||||||
|
(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 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
|
||||||
|
domaine, À propos.
|
||||||
|
- **Interface en ligne de commande** utilisable sans environnement graphique
|
||||||
|
(SSH, déploiement scripté) : la CLI n'importe jamais PySide6.
|
||||||
|
- **Le mot de passe ne transite jamais par la ligne de commande** : il est
|
||||||
|
transmis au sous-processus via stdin ou son environnement, et n'apparaît
|
||||||
|
donc pas dans `ps` ni dans l'historique du shell.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install # crée l'environnement virtuel et installe les dépendances
|
||||||
|
```
|
||||||
|
|
||||||
|
Dépendances : Python 3.11+, `PySide6`, `dnspython`, `ntplib`.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
### Interface graphique
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run # depuis les sources
|
||||||
|
sudo ./dist/JoinDomainGUI # binaire autonome Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
La jointure de domaine nécessite les droits root / administrateur ; le
|
||||||
|
diagnostic, non.
|
||||||
|
|
||||||
|
### Ligne de commande
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Diagnostic réseau
|
||||||
|
python main.py diagnose --domain exemple.lan
|
||||||
|
make diagnose DOMAIN=exemple.lan
|
||||||
|
|
||||||
|
# Jointure de domaine (root / administrateur requis)
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Sans mot de passe fourni, il est demandé de façon interactive et masquée.
|
||||||
|
`--password` existe mais est déconseillé (visible dans `ps` et l'historique).
|
||||||
|
|
||||||
|
Code de sortie : `0` si tout va bien, `1` si un test échoue ou si la jointure
|
||||||
|
échoue, `2` en cas de droits insuffisants ou de backend indisponible.
|
||||||
|
|
||||||
|
## Compilation des binaires
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-linux # -> dist/JoinDomainGUI (ELF autonome)
|
||||||
|
make build-windows # -> dist/windows/JoinDomainGUI.exe (PE64 autonome)
|
||||||
|
make build # les deux
|
||||||
|
```
|
||||||
|
|
||||||
|
PyInstaller ne fait pas de cross-compilation. Le binaire Windows est donc
|
||||||
|
produit depuis Linux en exécutant un vrai PyInstaller Windows dans un
|
||||||
|
préfixe **Wine** dédié (`packaging/build_windows_wine.sh`) : le `.exe` obtenu
|
||||||
|
est un binaire Windows natif. Le premier appel télécharge un Python Windows
|
||||||
|
embarqué et les dépendances (plusieurs minutes) ; les suivants sont
|
||||||
|
incrémentaux.
|
||||||
|
|
||||||
|
Sur une machine Windows, `packaging/build_windows.bat` fait le même travail
|
||||||
|
nativement.
|
||||||
|
|
||||||
|
La build Wine épingle **Qt 6.9** (`PySide6<6.10`) : à partir de Qt 6.10,
|
||||||
|
`Qt6Core.dll` dépend de l'ICU fourni par Windows (`icuuc.dll` dans `System32`),
|
||||||
|
que Wine n'implémente pas. PySide6 y devient inimportable — et le hook PySide6
|
||||||
|
de PyInstaller, qui importe QtCore pour localiser les plugins Qt, échoue alors
|
||||||
|
*silencieusement* et produit un `.exe` sans plugin de plateforme, qui refuse de
|
||||||
|
démarrer sous Windows (« could not initialize Qt platform plugin windows »). Le
|
||||||
|
script vérifie donc, avant compilation, que PySide6 s'importe, et après
|
||||||
|
compilation, que `qwindows.dll` est bien embarqué.
|
||||||
|
|
||||||
|
## Publication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make publish # pousse la branche courante et les tags vers tous les dépôts
|
||||||
|
```
|
||||||
|
|
||||||
|
Le projet est publié sur deux instances Gitea :
|
||||||
|
[git.tuxtech.fr](https://git.tuxtech.fr/tuxgyver/JoinDomainGUI) (`origin`) et
|
||||||
|
[git.h3campus.fr](https://git.h3campus.fr/Johnny/JoinDomainGUI) (`h3campus`).
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
main.py Point d'entrée : GUI sans argument, CLI sinon
|
||||||
|
domainjoin/
|
||||||
|
├── cli.py Interface en ligne de commande
|
||||||
|
├── gui.py Interface PySide6
|
||||||
|
├── diagnostics.py Moteur de diagnostic réseau
|
||||||
|
├── worker.py Exécution en arrière-plan pour la GUI
|
||||||
|
├── privileges.py Détection des droits root/administrateur
|
||||||
|
└── backend/
|
||||||
|
├── base.py Interface commune de jointure
|
||||||
|
├── linux_realmd.py Backend Linux (realmd / adcli)
|
||||||
|
└── windows_addcomputer.py Backend Windows (Add-Computer)
|
||||||
|
packaging/ Scripts de compilation PyInstaller
|
||||||
|
```
|
||||||
|
|
||||||
|
Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique des versions.
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.2"
|
__version__ = "0.1.5"
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ class DomainJoinBackend(ABC):
|
|||||||
de ligne de commande : il est transmis via l'entrée standard du
|
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."""
|
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:
|
def can_install_prerequisites(self) -> bool:
|
||||||
"""True si ce backend sait installer automatiquement les outils manquants."""
|
"""True si ce backend sait installer automatiquement les outils manquants."""
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ from .base import DomainJoinBackend, JoinResult, OutputCallback
|
|||||||
REQUIRED_PACKAGES = ["realmd", "sssd", "adcli", "samba-common-bin", "packagekit"]
|
REQUIRED_PACKAGES = ["realmd", "sssd", "adcli", "samba-common-bin", "packagekit"]
|
||||||
INSTALL_HINT = "sudo apt install " + " ".join(REQUIRED_PACKAGES)
|
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):
|
class LinuxRealmdBackend(DomainJoinBackend):
|
||||||
def is_available(self) -> tuple[bool, str]:
|
def is_available(self) -> tuple[bool, str]:
|
||||||
@@ -22,18 +28,13 @@ class LinuxRealmdBackend(DomainJoinBackend):
|
|||||||
def can_install_prerequisites(self) -> bool:
|
def can_install_prerequisites(self) -> bool:
|
||||||
return shutil.which("apt-get") is not None
|
return shutil.which("apt-get") is not None
|
||||||
|
|
||||||
def install_prerequisites(self, on_output: Optional[OutputCallback] = None) -> JoinResult:
|
def _run_apt(self, commands: list[list[str]], emit: OutputCallback) -> Optional[JoinResult]:
|
||||||
def emit(line: str) -> None:
|
"""Enchaîne des commandes apt-get. Retourne None si tout a réussi,
|
||||||
if on_output:
|
sinon le JoinResult d'échec décrivant la commande fautive."""
|
||||||
on_output(line)
|
|
||||||
|
|
||||||
if shutil.which("apt-get") is None:
|
|
||||||
return JoinResult(success=False, message="apt-get est introuvable sur ce système.")
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["DEBIAN_FRONTEND"] = "noninteractive"
|
env["DEBIAN_FRONTEND"] = "noninteractive"
|
||||||
|
|
||||||
for cmd in (["apt-get", "update"], ["apt-get", "install", "-y", *REQUIRED_PACKAGES]):
|
for cmd in commands:
|
||||||
emit(f"$ {' '.join(cmd)}")
|
emit(f"$ {' '.join(cmd)}")
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
@@ -58,6 +59,21 @@ class LinuxRealmdBackend(DomainJoinBackend):
|
|||||||
message=f"Échec de « {' '.join(cmd)} » (code retour {returncode}). "
|
message=f"Échec de « {' '.join(cmd)} » (code retour {returncode}). "
|
||||||
"Vérifiez que l'application tourne avec les droits root.",
|
"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()
|
available, _ = self.is_available()
|
||||||
if available:
|
if available:
|
||||||
@@ -132,3 +148,87 @@ class LinuxRealmdBackend(DomainJoinBackend):
|
|||||||
success=False,
|
success=False,
|
||||||
message=f"Échec de la jointure au domaine (code retour {returncode}). Voir le journal ci-dessus.",
|
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)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -10,8 +12,12 @@ from typing import Optional
|
|||||||
from .base import DomainJoinBackend, JoinResult, OutputCallback
|
from .base import DomainJoinBackend, JoinResult, OutputCallback
|
||||||
|
|
||||||
_PASSWORD_ENV_VAR = "DOMAINJOIN_PWD"
|
_PASSWORD_ENV_VAR = "DOMAINJOIN_PWD"
|
||||||
_SUCCESS_MARKER = "JOIN_SUCCESS"
|
_SUCCESS_MARKER = "DOMAINJOIN_OK"
|
||||||
_FAILURE_PREFIX = "JOIN_FAILURE:"
|
_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:
|
def _ps_quote(value: str) -> str:
|
||||||
@@ -19,38 +25,177 @@ def _ps_quote(value: str) -> str:
|
|||||||
return value.replace("'", "''")
|
return value.replace("'", "''")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_command(script: str) -> str:
|
||||||
|
"""Encode un script pour `powershell -EncodedCommand` (base64 d'UTF-16LE).
|
||||||
|
|
||||||
|
Le script est ainsi transmis comme un bloc unique et atomique. L'alternative
|
||||||
|
`-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. 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):
|
class WindowsAddComputerBackend(DomainJoinBackend):
|
||||||
def is_available(self) -> tuple[bool, str]:
|
def is_available(self) -> tuple[bool, str]:
|
||||||
if shutil.which("powershell") is None and shutil.which("powershell.exe") is None:
|
if shutil.which("powershell") is None and shutil.which("powershell.exe") is None:
|
||||||
return False, "PowerShell est introuvable sur ce poste."
|
return False, "PowerShell est introuvable sur ce poste."
|
||||||
return True, "PowerShell disponible."
|
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 = [
|
lines = [
|
||||||
"$ErrorActionPreference = 'Stop'",
|
*_PREAMBLE,
|
||||||
f"$domain = '{_ps_quote(domain)}'",
|
f"$domain = '{_ps_quote(domain)}'",
|
||||||
f"$username = '{_ps_quote(username)}'",
|
*_credential_lines(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)",
|
|
||||||
"$params = @{ DomainName = $domain; Credential = $cred; Force = $true; Restart = $false }",
|
"$params = @{ DomainName = $domain; Credential = $cred; Force = $true; Restart = $false }",
|
||||||
]
|
]
|
||||||
if ou:
|
if ou:
|
||||||
lines.append(f"$params['OUPath'] = '{_ps_quote(ou)}'")
|
lines.append(f"$params['OUPath'] = '{_ps_quote(ou)}'")
|
||||||
if computer_name:
|
if computer_name:
|
||||||
lines.append(f"$params['NewName'] = '{_ps_quote(computer_name)}'")
|
lines.append(f"$params['NewName'] = '{_ps_quote(computer_name)}'")
|
||||||
lines += [
|
lines += _guarded(["Add-Computer @params -ErrorAction Stop"])
|
||||||
"try {",
|
|
||||||
" Add-Computer @params -ErrorAction Stop",
|
|
||||||
f" Write-Output '{_SUCCESS_MARKER}'",
|
|
||||||
"} catch {",
|
|
||||||
f" Write-Output ('{_FAILURE_PREFIX} ' + $_.Exception.Message)",
|
|
||||||
" exit 1",
|
|
||||||
"}",
|
|
||||||
]
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ execution
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-EncodedCommand",
|
||||||
|
_encode_command(script),
|
||||||
|
],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
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 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] = ""
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
if process.stdout:
|
||||||
|
for raw in process.stdout:
|
||||||
|
line = raw.rstrip("\n")
|
||||||
|
if _is_noise(line):
|
||||||
|
continue
|
||||||
|
lines.append(line)
|
||||||
|
emit(line)
|
||||||
|
return process.wait(), 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 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(
|
def join(
|
||||||
self,
|
self,
|
||||||
domain: str,
|
domain: str,
|
||||||
@@ -64,55 +209,49 @@ class WindowsAddComputerBackend(DomainJoinBackend):
|
|||||||
if not available:
|
if not available:
|
||||||
return JoinResult(success=False, message=reason)
|
return JoinResult(success=False, message=reason)
|
||||||
|
|
||||||
def emit(line: str) -> None:
|
emit: OutputCallback = on_output or (lambda _line: None)
|
||||||
if on_output:
|
|
||||||
on_output(line)
|
|
||||||
|
|
||||||
script = self._build_script(domain, username, ou, computer_name)
|
|
||||||
emit(f"Exécution de Add-Computer pour le domaine {domain}...")
|
emit(f"Exécution de Add-Computer pour le domaine {domain}...")
|
||||||
|
|
||||||
env = os.environ.copy()
|
returncode, lines = self._run_powershell(
|
||||||
env[_PASSWORD_ENV_VAR] = password
|
self._join_script(domain, username, ou, computer_name), password, emit
|
||||||
|
|
||||||
try:
|
|
||||||
process = subprocess.Popen(
|
|
||||||
["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"],
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
env=env,
|
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
return self._interpret(
|
||||||
return JoinResult(success=False, message=f"Impossible de lancer PowerShell : {exc}")
|
returncode,
|
||||||
finally:
|
lines,
|
||||||
env[_PASSWORD_ENV_VAR] = ""
|
f"Le poste a rejoint le domaine {domain} avec succès. Un redémarrage est requis.",
|
||||||
|
|
||||||
try:
|
|
||||||
process.stdin.write(script)
|
|
||||||
process.stdin.close()
|
|
||||||
except BrokenPipeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
output_lines: list[str] = []
|
|
||||||
if process.stdout:
|
|
||||||
for line in process.stdout:
|
|
||||||
line = line.rstrip("\n")
|
|
||||||
output_lines.append(line)
|
|
||||||
emit(line)
|
|
||||||
|
|
||||||
returncode = process.wait()
|
|
||||||
full_output = "\n".join(output_lines)
|
|
||||||
|
|
||||||
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,
|
needs_reboot=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
failure_detail = next((l for l in output_lines if l.strip().startswith(_FAILURE_PREFIX)), None)
|
def can_leave(self) -> bool:
|
||||||
message = failure_detail or f"Échec de la jointure au domaine (code retour {returncode})."
|
return True
|
||||||
return JoinResult(success=False, message=message)
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -166,6 +166,41 @@ def _cmd_join(args: argparse.Namespace) -> int:
|
|||||||
return 0 if result.success else 1
|
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:
|
def _cmd_install_prereqs(args: argparse.Namespace) -> int:
|
||||||
if (code := _require_elevation()) is not None:
|
if (code := _require_elevation()) is not None:
|
||||||
return code
|
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.add_argument("-y", "--yes", action="store_true", help="Ne pas demander de confirmation.")
|
||||||
p_join.set_defaults(func=_cmd_join)
|
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(
|
p_install = sub.add_parser(
|
||||||
"install-prereqs", help="Installe les outils système requis pour la jointure (Linux/apt uniquement)."
|
"install-prereqs", help="Installe les outils système requis pour la jointure (Linux/apt uniquement)."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ AD_PORTS = {
|
|||||||
SRV_RECORDS = [
|
SRV_RECORDS = [
|
||||||
"_ldap._tcp.{domain}",
|
"_ldap._tcp.{domain}",
|
||||||
"_kerberos._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}",
|
"_kerberos._tcp.dc._msdcs.{domain}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+113
-3
@@ -9,6 +9,7 @@ from PySide6.QtCore import Qt
|
|||||||
from PySide6.QtGui import QColor
|
from PySide6.QtGui import QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -26,7 +27,8 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from . import __version__, diagnostics, privileges
|
from . import __version__, diagnostics, privileges
|
||||||
from .backend import get_backend
|
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"
|
DEFAULT_DOMAIN = "h3adm.lan"
|
||||||
|
|
||||||
@@ -114,6 +116,7 @@ class JoinTab(QWidget):
|
|||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._worker: JoinWorker | None = None
|
self._worker: JoinWorker | None = None
|
||||||
|
self._leave_worker: LeaveWorker | None = None
|
||||||
self.backend = get_backend()
|
self.backend = get_backend()
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
@@ -126,14 +129,37 @@ class JoinTab(QWidget):
|
|||||||
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||||
self.ou_edit = QLineEdit()
|
self.ou_edit = QLineEdit()
|
||||||
self.ou_edit.setPlaceholderText("optionnel, ex : OU=Postes,DC=h3adm,DC=lan")
|
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 = 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("Domaine :", self.domain_edit)
|
||||||
form.addRow("Utilisateur :", self.username_edit)
|
form.addRow("Utilisateur :", self.username_edit)
|
||||||
form.addRow("Mot de passe :", self.password_edit)
|
form.addRow("Mot de passe :", self.password_edit)
|
||||||
form.addRow("Unité d'organisation :", self.ou_edit)
|
form.addRow("Unité d'organisation :", self.ou_edit)
|
||||||
form.addRow("Nom de la machine :", self.computer_name_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.addLayout(form)
|
||||||
|
layout.addWidget(self.remove_packages_check)
|
||||||
|
|
||||||
self.prereq_label = QLabel("")
|
self.prereq_label = QLabel("")
|
||||||
self.prereq_label.setWordWrap(True)
|
self.prereq_label.setWordWrap(True)
|
||||||
@@ -148,6 +174,9 @@ class JoinTab(QWidget):
|
|||||||
self.install_button.clicked.connect(self._start_install)
|
self.install_button.clicked.connect(self._start_install)
|
||||||
self.install_button.setVisible(False)
|
self.install_button.setVisible(False)
|
||||||
buttons.addWidget(self.install_button)
|
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()
|
buttons.addStretch()
|
||||||
layout.addLayout(buttons)
|
layout.addLayout(buttons)
|
||||||
|
|
||||||
@@ -187,6 +216,21 @@ class JoinTab(QWidget):
|
|||||||
self.join_button.setEnabled(True)
|
self.join_button.setEnabled(True)
|
||||||
self.join_button.setToolTip("")
|
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:
|
def _start_install(self) -> None:
|
||||||
confirm = QMessageBox.question(
|
confirm = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
@@ -220,7 +264,6 @@ class JoinTab(QWidget):
|
|||||||
username = self.username_edit.text().strip()
|
username = self.username_edit.text().strip()
|
||||||
password = self.password_edit.text()
|
password = self.password_edit.text()
|
||||||
ou = self.ou_edit.text().strip() or None
|
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:
|
if not domain or not username or not password:
|
||||||
QMessageBox.warning(self, "Champs manquants", "Domaine, utilisateur et mot de passe sont requis.")
|
QMessageBox.warning(self, "Champs manquants", "Domaine, utilisateur et mot de passe sont requis.")
|
||||||
@@ -246,11 +289,78 @@ class JoinTab(QWidget):
|
|||||||
self.log.clear()
|
self.log.clear()
|
||||||
self.join_button.setEnabled(False)
|
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.output_line.connect(self.log.appendPlainText)
|
||||||
self._worker.finished_join.connect(self._on_join_finished)
|
self._worker.finished_join.connect(self._on_join_finished)
|
||||||
self._worker.start()
|
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:
|
def _on_join_finished(self, result) -> None:
|
||||||
self._refresh_backend_state()
|
self._refresh_backend_state()
|
||||||
if result.success:
|
if result.success:
|
||||||
|
|||||||
@@ -70,3 +70,34 @@ class InstallPrerequisitesWorker(QThread):
|
|||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
result: JoinResult = self.backend.install_prerequisites(on_output=self.output_line.emit)
|
result: JoinResult = self.backend.install_prerequisites(on_output=self.output_line.emit)
|
||||||
self.finished_install.emit(result)
|
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)
|
||||||
|
|||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fabrique le binaire Windows autonome (.exe) de JoinDomainGUI DEPUIS LINUX,
|
||||||
|
# en pilotant un Python Windows embarque dans un prefixe Wine dedie.
|
||||||
|
#
|
||||||
|
# PyInstaller ne cross-compile pas : on execute donc un vrai PyInstaller Windows
|
||||||
|
# sous Wine. Le .exe produit est un binaire Windows natif.
|
||||||
|
#
|
||||||
|
# Prerequis : wine (>= 8), curl, unzip.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PYTHON_VERSION="${PYTHON_VERSION:-3.12.10}"
|
||||||
|
# Depuis Qt 6.10, Qt6Core.dll est lie a l'ICU fourni par Windows (icuuc.dll dans
|
||||||
|
# System32, present depuis Win10 1703). Wine ne fournit pas cette DLL : PySide6
|
||||||
|
# y devient inimportable, et le hook PySide6 de PyInstaller -- qui importe QtCore
|
||||||
|
# pour localiser les plugins Qt -- echoue alors SILENCIEUSEMENT et produit un
|
||||||
|
# .exe depourvu de plugin de plateforme ("could not initialize Qt platform
|
||||||
|
# plugin windows" au lancement). On contraint donc cette build a Qt 6.9.
|
||||||
|
PYSIDE6_CONSTRAINT="${PYSIDE6_CONSTRAINT:-PySide6<6.10}"
|
||||||
|
WINEPREFIX="${WINEPREFIX:-$PWD/.build-wine}"
|
||||||
|
export WINEPREFIX
|
||||||
|
export WINEARCH=win64
|
||||||
|
export WINEDEBUG="${WINEDEBUG:--all}"
|
||||||
|
|
||||||
|
PY_DIR="$WINEPREFIX/drive_c/python"
|
||||||
|
WPY="c:\\python\\python.exe"
|
||||||
|
CACHE=".build-wine-cache"
|
||||||
|
|
||||||
|
command -v wine >/dev/null || { echo "wine est requis (apt install wine)" >&2; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "$CACHE"
|
||||||
|
|
||||||
|
if [ ! -d "$WINEPREFIX" ]; then
|
||||||
|
echo ">> Initialisation du prefixe Wine ($WINEPREFIX)"
|
||||||
|
wineboot --init >/dev/null 2>&1
|
||||||
|
wineserver -w
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$PY_DIR/python.exe" ]; then
|
||||||
|
ZIP="$CACHE/python-$PYTHON_VERSION-embed-amd64.zip"
|
||||||
|
[ -f "$ZIP" ] || curl -fL --retry 3 -o "$ZIP" \
|
||||||
|
"https://www.python.org/ftp/python/$PYTHON_VERSION/python-$PYTHON_VERSION-embed-amd64.zip"
|
||||||
|
echo ">> Installation de Python $PYTHON_VERSION (Windows) dans le prefixe"
|
||||||
|
rm -rf "$PY_DIR"
|
||||||
|
mkdir -p "$PY_DIR"
|
||||||
|
unzip -q "$ZIP" -d "$PY_DIR"
|
||||||
|
# La distribution "embeddable" desactive site-packages : on le reactive
|
||||||
|
# pour que pip et les paquets installes soient importables.
|
||||||
|
sed -i 's/^#import site/import site/' "$PY_DIR"/python*._pth
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$PY_DIR/Scripts/pip.exe" ]; then
|
||||||
|
GETPIP="$CACHE/get-pip.py"
|
||||||
|
[ -f "$GETPIP" ] || curl -fL --retry 3 -o "$GETPIP" https://bootstrap.pypa.io/get-pip.py
|
||||||
|
echo ">> Installation de pip"
|
||||||
|
cp "$GETPIP" "$PY_DIR/get-pip.py"
|
||||||
|
wine "$WPY" c:\\python\\get-pip.py --no-warn-script-location
|
||||||
|
fi
|
||||||
|
|
||||||
|
# pip sous Wine est lent (plusieurs minutes pour le wheel PySide6) : on ne
|
||||||
|
# reinstalle que si requirements.txt ou la contrainte ont change (FORCE_DEPS=1
|
||||||
|
# pour forcer).
|
||||||
|
DEPS_STAMP="$PY_DIR/.deps-stamp"
|
||||||
|
WANT_HASH="$(cat requirements.txt <(echo "$PYSIDE6_CONSTRAINT") | sha256sum | cut -d' ' -f1)"
|
||||||
|
if [ "${FORCE_DEPS:-0}" = "1" ] || [ "$(cat "$DEPS_STAMP" 2>/dev/null)" != "$WANT_HASH" ]; then
|
||||||
|
echo ">> Installation des dependances Windows (peut prendre plusieurs minutes)"
|
||||||
|
printf '%s\n' "$PYSIDE6_CONSTRAINT" > "$PY_DIR/constraints.txt"
|
||||||
|
# Desinstallation prealable : pip ne retrograde pas toujours un paquet deja
|
||||||
|
# installe qui viole une contrainte nouvellement ajoutee.
|
||||||
|
wine "$WPY" -m pip uninstall -q -y PySide6 PySide6-Essentials PySide6-Addons shiboken6 >/dev/null 2>&1 || true
|
||||||
|
wine "$WPY" -m pip install -q --no-warn-script-location \
|
||||||
|
-c c:\\python\\constraints.txt -r requirements.txt pyinstaller
|
||||||
|
echo "$WANT_HASH" > "$DEPS_STAMP"
|
||||||
|
else
|
||||||
|
echo ">> Dependances Windows a jour (FORCE_DEPS=1 pour les reinstaller)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Garde-fou : si PySide6 ne s'importe pas ici, le hook PyInstaller ne collectera
|
||||||
|
# aucun plugin Qt et produira un .exe casse sans le signaler. Echouer tot.
|
||||||
|
echo ">> Verification de PySide6 sous Wine"
|
||||||
|
if ! wine "$WPY" -c "import PySide6.QtWidgets" 2>&1; then
|
||||||
|
echo "ERREUR : PySide6 n'est pas importable sous Wine — le .exe produit serait" >&2
|
||||||
|
echo " depourvu de plugins Qt. Verifiez PYSIDE6_CONSTRAINT." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> Compilation du .exe"
|
||||||
|
wine "$WPY" -m PyInstaller \
|
||||||
|
--noconfirm \
|
||||||
|
--onefile \
|
||||||
|
--windowed \
|
||||||
|
--name JoinDomainGUI \
|
||||||
|
--distpath dist/windows \
|
||||||
|
--workpath build/windows \
|
||||||
|
--specpath build/windows \
|
||||||
|
main.py
|
||||||
|
|
||||||
|
wineserver -w
|
||||||
|
|
||||||
|
# Garde-fou : sans qwindows.dll, l'application refuse de demarrer sous Windows.
|
||||||
|
if ! grep -qi 'qwindows.dll' build/windows/JoinDomainGUI/PKG-00.toc; then
|
||||||
|
echo "ERREUR : le plugin de plateforme qwindows.dll est absent du binaire." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Binaire produit : dist/windows/JoinDomainGUI.exe"
|
||||||
|
echo "Lancement : clic droit -> Executer en tant qu'administrateur"
|
||||||
Reference in New Issue
Block a user