0

Déployer Zabbix Agent 2 en masse via WinRM — ce qui merde et pourquoi

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 :

  1. Récupérer la liste des cibles depuis l’AD (filtre sur le nom de machine)
  2. Copier le MSI sur chaque cible via Admin$
  3. Lancer l’installation silencieuse à distance avec Invoke-Command
  4. 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

Plop

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *