Mettre à jour un agent de monitoring sur un parc Windows sans GPO ni outil de déploiement dédié, c’est faisable en PowerShell. Mais entre le script « qui marche sur le papier » et celui qui tient en prod, il y a trois ou quatre pièges qui coûtent une soirée si on ne les connaît pas.
Le contexte
Besoin simple : pousser (et surtout mettre à jour) l’agent Zabbix sur un parc de serveurs Windows joints à l’AD. Pas de GPO de déploiement en place, pas de SCCM, pas envie d’en monter un pour ça. WinRM est activé, j’ai le MSI de l’agent, et PowerShell fait le reste.
L’idée tient en quatre étapes :
- Récupérer la liste des cibles depuis l’AD (filtre sur le nom de machine)
- Copier le MSI sur chaque cible via
Admin$ - Lancer l’installation silencieuse à distance avec
Invoke-Command - Logguer le résultat et sortir un résumé exploitable
Le squelette est trivial :
$targets = Get-ADComputer -Filter { Name -like "*srv*" } |
Select-Object -ExpandProperty Name
foreach ($target in $targets) {
Copy-Item $MsiLocal "\\$target\Admin$\Temp\agent.msi" -Force
Invoke-Command -ComputerName $target -ScriptBlock {
Start-Process msiexec.exe -ArgumentList "/i C:\Windows\Temp\agent.msi /qn" -Wait
}
}
Et c’est là que ça se corse. Ce squelette a l’air correct. Il ne l’est pas.
Piège 1 — le ping comme test de joignabilité
Le réflexe, c’est de tester la machine avec Test-Connection (ping) avant de tenter quoi que ce soit. Mauvaise idée : l’ICMP est bloqué par défaut par le pare-feu Windows, et souvent renforcé par GPO. Résultat, des machines parfaitement opérationnelles sont déclarées injoignables et sautées.
Ce qu’on veut vraiment savoir, c’est si WinRM répond. Donc on teste le port :
Test-NetConnection $target -Port 5985 -InformationLevel Quiet -WarningAction SilentlyContinue
C’est le seul test qui reflète ce qu’on va réellement utiliser.
Piège 2 — l’exit code 1603 sur une mise à jour
Le plus tordu, et celui qui m’a fait perdre le plus de temps. Sur certaines machines, l’install sortait en 1603 — l’échec fatal générique de Windows Installer, celui qui ne dit rien d’utile.
La cause : l’agent avait été installé à l’origine depuis un MSI téléchargé deux fois par le navigateur, renommé automatiquement avec un suffixe (1). Windows Installer mémorise ce chemin comme source de référence pour les futures opérations. Quand on pousse le nouveau MSI avec un nom propre, l’installeur va chercher l’ancien fichier — qui n’existe plus — et abandonne.
La solution propre : désinstaller l’ancienne version avant d’installer la nouvelle. Mais surtout pas en énumérant Win32_Product : c’est lent, ça déclenche une reconfiguration/vérification de tous les packages MSI installés, et c’est particulièrement néfaste sur un contrôleur de domaine.
On passe par le registre à la place, on récupère le code produit, et on désinstalle proprement :
$keys = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$existing = Get-ItemProperty $keys -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like "*Zabbix Agent 2*" }
foreach ($p in $existing) {
if ($p.UninstallString -match '\{[0-9A-Fa-f\-]+\}') {
Start-Process msiexec.exe -ArgumentList "/x $($matches[0]) /qn" -Wait -NoNewWindow
}
}
Piège 3 — l’install réussit, mais le service ne démarre pas
Celui-là est vicieux parce qu’il ne lève aucune erreur. L’install se termine en moins d’une seconde, exit code 0, tout va bien… sauf que le service Zabbix n’est pas démarré. Le monitoring reste muet.
En regardant le log MSI (/l*v), la cause saute aux yeux : les actions de démarrage du service (AgentService_Run) sont sautées. Le MSI de l’agent ne démarre le service que si on lui passe explicitement la propriété STARTAGENTS=1. Sans elle, il installe, configure, et laisse le service à l’arrêt.
D’où l’importance de construire les arguments MSI correctement, et de ceinturer/bretelles avec un Start-Service de secours :
$msiArgs = @(
"/i `"$localMsi`"", "/qn", "/l*v `"$logFile`"",
"SERVER=$server", "SERVERACTIVE=$serverActive",
"HOSTNAME=$env:COMPUTERNAME",
"STARTAGENTS=1" # <-- sans ça, service à l'arrêt
) -join " "
Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -NoNewWindow
Start-Sleep -Seconds 5
$svc = Get-Service "Zabbix Agent 2" -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -ne 'Running') { Start-Service $svc }
Piège 4 — pas d’observabilité = pas de confiance
Un déploiement en masse dont on ne voit pas le résultat par machine, c’est un déploiement auquel on ne fait pas confiance. Le point qui change tout, c’est de faire remonter par le ScriptBlock distant non seulement l’exit code du MSI, mais aussi l’état réel du service après coup (Running, Stopped, NOT_FOUND).
Avec ça, le résumé final distingue trois cas nets :
- SUCCESS — exit 0/3010 et service
Running - WARN — install OK mais service pas démarré (à investiguer)
- ERROR — exit non nul (1603, 1618, etc.)
C’est cette distinction WARN/SUCCESS qui évite de croire qu’un déploiement est bon alors que la moitié des agents sont muets.
Ce que je retiens
Rien de sorcier dans ce script, mais tout l’écart entre « ça a l’air de marcher » et « ça marche vraiment » tient dans quatre détails : tester WinRM et pas le ping, désinstaller proprement sans toucher à Win32_Product, passer STARTAGENTS=1, et vérifier l’état du service plutôt que le seul code de retour du MSI.
Un mode « dry run » pour lister les cibles sans rien toucher, et une restriction à quelques hôtes de test avant de lâcher sur tout le parc, complètent le tableau. On teste sur deux machines, on regarde le résumé, et ensuite seulement on déroule.
script
Ah aussi, voici le script, je verais pour le git plus tard ;) :
#Requires -Modules ActiveDirectory
<#
.SYNOPSIS
Déploiement / mise à jour en masse de Zabbix Agent 2 sur un parc Windows AD, via WinRM.
.DESCRIPTION
- Requête AD filtrée sur le nom de machine
- Test de joignabilité WinRM (port 5985) — PAS de ping
- Copie du MSI via SMB (Admin$)
- Désinstallation propre de l'ancienne version si nécessaire (sans Win32_Product)
- Installation silencieuse via Invoke-Command (msiexec /qn + STARTAGENTS=1)
- Fallback Start-Service + remontée du ServiceStatus par machine
- Mode DryRun, liste d'exclusion, restriction à des hôtes de test
- Log CSV + résumé final
.NOTES
Prérequis :
- WinRM activé sur les cibles (ou GPO WinRM)
- Partage Admin$ accessible (droits admin locaux ou DA)
- Module ActiveDirectory sur le poste de lancement
- MSI Zabbix Agent 2 disponible en local sur la machine de déploiement
Auteur : Plop
Version : 2.0 (consolidée)
.EXAMPLE
# Test à blanc sur 2 machines, rien n'est installé :
.\Deploy-ZabbixAgent2.ps1 -DryRun -TestHosts "LABSRV01","LABSRV02"
.EXAMPLE
# Déploiement réel sur tout le parc *srv* :
.\Deploy-ZabbixAgent2.ps1
#>
[CmdletBinding()]
param(
# Ne fait aucune action distante : liste les cibles + ce qui SERAIT fait
[switch]$DryRun,
# Restreint le déploiement à ces machines uniquement (idéal pour tester)
[string[]]$TestHosts = @(),
# Machines à exclure explicitement (ex : DC sensibles, serveurs figés)
[string[]]$ExclusionList = @()
)
# ============================================================
# CONFIGURATION — À ADAPTER
# ============================================================
# Chemin LOCAL du MSI sur la machine qui lance le script
$MSI_LOCAL_PATH = "C:\Deploy\Zabbix\zabbix_agent2-7.0.25-windows-amd64-openssl.msi"
# Propriétés d'installation (noms officiels du MSI Zabbix Agent 2)
$ZBX_SERVER = "192.168.1.10" # SERVER — checks passifs
$ZBX_SERVER_ACTIVE = "192.168.1.10" # SERVERACTIVE — checks actifs
$ZBX_LISTEN_PORT = 10050 # LISTENPORT
# Filtre AD — toutes les machines dont le nom contient "srv"
$AD_FILTER = "Name -like '*srv*'"
$AD_SEARCHBASE = "" # Vide = tout le domaine ; ex : "OU=Servers,DC=EXEMPLE,DC=LAN"
# Nom d'affichage du produit à désinstaller si upgrade verrouillé (cf. gotcha 1603)
$ZBX_PRODUCT_NAME = "Zabbix Agent 2"
# Sortie
$LOG_CSV = "C:\Deploy\Zabbix\deploy-zabbix_{0}.csv" -f (Get-Date -Format "yyyyMMdd-HHmmss")
# ============================================================
# HELPERS CONSOLE
# ============================================================
function Write-Step { param($m) Write-Host "[*] $m" -ForegroundColor Cyan }
function Write-OK { param($m) Write-Host "[+] $m" -ForegroundColor Green }
function Write-Warn { param($m) Write-Host "[!] $m" -ForegroundColor Yellow }
function Write-Err { param($m) Write-Host "[x] $m" -ForegroundColor Red }
# ============================================================
# PRÉ-VOL
# ============================================================
if (-not (Test-Path $MSI_LOCAL_PATH)) {
Write-Err "MSI introuvable : $MSI_LOCAL_PATH"
exit 1
}
$MSI_FILENAME = Split-Path $MSI_LOCAL_PATH -Leaf
# ============================================================
# DÉCOUVERTE DES CIBLES
# ============================================================
Write-Step "Requête AD ($AD_FILTER)..."
$adParams = @{ Filter = $AD_FILTER }
if ($AD_SEARCHBASE) { $adParams.SearchBase = $AD_SEARCHBASE }
$targets = Get-ADComputer @adParams | Select-Object -ExpandProperty Name | Sort-Object
# Restriction hôtes de test
if ($TestHosts.Count -gt 0) {
$targets = $targets | Where-Object { $_ -in $TestHosts }
Write-Warn "Mode restreint : $($targets.Count) hôte(s) de test."
}
# Exclusions
if ($ExclusionList.Count -gt 0) {
$targets = $targets | Where-Object { $_ -notin $ExclusionList }
}
if (-not $targets) { Write-Err "Aucune cible après filtrage."; exit 1 }
Write-OK "$($targets.Count) cible(s) retenue(s)."
if ($DryRun) { Write-Warn "=== MODE DRYRUN : aucune action distante ne sera effectuée ===" }
# Confirmation (sauf DryRun)
if (-not $DryRun) {
Write-Host ""
$targets | ForEach-Object { Write-Host " - $_" }
Write-Host ""
$confirm = Read-Host "Déployer $MSI_FILENAME sur ces $($targets.Count) machines ? (o/N)"
if ($confirm -notmatch '^[oO]') { Write-Warn "Annulé."; exit 0 }
}
# ============================================================
# BOUCLE DE DÉPLOIEMENT
# ============================================================
$results = @()
foreach ($target in $targets) {
Write-Step "=== $target ==="
$row = [ordered]@{
Machine = $target
Reachable = $false
Copied = $false
ExitCode = $null
ServiceStatus = "N/A"
Status = "PENDING"
Message = ""
}
# --- 1. Joignabilité WinRM (port 5985, PAS ping) ---
if (-not (Test-NetConnection $target -Port 5985 -InformationLevel Quiet -WarningAction SilentlyContinue)) {
Write-Warn " WinRM (5985) injoignable — skip"
$row.Status = "SKIP"; $row.Message = "WinRM injoignable"
$results += [pscustomobject]$row
continue
}
$row.Reachable = $true
if ($DryRun) {
Write-Host " [DRYRUN] copierait $MSI_FILENAME puis installerait (STARTAGENTS=1)" -ForegroundColor DarkGray
$row.Status = "DRYRUN"
$results += [pscustomobject]$row
continue
}
# --- 2. Copie du MSI via Admin$ ---
$remotePath = "\\$target\Admin$\Temp\$MSI_FILENAME"
try {
Copy-Item $MSI_LOCAL_PATH $remotePath -Force -ErrorAction Stop
$row.Copied = $true
Write-OK " MSI copié"
} catch {
Write-Err " Copie échouée : $($_.Exception.Message)"
$row.Status = "ERROR"; $row.Message = "Copie Admin$ : $($_.Exception.Message)"
$results += [pscustomobject]$row
continue
}
# --- 3. Install distante ---
$invokeResult = Invoke-Command -ComputerName $target -ScriptBlock {
param($msiName, $productName, $server, $serverActive, $port)
$localMsi = "C:\Windows\Temp\$msiName"
$logFile = "C:\Windows\Temp\zabbix_install.log"
# 3a. Désinstallation préalable si une version est verrouillée sur une source absente
# (cas du MSI d'origine renommé "(1).msi" par le navigateur -> exit 1603 à l'upgrade).
# On récupère le code produit via le registre — surtout PAS Win32_Product (lent + invasif sur DC).
$uninstallKeys = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$existing = Get-ItemProperty $uninstallKeys -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like "*$productName*" }
foreach ($p in $existing) {
if ($p.UninstallString -match '\{[0-9A-Fa-f\-]+\}') {
$code = $matches[0]
Start-Process msiexec.exe -ArgumentList "/x $code /qn" -Wait -NoNewWindow
}
}
# 3b. Installation silencieuse — STARTAGENTS=1 est CRITIQUE, sans lui le service n'est pas démarré
$msiArgs = @(
"/i `"$localMsi`""
"/qn"
"/l*v `"$logFile`""
"SERVER=$server"
"SERVERACTIVE=$serverActive"
"LISTENPORT=$port"
"HOSTNAME=$env:COMPUTERNAME"
"STARTAGENTS=1"
) -join " "
$proc = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru -NoNewWindow
$exit = $proc.ExitCode
# 3c. Fallback : forcer le démarrage si le service existe mais n'est pas lancé
Start-Sleep -Seconds 5
$svc = Get-Service -Name "Zabbix Agent 2" -ErrorAction SilentlyContinue
if ($svc) {
if ($svc.Status -ne 'Running') {
try { Start-Service $svc -ErrorAction Stop } catch {}
Start-Sleep -Seconds 2
$svc = Get-Service -Name "Zabbix Agent 2" -ErrorAction SilentlyContinue
}
$svcStatus = $svc.Status.ToString()
} else {
$svcStatus = "NOT_FOUND"
}
# Nettoyage du MSI copié
Remove-Item $localMsi -Force -ErrorAction SilentlyContinue
[pscustomobject]@{ ExitCode = $exit; ServiceStatus = $svcStatus }
} -ArgumentList $MSI_FILENAME, $ZBX_PRODUCT_NAME, $ZBX_SERVER, $ZBX_SERVER_ACTIVE, $ZBX_LISTEN_PORT `
-ErrorAction SilentlyContinue
if ($null -eq $invokeResult) {
Write-Err " Invoke-Command sans retour"
$row.Status = "ERROR"; $row.Message = "Invoke-Command : pas de retour"
$results += [pscustomobject]$row
continue
}
$row.ExitCode = $invokeResult.ExitCode
$row.ServiceStatus = $invokeResult.ServiceStatus
# --- 4. Interprétation ---
switch ($invokeResult.ExitCode) {
0 { $row.Message = "OK" }
3010 { $row.Message = "OK (reboot requis)" }
1603 { $row.Message = "1603 — échec fatal (souvent source MSI d'origine absente)" }
1618 { $row.Message = "1618 — un autre msiexec est déjà en cours" }
default { $row.Message = "Exit $($invokeResult.ExitCode)" }
}
if ($invokeResult.ExitCode -in 0,3010 -and $invokeResult.ServiceStatus -eq 'Running') {
Write-OK " Installé, service Running"
$row.Status = "SUCCESS"
} elseif ($invokeResult.ExitCode -in 0,3010) {
Write-Warn " Installé mais service = $($invokeResult.ServiceStatus)"
$row.Status = "WARN"
} else {
Write-Err " $($row.Message)"
$row.Status = "ERROR"
}
$results += [pscustomobject]$row
}
# ============================================================
# EXPORT + RÉSUMÉ
# ============================================================
$results | Export-Csv -Path $LOG_CSV -NoTypeInformation -Encoding UTF8
Write-Step "Log CSV : $LOG_CSV"
Write-Host ""
Write-Host "===================== RÉSUMÉ =====================" -ForegroundColor White
$results | Group-Object Status | Sort-Object Name | ForEach-Object {
$color = switch ($_.Name) {
"SUCCESS" { "Green" }
"WARN" { "Yellow" }
"SKIP" { "DarkGray" }
"DRYRUN" { "Cyan" }
default { "Red" }
}
Write-Host (" {0,-8} : {1}" -f $_.Name, $_.Count) -ForegroundColor $color
}
Write-Host "==================================================" -ForegroundColor White