commit ed1485646ab191026afa0fc0ead9494bc8365442 Author: PierreCsn Date: Tue Oct 27 17:15:21 2026 +0100 first upload diff --git a/README.md b/README.md new file mode 100644 index 0000000..d116b67 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# comi-win11-max-25h2 + +Paquet de configuration WAPT **Windows 11** limitant les mises a jour de fonctionnalites Windows Update a **25H2**. Il ne desactive ni Windows Update ni les mises a jour cumulatives/de securite. + +## Ce que configure le paquet + +Sous `HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate` (vue 64 bits) : + +| Valeur | Type | Donnee | +| --- | --- | --- | +| `ProductVersion` | `REG_SZ` | `Windows 11` | +| `TargetReleaseVersion` | `REG_DWORD` | `1` | +| `TargetReleaseVersionInfo` | `REG_SZ` | `25H2` | + +- Poste Windows 11 23H2 / 24H2 : 25H2 demeure une version cible autorisee, sous reserve de compatibilite, de disponibilite et des autres politiques de mise a jour. +- Poste Windows 11 25H2 : reste cible sur 25H2 pour les mises a jour de fonctionnalites. +- Poste deja en 26H1/26H2 : **installation refusee** si `DisplayVersion` le revele. Ce paquet ne fait pas de retrogradation. +- Windows 10, Windows Server, editions Home et LTSC connues : installation refusee pour eviter les effets de bord. + +Cette politique concerne **les Feature Updates distribuees par Windows Update**. Elle ne bloque pas un autre outil, un ISO ou une mise a niveau executee manuellement. L'efficacite reelle peut aussi etre modifiee par GPO, Intune/MDM, WSUS, la fin de support de la version cible ou des politiques contradictoires. Elle ne doit pas etre consideree comme une barriere de securite absolue. + +## Contenu + +``` +comi-win11-max-25h2/ + setup.py + README.md + WAPT/ + control +``` + +## Construire et deployer + +1. Decompresser l'archive de sources sur un poste **WAPT Packaging** muni d'un certificat de signature autorise et de sa cle privee. +2. Verifier / adapter le champ `maintainer` ou le prefixe `package` dans `WAPT/control`. +3. Construire et signer via la Console WAPT (**Build and upload package**) ou en ligne de commande : + + ```powershell + & 'C:\Program Files (x86)\wapt\wapt-get.exe' build-package 'C:\waptdev\comi-win11-max-25h2' + ``` + + Pour compiler ET mettre en depot en une operation : + + ```powershell + & 'C:\Program Files (x86)\wapt\wapt-get.exe' build-upload 'C:\waptdev\comi-win11-max-25h2' + ``` + +4. Affecter `comi-win11-max-25h2` a l'hote ou au groupe voulu, et lancer l'installation depuis WAPT. +5. Tester d'abord sur un poste Windows 11 non critique. + +**Important :** cette archive contient les *sources*, pas un fichier `.wapt` deja signe. La signature utilise la cle de packaging de votre environnement WAPT ; aucune cle privee n'est incluse. + +## Audit + +- Audit WAPT journalier (`audit_schedule: 1d`) ; lance aussi apres l'upgrade / sur demande. +- `OK` : strategie conforme et version detectee <= 25H2. +- `ERROR` : policy differente/absente, OS incompatible ou version deja > 25H2. +- `WARNING` : strategie conforme, mais version Windows non determinee. + +Sur un poste WAPT : + +```powershell +& 'C:\Program Files (x86)\wapt\wapt-get.exe' audit comi-win11-max-25h2 +Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' | + Select-Object ProductVersion, TargetReleaseVersion, TargetReleaseVersionInfo +``` + +La conformite des trois valeurs de Registre est un controle local : la prise en compte effective reste soumise au moteur Windows Update et a la source de mises a jour configuree. + +## Desinstallation / retour arriere + +Lors de la *premiere* installation, les valeurs precedemment presentes sont sauvegardees dans : + +`C:\ProgramData\Comitari\WAPT\comi-win11-max-25h2\previous_policy.json` + +La fonction `uninstall()` restaure les valeurs precedentes, ou retire les valeurs qui n'existaient pas. Si une valeur a change depuis l'installation sous l'effet d'un autre gestionnaire, elle est **conservee**. Les reinstallations n'ecrasent pas la sauvegarde initiale. + +La suppression de la dependance hote, ou `wapt-get remove` seul, **ne garantit pas** selon la version WAPT l'execution du `uninstall()` personnalise. Pour annuler explicitement la strategie avant de supprimer le paquet, faire : + +```powershell +& 'C:\Program Files (x86)\wapt\wapt-get.exe' uninstall comi-win11-max-25h2 +& 'C:\Program Files (x86)\wapt\wapt-get.exe' remove comi-win11-max-25h2 +``` + +Retirer egalement la dependance du paquet sur l'hote/groupe dans la Console WAPT ; sinon il peut etre reinstalle. + +La fonction de desinstallation ne desactive pas Windows Update et ne supprime pas la cle WindowsUpdate parente (elle peut contenir d'autres regles). + +## Limites + +- Pas de retour arriere d'un Windows deja passe en version superieure. +- Ce paquet ne force pas la migration vers 25H2 ; il fixe une **cible maximale** pour les *Feature Updates* de Windows Update. +- Les versions de Windows sortant de maintenance peuvent necessiter une politique actualisee. Une version cible en fin de maintenance ne doit pas rester gelee indefiniment. +- Le ciblage par Windows Update for Business est documente pour les editions Windows 11 Pro, Enterprise et Education (pas Home). +- GPO, MDM/Intune, WSUS et autres outils peuvent surcharger la policy, ou proposer des mises a niveau par d'autres mecanismes. +- Aucun `gpupdate`, redemarrage de service, redemarrage du poste ou scan Windows Update force n'est declenche. + +## Sources + +- Microsoft (strategie cible) : https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-update +- Microsoft (etats/version de Windows 11) : https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information +- Tranquil IT WAPT 2.6 (packages) : https://www.wapt.fr/fr/doc-2.6/wapt-package-structure.html +- Tranquil IT WAPT 2.6 (build/sign) : https://www.wapt.fr/fr/doc-2.6/wapt-command-line-interface.html diff --git a/WAPT/control b/WAPT/control new file mode 100644 index 0000000..464a9c0 --- /dev/null +++ b/WAPT/control @@ -0,0 +1,15 @@ +package : comi-win11-max-25h2 +version : 1.0.0-0 +architecture : all +section : base +priority : optional +maintainer : Comitari +description : Pin Windows 11 feature updates to 25H2 maximum; keep quality updates enabled +description_fr : Limite les mises a jour de fonctionnalites Windows 11 a 25H2 sans bloquer les correctifs de securite +maturity : PROD +target_os : windows +min_os_version : 10.0.22000 +min_wapt_version : 2.0 +audit_schedule : 1d +keywords : windows11,windows-update,25h2,policy +homepage : https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-update diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..178de55 --- /dev/null +++ b/setup.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +"""WAPT: keep Windows 11 at or below feature update 25H2. + +Uses the Microsoft target feature update policy. No Windows Update service, +quality update settings, deferrals, or WSUS settings are modified. +""" + +from setuphelpers import * + +import json +import os +import re +import tempfile +import winreg + +uninstallkey = [] + +PACKAGE_NAME = 'comi-win11-max-25h2' +POLICY_PATH = r'SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' +CURRENT_VERSION_PATH = r'SOFTWARE\Microsoft\Windows NT\CurrentVersion' +TARGET_VERSION = '25H2' +EXPECTED = { + 'ProductVersion': ('Windows 11', winreg.REG_SZ), + 'TargetReleaseVersion': (1, winreg.REG_DWORD), + 'TargetReleaseVersionInfo': (TARGET_VERSION, winreg.REG_SZ), +} +STATE_DIR = os.path.join( + os.environ.get('ProgramData', r'C:\ProgramData'), + 'Comitari', 'WAPT', PACKAGE_NAME, +) +BACKUP_FILE = os.path.join(STATE_DIR, 'previous_policy.json') +REGISTRY_VIEW = winreg.KEY_WOW64_64KEY + + +def _read_value(path, name): + """Return (value, reg_type), or None when path/value is absent.""" + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, + winreg.KEY_QUERY_VALUE | REGISTRY_VIEW) as key: + return winreg.QueryValueEx(key, name) + except FileNotFoundError: + return None + + +def _read_os(): + build_info = _read_value(CURRENT_VERSION_PATH, 'CurrentBuildNumber') + install_info = _read_value(CURRENT_VERSION_PATH, 'InstallationType') + edition_info = _read_value(CURRENT_VERSION_PATH, 'EditionID') + display_info = _read_value(CURRENT_VERSION_PATH, 'DisplayVersion') + if not build_info: + raise RuntimeError('Impossible de lire la build de Windows') + try: + build = int(build_info[0]) + except (ValueError, TypeError): + raise RuntimeError('Build Windows illisible : %r' % (build_info[0],)) + installation = str(install_info[0]) if install_info else '' + edition = str(edition_info[0]) if edition_info else '' + display = str(display_info[0]) if display_info else '' + return build, installation, edition, display + + +def _assert_supported_os(): + build, installation, edition, display = _read_os() + # Windows Server 2025 has build 26100 too: build alone is insufficient. + if build < 22000 or installation.lower() != 'client': + raise RuntimeError( + 'Ce paquet est reserve aux postes Windows 11 (build >= 22000); ' + 'build=%s, installation=%s' % (build, installation) + ) + # LTSC editions have a different servicing model and do not target 25H2. + if edition.lower().startswith(('enterprises', 'iotenterprises')): + raise RuntimeError('Edition LTSC non prise en charge : %s' % edition) + if edition.lower().startswith('core'): + raise RuntimeError('Edition Home non prise en charge officiellement : %s' % edition) + return build, display + + +def _version_tuple(value): + match = re.fullmatch(r'(\d{2})H([12])', value.strip(), re.IGNORECASE) + if not match: + return None + return int(match.group(1)), int(match.group(2)) + + +def _snapshot_policy(): + """Save the pre-existing values once, also across WAPT reinstalls.""" + if os.path.isfile(BACKUP_FILE): + # Fail closed if a backup exists but is unreadable. + with open(BACKUP_FILE, 'r', encoding='utf-8') as stream: + saved = json.load(stream) + if saved.get('schema_version') != 1 or saved.get('package') != PACKAGE_NAME: + raise RuntimeError('Sauvegarde de strategie incompatible : ' + BACKUP_FILE) + if set(saved.get('values', {})) != set(EXPECTED): + raise RuntimeError('Sauvegarde de strategie incomplete : ' + BACKUP_FILE) + return + + state = {'schema_version': 1, 'package': PACKAGE_NAME, 'values': {}} + for name in EXPECTED: + previous = _read_value(POLICY_PATH, name) + state['values'][name] = ( + {'present': False} + if previous is None else + {'present': True, 'value': previous[0], 'type': previous[1]} + ) + + os.makedirs(STATE_DIR, exist_ok=True) + descriptor, tmp_path = tempfile.mkstemp(prefix='.previous-policy-', + suffix='.tmp', dir=STATE_DIR) + try: + with os.fdopen(descriptor, 'w', encoding='utf-8') as stream: + json.dump(state, stream, ensure_ascii=False, indent=2) + stream.flush() + os.fsync(stream.fileno()) + os.replace(tmp_path, BACKUP_FILE) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +def _write_policy(): + with winreg.CreateKeyEx(winreg.HKEY_LOCAL_MACHINE, POLICY_PATH, 0, + winreg.KEY_SET_VALUE | REGISTRY_VIEW) as key: + for name, (value, kind) in EXPECTED.items(): + current = _read_value(POLICY_PATH, name) + if current != (value, kind): + winreg.SetValueEx(key, name, 0, kind, value) + print('Policy %s = %r' % (name, value)) + else: + print('Policy %s deja conforme' % name) + + +def install(): + build, display = _assert_supported_os() + current_version = _version_tuple(display) + # 25H2 uses build family 26200. Newer release families cannot downgrade. + if build >= 26300: + raise RuntimeError( + 'Ce poste utilise deja une build posterieure a 25H2 ' + '(build %s, DisplayVersion=%s); downgrade impossible.' % (build, display) + ) + if current_version is not None and current_version > _version_tuple(TARGET_VERSION): + raise RuntimeError( + 'Ce poste est deja en Windows 11 %s (build %s). ' + 'Un verrouillage 25H2 ne peut pas effectuer de downgrade.' + % (display, build) + ) + if current_version is None: + print('WARNING: DisplayVersion non reconnu (%r), verifier la version OS.' % display) + _snapshot_policy() + _write_policy() + print('Windows Update cible Windows 11 %s; mises a jour qualite inchangees.' + % TARGET_VERSION) + + +def audit(): + try: + build, display = _assert_supported_os() + except Exception as exc: + print('ERROR: OS incompatible : %s' % exc) + return 'ERROR' + + failures = [] + for name, expected in EXPECTED.items(): + actual = _read_value(POLICY_PATH, name) + if actual != expected: + failures.append('%s attendu=%r obtenu=%r' % (name, expected, actual)) + if failures: + print('ERROR: strategie absente, alteree ou surchargee : %s' + % '; '.join(failures)) + return 'ERROR' + + current_version = _version_tuple(display) + if current_version is None: + print('WARNING: policy conforme, mais version Windows inconnue : ' + 'DisplayVersion=%r, build=%s' % (display, build)) + return 'WARNING' + if current_version > _version_tuple(TARGET_VERSION): + print('ERROR: Windows 11 %s deja superieur a %s : downgrade impossible' + % (display, TARGET_VERSION)) + return 'ERROR' + print('OK: Windows 11 %s (build %s), policy cible %s' + % (display, build, TARGET_VERSION)) + return 'OK' + + +def uninstall(): + """Restore initial settings without clobbering externally changed values. + + On some WAPT versions this must be triggered explicitly with + `wapt-get uninstall ` before `wapt-get remove `. + """ + if not os.path.isfile(BACKUP_FILE): + print('WARNING: aucune sauvegarde; pas de suppression aveugle des policies') + return + with open(BACKUP_FILE, 'r', encoding='utf-8') as stream: + state = json.load(stream) + if state.get('schema_version') != 1 or state.get('package') != PACKAGE_NAME: + raise RuntimeError('Sauvegarde de strategie incompatible : ' + BACKUP_FILE) + if set(state.get('values', {})) != set(EXPECTED): + raise RuntimeError('Sauvegarde de strategie incomplete : ' + BACKUP_FILE) + + with winreg.CreateKeyEx(winreg.HKEY_LOCAL_MACHINE, POLICY_PATH, 0, + winreg.KEY_SET_VALUE | REGISTRY_VIEW) as key: + for name, (ours, our_kind) in EXPECTED.items(): + current = _read_value(POLICY_PATH, name) + if current != (ours, our_kind): + print('WARNING: %s modifie par un autre mecanisme; conserve : %r' + % (name, current)) + continue + before = state['values'][name] + if before['present']: + winreg.SetValueEx(key, name, 0, before['type'], before['value']) + print('Restauration : %s' % name) + else: + winreg.DeleteValue(key, name) + print('Suppression : %s' % name) + # Keep the WindowsUpdate parent key: it may contain other managed values. + os.unlink(BACKUP_FILE) + try: + os.rmdir(STATE_DIR) + except OSError: + pass + print('Restauration terminee (les parametres externes sont preserves).')