feat(windows-deploy): add offline Windows 11 deployment script for dual-boot provisioning

Add fws-windows-deploy script for secure, offline Windows 11 deployment on dedicated disk.

Features:
- Fail-closed safety validation (disk, ISO, protected mounts)
- Partition carving with sgdisk (ESP, MSR, NTFS, swap)
- WIM image application via wimlib
- BCD bootloader setup with hivex fallback
- Offline BitLocker prevention
- Answer file injection and gameboot component
- FWS hibernation swap provisioning
- Comprehensive guards against accidental data loss

Script runs in %post --nochroot during ISO build, targets /mnt/sysroot.
This commit is contained in:
2026-07-08 23:57:07 +02:00
parent 39baaa67e6
commit d1fa5143e8
@@ -0,0 +1,287 @@
#!/bin/bash
# ============================================================
# fws-windows-deploy — déploie Windows 11 en OFFLINE-APPLY sur un disque dédié,
# depuis le live FWS (appelé en %post --nochroot, cible sous /mnt/sysroot).
#
# Windows Setup ne tourne JAMAIS : on carve les partitions, on applique
# install.wim (wimlib), on rend bootable (BCD), on injecte l'answer file offline
# + le composant gameboot, on provisionne l'hibernation FWS.
#
# SÉCURITÉ (risque n°1 = effacer le mauvais NVMe) : garde-fous FAIL-CLOSED —
# état PARSÉ (jamais sourcé → pas d'injection), disque cible VALIDÉ contre la
# liste des disques réels, comparé à TOUS les disques protégés (racine FWS
# multi-PV incluse, ESP, média ISO, support live), aucun label FWS résiduel
# ailleurs, adressage des partitions par NUMÉRO sur le disque cible (jamais par
# by-partlabel global) avec vérif du parent. Le moindre doute → abort. Échec →
# DEPLOY_FAILED, aucune entrée UEFI piégeuse, FWS reste seul bootable.
#
# ⚠ NON VALIDÉ SUR MATÉRIEL RÉEL (déploiement Windows réel + BCD).
# ============================================================
set -u
STATE=/tmp/fws-windows
SYSROOT=/mnt/sysroot
while [ $# -gt 0 ]; do
case "$1" in
--state) STATE="${2:-}"; shift 2 ;;
--sysroot) SYSROOT="${2:-}"; shift 2 ;;
*) shift ;;
esac
done
log(){ printf '\e[36m[win-deploy]\e[0m %s\n' "$*"; }
warn(){ printf '\e[33m[win-deploy] %s\e[0m\n' "$*" >&2; }
status(){ mkdir -p "$SYSROOT/var/lib/fws" 2>/dev/null; echo "$1" > "$SYSROOT/var/lib/fws/windows-deploy.status" 2>/dev/null || true; }
die(){ printf '\e[31m[win-deploy] ABORT : %s\e[0m\n' "$*" >&2; status "DEPLOY_FAILED: $*"; exit 1; }
canon(){ readlink -f "$1" 2>/dev/null || printf '%s' "$1"; }
# Device de la partition N sur le disque D (nvme/mmc → pN ; sata → N).
part_dev(){ case "$1" in *[0-9]) printf '%sp%s' "$1" "$2";; *) printf '%s%s' "$1" "$2";; esac; }
# --- 0) État du spoke : PARSÉ en clé=valeur littéral (JAMAIS sourcé) [M3] -----
[ -r "$STATE" ] || { log "pas de $STATE → dual-boot non demandé, rien à faire."; exit 0; }
enabled=0; disk=""; size_gib=0; iso=""; edition=""; confirm_erase=0
while IFS='=' read -r k v; do
case "$k" in
enabled) enabled="$v" ;;
disk) disk="$v" ;;
size_gib) size_gib="$v" ;;
iso) iso="$v" ;;
edition) edition="$v" ;;
confirm_erase) confirm_erase="$v" ;;
esac
done < "$STATE"
[ "${enabled:-0}" = 1 ] || { log "dual-boot désactivé, rien à faire."; exit 0; }
[ "${size_gib:-0}" -eq "${size_gib:-0}" ] 2>/dev/null || die "taille non numérique"
log "Dual-boot Windows demandé : disque=$disk taille=${size_gib}Gio iso=$iso"
# --- Résolveur : TOUS les disques physiques sous un montage (fail-closed) [M1]
fws_disks_of(){
local src
src="$(findmnt -no SOURCE "$1" 2>/dev/null)" || return 1
[ -n "$src" ] || return 1
lsblk -spno NAME,TYPE "$src" 2>/dev/null | awk '$2=="disk"{print $1}'
}
# --- 1) Garde-fous AVANT toute écriture (fail-closed) -----------------------
WIN_DISK="$disk"
[ -b "$WIN_DISK" ] || die "cible '$WIN_DISK' n'est pas un disque bloc"
# [M3] 'disk' DOIT être un vrai disque physique (neutralise toute valeur piégée).
lsblk -dpno NAME,TYPE 2>/dev/null | awk '$2=="disk"{print $1}' | grep -qxF "$WIN_DISK" \
|| die "cible '$WIN_DISK' absente de la liste des disques physiques"
findmnt -no SOURCE "$SYSROOT" >/dev/null 2>&1 || die "$SYSROOT non monté"
[ -f "$iso" ] || die "ISO introuvable : $iso"
[ "${size_gib:-0}" -ge 100 ] || die "taille < 100 Gio"
# Swap d'hibernation (RAM+VRAM+marge, plancher 96) — calculé ICI pour le garde taille [M7].
MEM=$(awk '/^MemTotal/{print int($2/1024)}' /proc/meminfo)
VRAM=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | awk '{s+=$1}END{print s+0}')
SWAP_GIB=$(( (MEM + VRAM + 4096 + 1023) / 1024 )); [ "$SWAP_GIB" -lt 96 ] && SWAP_GIB=96
# Le disque cible ne doit être AUCUN disque protégé. On sonde uniquement de VRAIS
# montages [m1], et on compare à TOUS les disques porteurs [M1]. Resolver muet → abort.
WIN_C="$(canon "$WIN_DISK")"
for probe in "$SYSROOT" "$SYSROOT/boot/efi" "$SYSROOT/boot" /run/archiso/bootmnt; do
findmnt -M "$probe" >/dev/null 2>&1 || continue
mapfile -t pds < <(fws_disks_of "$probe")
[ "${#pds[@]}" -gt 0 ] || die "resolver muet sur '$probe' → abort (fail-closed)"
for d in "${pds[@]}"; do
[ "$WIN_C" != "$(canon "$d")" ] || die "$WIN_DISK == disque protégé ($probe via $d) — refus d'effacer"
done
done
# L'ISO ne doit pas vivre sur la cible — fail-closed si le média est indéterminable [m2].
iso_mnt="$(df --output=target "$iso" 2>/dev/null | tail -1)"
[ -n "$iso_mnt" ] || die "média de l'ISO indéterminable → abort (fail-closed)"
mapfile -t isods < <(fws_disks_of "$iso_mnt")
[ "${#isods[@]}" -gt 0 ] || die "resolver muet sur le média ISO → abort"
for d in "${isods[@]}"; do
[ "$WIN_C" != "$(canon "$d")" ] || die "l'ISO vit sur la cible $WIN_DISK — refus"
done
# Taille : NTFS + ESP + MSR + swap doivent tenir [M7].
DGIB=$(( $(lsblk -dbno SIZE "$WIN_DISK" 2>/dev/null || echo 0) / 1073741824 ))
[ "$DGIB" -gt 0 ] && [ "$size_gib" -le $(( DGIB - SWAP_GIB - 4 )) ] \
|| die "taille ${size_gib} + swap ${SWAP_GIB} + réserve dépasse le disque ${DGIB} Gio"
# [B1] Aucun label FWS résiduel ne doit exister sur un AUTRE disque (collision
# by-partlabel = risque d'écrire ailleurs). On refuse plutôt que de deviner.
while read -r dev lbl; do
case "$lbl" in
FWSWIN|WINESP|FWSSWAP)
pk="/dev/$(lsblk -no PKNAME "$dev" 2>/dev/null | head -1)"
[ "$(canon "$pk")" = "$WIN_C" ] \
|| die "label '$lbl' déjà présent sur $pk (≠ cible) — nettoie-le d'abord (wipefs -a $pk)" ;;
esac
done < <(lsblk -lnpo NAME,PARTLABEL 2>/dev/null)
log "Garde-fous OK : $WIN_DISK ($DGIB Gio) sûr à dédier à Windows (swap ${SWAP_GIB} Gio)."
# --- 2) Neutraliser toute activation du disque cible [M2] -------------------
log "Neutralisation des partitions de $WIN_DISK…"
for part in $(lsblk -lnpo NAME "$WIN_DISK" 2>/dev/null | tail -n +2); do
umount "$part" 2>/dev/null || true
swapoff "$part" 2>/dev/null || true
for vg in $(pvs --noheadings -o vg_name "$part" 2>/dev/null); do vgchange -an "$vg" 2>/dev/null || true; done
for h in $(lsblk -lno NAME "$part" 2>/dev/null | tail -n +2); do
case "$(lsblk -no TYPE "/dev/$h" 2>/dev/null | head -1)" in
raid*) mdadm --stop "/dev/$h" 2>/dev/null || true ;;
crypt) cryptsetup close "$h" 2>/dev/null || true ;;
esac
done
done
# Arrays md dont le disque cible est lui-même membre (détection par colonne TYPE).
for md in $(lsblk -lnpo NAME,TYPE "$WIN_DISK" 2>/dev/null | awk '$2 ~ /raid/{print $1}'); do
mdadm --stop "$md" 2>/dev/null || true
done
# Re-vérif fail-closed : plus rien d'actif sur le disque cible.
for part in $(lsblk -lnpo NAME "$WIN_DISK" 2>/dev/null | tail -n +2); do
findmnt -S "$part" >/dev/null 2>&1 && die "$part encore monté après neutralisation"
swapon --show=NAME --noheadings 2>/dev/null | grep -qx "$part" && die "$part encore en swap"
[ -n "$(lsblk -lno NAME "$part" 2>/dev/null | tail -n +2)" ] && die "$part a encore des holders (md/dm) actifs"
done
# --- 3) Carve (mode DEDICATE) — adressage par NUMÉRO sur le disque cible [B1]
log "Carve : ESP 1Gio + MSR 16Mio + NTFS ${size_gib}Gio + swap ${SWAP_GIB}Gio"
sgdisk --zap-all "$WIN_DISK" || die "sgdisk --zap-all a échoué"
sgdisk -n1:0:+1GiB -t1:ef00 -c1:WINESP "$WIN_DISK" || die "création WINESP"
sgdisk -n2:0:+16MiB -t2:0c01 -c2:MSR "$WIN_DISK" || warn "MSR non créée (non bloquant)"
sgdisk -n3:0:+${size_gib}GiB -t3:0700 -c3:FWSWIN "$WIN_DISK" || die "création partition Windows"
sgdisk -n4:0:+${SWAP_GIB}GiB -t4:8200 -c4:FWSSWAP "$WIN_DISK" || die "création swap"
partprobe "$WIN_DISK"; udevadm settle
ESP_PART="$(part_dev "$WIN_DISK" 1)"
WIN_PART="$(part_dev "$WIN_DISK" 3)"
SWAP_PART="$(part_dev "$WIN_DISK" 4)"
for p in "$ESP_PART" "$WIN_PART" "$SWAP_PART"; do
[ -b "$p" ] || die "partition $p absente après carve"
pk="/dev/$(lsblk -no PKNAME "$p" 2>/dev/null | head -1)"
[ "$(canon "$pk")" = "$WIN_C" ] || die "$p n'est PAS sur $WIN_DISK — abort"
done
mkfs.fat -F32 -n WINESP "$ESP_PART" || die "mkfs.fat WINESP"
mkswap -L FWSSWAP "$SWAP_PART" || die "mkswap FWSSWAP"
mkntfs -Q -f -L FWSWIN "$WIN_PART" || mkntfs -f -L FWSWIN "$WIN_PART" || die "mkntfs FWSWIN"
# --- 4) Appliquer install.wim (wimlib) sur la NTFS --------------------------
ISO_MNT="$(mktemp -d)"; WIN_MNT="$(mktemp -d)"; ESP_MNT="$(mktemp -d)"
cleanup(){ for m in "$WIN_MNT" "$ESP_MNT" "$ISO_MNT"; do umount "$m" 2>/dev/null || true; done; }
trap cleanup EXIT
mount -o loop,ro "$iso" "$ISO_MNT" || die "montage ISO échoué"
IMG="$ISO_MNT/sources/install.wim"; [ -f "$IMG" ] || IMG="$ISO_MNT/sources/install.esd"
[ -f "$IMG" ] || die "install.wim/.esd introuvable dans l'ISO"
# Édition par NOM (insensible à la casse) ; repli image unique [m4].
WANT="${edition:-Windows 11 Pro}"
IDX="$(wimlib-imagex info "$IMG" 2>/dev/null | awk -v w="$(printf '%s' "$WANT" | tr 'A-Z' 'a-z')" -F': *' '
tolower($1) ~ /^index/{i=$2} index(tolower($0), w){print i; exit}')"
if [ -z "$IDX" ]; then
CNT="$(wimlib-imagex info "$IMG" 2>/dev/null | awk -F': *' 'tolower($1) ~ /image count/{print $2; exit}')"
[ "${CNT:-0}" = 1 ] && IDX=1
fi
[ -n "$IDX" ] || die "édition « $WANT » absente de l'ISO (wimlib-imagex info) — ISO Home/N/tiny ?"
log "Application de l'image (index $IDX = $WANT) sur $WIN_PART — long…"
wimlib-imagex apply "$IMG" "$IDX" "$WIN_PART" || die "wimlib-imagex apply a échoué"
# --- 5) Bootabilité : BCD (hivex → repli WinPE-bcdboot) ---------------------
mount "$WIN_PART" "$WIN_MNT" || die "montage NTFS échoué"
mount "$ESP_PART" "$ESP_MNT" || die "montage WINESP échoué"
mkdir -p "$ESP_MNT/EFI/Microsoft/Boot"
cp -rn "$WIN_MNT/Windows/Boot/EFI/." "$ESP_MNT/EFI/Microsoft/Boot/" 2>/dev/null || true
WIN_PARTUUID="$(blkid -s PARTUUID -o value "$WIN_PART" 2>/dev/null)"
DISK_GUID="$(sgdisk -p "$WIN_DISK" 2>/dev/null | sed -n 's/^Disk identifier (GUID): *//p')"
if /usr/local/lib/fws/fws-bcd-write "$ESP_MNT" "$WIN_MNT" "$WIN_PARTUUID" "$DISK_GUID"; then
log "BCD écrit (voie hivex)."
BCDFIX=0
else
warn "Voie hivex non validée → repli WinPE-bcdboot (1 reboot auto)."
umount "$WIN_MNT" 2>/dev/null || true
/usr/local/bin/fws-windows-bcdfix "$ISO_MNT" "$ESP_MNT" "$WIN_DISK" 1 \
|| die "repli WinPE-bcdfix a échoué — Windows non bootable (FWS reste seul bootable)"
BCDFIX=1
mount "$WIN_PART" "$WIN_MNT" 2>/dev/null || die "remontage NTFS échoué"
fi
# --- 6) Anti-BitLocker HORS LIGNE (crucial : évite le lockout) --------------
HIVE="$WIN_MNT/Windows/System32/config/SYSTEM"
if command -v hivexget >/dev/null 2>&1 && [ -f "$HIVE" ]; then
SEL="$(hivexget "$HIVE" Select Current 2>/dev/null | tr -cd '0-9')"; [ -n "$SEL" ] || SEL=1
printf '[HKEY_LOCAL_MACHINE\\SYSTEM\\ControlSet%03d\\Control\\BitLocker]\n"PreventDeviceEncryption"=dword:00000001\n' "$SEL" \
| hivexregedit --merge --prefix 'HKEY_LOCAL_MACHINE\SYSTEM' "$HIVE" \
&& log "Anti-BitLocker offline posé (ControlSet$(printf '%03d' "$SEL"))." \
|| warn "écriture anti-BitLocker offline échouée (ceinture reg add en specialize)."
else
warn "hivex/SYSTEM absent → anti-BitLocker via la ceinture specialize seule."
fi
# --- 7) Answer file offline + injection du composant gameboot ---------------
TMPL=/usr/local/share/fws/gameboot-windows/unattend.offline.xml.tmpl
HN="$(tr -cd 'a-zA-Z0-9-' < /tmp/fws-hostname 2>/dev/null)"; [ -n "$HN" ] || HN=fws-win
LOC="$(. "$SYSROOT/etc/locale.conf" 2>/dev/null; printf '%s' "${LANG%%.*}")"; [ -n "$LOC" ] || LOC=fr-CH
LOC="${LOC/_/-}"
FIRSTBOOT='C:\ProgramData\FWS\deploy\FirstBoot-FwsGameboot.ps1'
mkdir -p "$WIN_MNT/Windows/Panther" "$WIN_MNT/ProgramData/FWS/deploy"
sed -e "s|__HOSTNAME__|$HN|g" -e "s|__USER__|gaming|g" -e "s|__DISPLAY__|Gaming|g" \
-e "s|__LOCALE__|$LOC|g" -e "s|__INPUT__|$LOC|g" -e "s|__FIRSTBOOT__|${FIRSTBOOT//\\/\\\\}|g" \
"$TMPL" > "$WIN_MNT/Windows/Panther/unattend.xml" || die "rendu unattend.xml échoué"
cp -rf /usr/local/share/fws/gameboot-windows/. "$WIN_MNT/ProgramData/FWS/deploy/" || die "injection gameboot échouée"
rm -f "$WIN_MNT/ProgramData/FWS/deploy/unattend.offline.xml.tmpl" 2>/dev/null || true
log "unattend.xml + composant gameboot injectés sur la NTFS."
# --- 8) Vérification AVANT toute écriture NVRAM -----------------------------
[ -f "$WIN_MNT/Windows/System32/ntoskrnl.exe" ] || die "ntoskrnl.exe absent → apply incomplet"
[ -f "$WIN_MNT/Windows/System32/winload.efi" ] || die "winload.efi absent"
[ -f "$ESP_MNT/EFI/Microsoft/Boot/bootmgfw.efi" ] || die "bootmgfw.efi absent de l'ESP"
[ "$BCDFIX" = 1 ] || [ -s "$ESP_MNT/EFI/Microsoft/Boot/BCD" ] || die "BCD absent/vide (voie hivex)"
sync
# --- 9) Entrées de boot : FWS TOUJOURS en tête [M5][M6] ---------------------
# Voie hivex directe : on crée l'entrée Windows persistante (en queue).
if [ "$BCDFIX" = 0 ]; then
efibootmgr -c -d "$WIN_DISK" -p 1 -L 'Windows Boot Manager' -l '\EFI\Microsoft\Boot\bootmgfw.efi' >/dev/null 2>&1 || warn "création entrée UEFI Windows échouée"
WBN="$(efibootmgr 2>/dev/null | sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\? *Windows Boot Manager.*/\1/p' | head -1)"
[ -n "$WBN" ] && sed -i "s/^FWS_WINDOWS_BOOTNUM=.*/FWS_WINDOWS_BOOTNUM=\"$WBN\"/" \
"$SYSROOT/etc/fws/gameboot.conf" 2>/dev/null || true
fi
# Dans TOUS les cas : FWS en BootOrder[0] (Windows/WinPE jamais 1er). Si aucune
# entrée NVRAM FWS (booté via \EFI\BOOT\BOOTX64.EFI removable), en créer une.
FBN="$(efibootmgr 2>/dev/null | sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\? *\(FWS\|GRUB\).*/\1/p' | head -1)"
if [ -z "$FBN" ]; then
esp_src="$(findmnt -no SOURCE "$SYSROOT/boot/efi" 2>/dev/null)"
if [ -b "$esp_src" ]; then
sdisk="/dev/$(lsblk -no PKNAME "$esp_src" 2>/dev/null | head -1)"
spn="$(lsblk -no PARTN "$esp_src" 2>/dev/null | head -1)"
efibootmgr -c -d "$sdisk" -p "${spn:-1}" -L 'FWS' -l '\EFI\BOOT\BOOTX64.EFI' >/dev/null 2>&1 || true
FBN="$(efibootmgr 2>/dev/null | sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\? *FWS.*/\1/p' | head -1)"
fi
fi
order="$(efibootmgr 2>/dev/null | sed -n 's/^BootOrder: //p' | tr -d '[:space:]')"
if [ -n "$FBN" ] && [ -n "$order" ]; then
rest="$(printf '%s' "$order" | tr ',' '\n' | grep -vx "$FBN" | paste -sd, -)"
efibootmgr -o "${FBN}${rest:+,$rest}" >/dev/null 2>&1 || true # FWS 1er, reste (Windows/WinPE) derrière
fi
# --- 10) Hibernation FWS (chroot cible) : swap dédié → resume= --------------
SWAP_UUID="$(blkid -s UUID -o value "$SWAP_PART" 2>/dev/null)"
if [ -n "$SWAP_UUID" ] && command -v arch-chroot >/dev/null 2>&1; then
log "Config hibernation FWS (resume=UUID=$SWAP_UUID)…"
cat > "$SYSROOT/etc/fws-gameboot-swap.sh" <<CHROOT
set -u
grep -q "UUID=$SWAP_UUID" /etc/fstab 2>/dev/null || printf 'UUID=%s none swap defaults 0 0\n' "$SWAP_UUID" >> /etc/fstab
grep -qE '^HOOKS=.*\bresume\b' /etc/mkinitcpio.conf || sed -i -E 's/^(HOOKS=\(.*)\bfilesystems\b/\1resume filesystems/' /etc/mkinitcpio.conf
mkinitcpio -P || true
if grep -q '^GRUB_CMDLINE_LINUX_DEFAULT=' /etc/default/grub; then
grep -q 'resume=UUID=$SWAP_UUID' /etc/default/grub || sed -i 's|^GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"|GRUB_CMDLINE_LINUX_DEFAULT="\1 resume=UUID=$SWAP_UUID hibernate.compressor=lz4"|' /etc/default/grub
fi
grep -q '^GRUB_DISABLE_OS_PROBER=' /etc/default/grub || echo 'GRUB_DISABLE_OS_PROBER=true' >> /etc/default/grub
grub-mkconfig -o /boot/grub/grub.cfg || true
install -Dm644 /dev/stdin /etc/modprobe.d/nvidia-hibernate.conf <<'NV'
options nvidia NVreg_PreserveVideoMemoryAllocations=1 NVreg_TemporaryFilePath=/var/tmp
NV
systemctl enable nvidia-suspend.service nvidia-hibernate.service nvidia-resume.service 2>/dev/null || true
systemctl enable fws-gameboot-bootfix.service 2>/dev/null || true
CHROOT
arch-chroot "$SYSROOT" bash /etc/fws-gameboot-swap.sh 2>&1 | tee -a /tmp/fws-windows-deploy.log || warn "config hibernation chroot partielle"
rm -f "$SYSROOT/etc/fws-gameboot-swap.sh"
else
warn "swap UUID introuvable ou arch-chroot absent → hibernation non configurée (reboot simple restera dispo)."
fi
sync
status "DEPLOY_OK: Windows sur $WIN_DISK (${size_gib}Gio) — SB à activer au firmware"
log "Déploiement Windows terminé.$([ "$BCDFIX" = 1 ] && echo ' (BCD via WinPE au 1er reboot)')"
log "⚠ Reste manuel : activer Secure Boot au firmware (sbctl verify déjà clean)."
exit 0