# ============================================================ # Omika - Module commun de deploiement des services Windows # ============================================================ # Utilise par les scripts Install-Omika*.ps1 (UN FICHIER PAR DEPLOIEMENT). # # Ce module regroupe ce qui etait duplique ou implicite dans l'ancien # Install-OmikaServices.ps1 monolithique : # - controle des droits administrateur ; # - messages homogenes (etape / info / ok / avertissement / echec) ; # - creation de dossiers idempotente ; # - ACL NTFS AVEC CONTROLE DU CODE RETOUR d'icacls ; # - validation du compte de service (existence + droit « Ouvrir une session # en tant que service ») ; # - creation / suppression / verification d'un service Windows. # # COMPATIBILITE : Windows PowerShell 5.1 (aucune syntaxe PS 7). # # CE MODULE NE DEMARRE JAMAIS UN SERVICE. Il cree et configure uniquement ; # le demarrage reste une action manuelle et explicite (doctrine Phase 5C.2C). # # A PROPOS DE -WhatIf : # Les variables de preference ($WhatIfPreference) NE se propagent PAS dans # l'etat de session d'un module. Les scripts appelants doivent donc transmettre # explicitement -WhatIf:$Simulation a chaque appel. Chaque fonction marquee # [CmdletBinding(SupportsShouldProcess)] l'accepte. # ============================================================ # --- Messages ---------------------------------------------------------------- function Write-OmikaStep { param([Parameter(Mandatory = $true)][string]$Message) Write-Host '' Write-Host "== $Message ==" -ForegroundColor Cyan } function Write-OmikaInfo { param([Parameter(Mandatory = $true)][string]$Message) Write-Host " .. $Message" } function Write-OmikaOk { param([Parameter(Mandatory = $true)][string]$Message) Write-Host " OK $Message" -ForegroundColor Green } function Write-OmikaWarn { param([Parameter(Mandatory = $true)][string]$Message) Write-Host " ATTENTION $Message" -ForegroundColor Yellow } function Write-OmikaFail { param([Parameter(Mandatory = $true)][string]$Message) Write-Host " ECHEC $Message" -ForegroundColor Red } # --- Prerequis --------------------------------------------------------------- function Assert-OmikaAdmin { <# .SYNOPSIS Verifie que la session PowerShell est elevee. Leve une exception sinon. #> $identite = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identite) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw "Ce script doit etre execute en PowerShell ADMINISTRATEUR (clic droit -> Executer en tant qu'administrateur)." } } function Get-OmikaPaths { <# .SYNOPSIS Resout l'arborescence serveur C:\Omika (meme convention que Install-OmikaServerFiles.ps1). #> param([string]$InstallRoot = 'C:\Omika') return [pscustomobject]@{ InstallRoot = $InstallRoot Apps = Join-Path $InstallRoot 'Apps' Data = Join-Path $InstallRoot 'Data' Logs = Join-Path $InstallRoot 'Logs' Config = Join-Path $InstallRoot 'Config' Backups = Join-Path $InstallRoot 'Backups' Deploy = Join-Path $InstallRoot 'Deploy' } } # --- Dossiers ---------------------------------------------------------------- function New-OmikaDirectory { [CmdletBinding(SupportsShouldProcess = $true)] param([Parameter(Mandatory = $true)][string]$Path) if (Test-Path -LiteralPath $Path) { Write-OmikaInfo "Dossier deja present : $Path" return $false } if ($PSCmdlet.ShouldProcess($Path, 'Creer le dossier')) { New-Item -ItemType Directory -Force -Path $Path | Out-Null Write-OmikaOk "Dossier cree : $Path" return $true } return $false } # --- Compte de service ------------------------------------------------------- function Resolve-OmikaAccountName { <# .SYNOPSIS Normalise un compte de service en forme NON AMBIGUE 'ORDINATEUR\compte'. .DESCRIPTION L'ancien script passait la forme '.\svc_omika_api' directement a icacls. La forme '.\' est une commodite de ligne de commande : elle n'est pas la forme documentee d'icacls (qui attend 'Compte' ou 'Domaine\Compte'). Comme le bac a sable de developpement ne permet pas de verifier les ecritures d'ACL, ce module ne pariera pas sur la forme '.\' : il resout systematiquement en 'ORDINATEUR\compte', forme non ambigue, et le script CONTROLE le code retour d'icacls (voir Grant-OmikaAcl). #> param( [Parameter(Mandatory = $true)][string]$Account, [string]$ComputerName = $env:COMPUTERNAME ) if ($Account -eq '.') { return "$ComputerName\$env:USERNAME" } if ($Account.StartsWith('.\')) { return ('{0}\{1}' -f $ComputerName, $Account.Substring(2)) } if ($Account.StartsWith('./')) { return ('{0}\{1}' -f $ComputerName, $Account.Substring(2)) } return $Account } function Test-OmikaLocalAccount { <# .SYNOPSIS Verifie qu'un compte de service existe sur la machine. .OUTPUTS PSCustomObject : Nom, FormeResolue, Existe ($true/$false/$null si domaine), Actif, Sid, Raison. #> param( [Parameter(Mandatory = $true)][string]$Account, [string]$ComputerName = $env:COMPUTERNAME ) $resolu = Resolve-OmikaAccountName -Account $Account -ComputerName $ComputerName $resultat = [pscustomobject]@{ Nom = $Account FormeResolue = $resolu Existe = $null Actif = $null Sid = $null Raison = '' } # Compte de domaine : non verifiable localement (le DC est autoritaire). $nomCourt = $resolu if ($resolu.Contains('\')) { $nomCourt = $resolu.Substring($resolu.IndexOf('\') + 1) } if ($resolu.Contains('\') -and -not $resolu.StartsWith("$ComputerName\", [System.StringComparison]::OrdinalIgnoreCase)) { $resultat.Raison = 'compte de domaine : existence non verifiable depuis ce serveur' return $resultat } # 1) Get-LocalUser (module LocalAccounts) : leve UserNotFoundException si le # compte est absent. C'est le chemin nominal. if (Get-Command -Name Get-LocalUser -ErrorAction SilentlyContinue) { try { $utilisateur = Get-LocalUser -Name $nomCourt -ErrorAction Stop $resultat.Existe = $true $resultat.Actif = [bool]$utilisateur.Enabled if ($utilisateur.SID) { $resultat.Sid = $utilisateur.SID.Value } return $resultat } catch { if ($_.Exception.GetType().Name -eq 'UserNotFoundException') { $resultat.Existe = $false $resultat.Raison = "compte local introuvable : $resolu" return $resultat } # Autre erreur (droits, module...) : on ne conclut pas, on passe au repli. } } # 2) Repli net.exe user : code retour 0 = le compte existe, non nul = absent. # # PAS DE REPLI ADSI. Verifie en test sur ce poste : # [ADSI]("WinNT://ORDINATEUR/compte,user") -> AUCUNE exception pour un # compte inexistant (et .Name vide) : ce repli produisait un FAUX POSITIF # « Existe = True », c'est-a-dire exactement le diagnostic inverse de # celui attendu. Un faux positif est pire qu'une incertitude ici : le # script laisserait creer un service avec un compte inexistant. # Le nom est filtre sur le guillemet simple (argument de ligne de commande). if ($nomCourt -notmatch "'") { $ancien = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $null = (& net.exe user $nomCourt 2>&1 | Out-String) $code = $LASTEXITCODE } finally { $ErrorActionPreference = $ancien } if ($code -eq 0) { $resultat.Existe = $true return $resultat } $resultat.Existe = $false $resultat.Raison = "compte local introuvable : $resolu" return $resultat } # 3) Ni l'un ni l'autre exploitable : on reste INDETERMINE (jamais « existe »). $resultat.Raison = 'existence non verifiable (ni Get-LocalUser ni net.exe exploitables)' return $resultat } function Get-OmikaServiceLogonRight { <# .SYNOPSIS Lit la liste des comptes titulaires du droit « Ouvrir une session en tant que service » (SeServiceLogonRight) via un export secedit. .OUTPUTS Tableau d'entrees (SID ou nom), ou $null si indeterminable (non admin). #> $fichier = Join-Path $env:TEMP ('omika-secedit-{0}.inf' -f ([guid]::NewGuid().ToString('N'))) $ancien = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { & secedit.exe /export /areas USER_RIGHTS /cfg $fichier | Out-Null if (-not (Test-Path -LiteralPath $fichier)) { return $null } # Les noms de privileges (SeServiceLogonRight) ne sont PAS localises : # le filtrage fonctionne sur un Windows francais. $ligne = Get-Content -LiteralPath $fichier | Where-Object { $_ -match '^\s*SeServiceLogonRight\s*=' } | Select-Object -First 1 if (-not $ligne) { return @() } $valeur = ($ligne -split '=', 2)[1] $entrees = @($valeur -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) return $entrees } catch { return $null } finally { $ErrorActionPreference = $ancien if (Test-Path -LiteralPath $fichier) { Remove-Item -LiteralPath $fichier -Force -ErrorAction SilentlyContinue } } } function Test-OmikaServiceLogonRight { <# .SYNOPSIS Verifie que le compte possede le droit « Ouvrir une session en tant que service ». C'est la cause n1 d'echec de demarrage (erreur 1069/1053). .OUTPUTS $true, $false, ou $null si le droit n'a pas pu etre determine. #> param( [Parameter(Mandatory = $true)][string]$Account, [string]$ComputerName = $env:COMPUTERNAME ) $compte = Test-OmikaLocalAccount -Account $Account -ComputerName $ComputerName $droits = Get-OmikaServiceLogonRight if ($null -eq $droits) { return $null } $candidats = @() if ($compte.Sid) { $candidats += $compte.Sid } if ($compte.FormeResolue) { $candidats += $compte.FormeResolue } $nomCourt = $compte.FormeResolue if ($nomCourt.Contains('\')) { $nomCourt = $nomCourt.Substring($nomCourt.IndexOf('\') + 1) } if ($nomCourt) { $candidats += $nomCourt } foreach ($entree in $droits) { foreach ($candidat in $candidats) { if ([string]::IsNullOrWhiteSpace($candidat)) { continue } if ($entree -eq $candidat) { return $true } # secedit exporte les comptes locaux sous forme de SID (*S-1-5-21-...). if ($entree -like "*$candidat*") { return $true } } } return $false } function Assert-OmikaServiceAccount { <# .SYNOPSIS Verifie le compte de service avant la creation (existence + droit de connexion). Leve une exception si le compte n'existe pas ; avertit seulement si un droit manque (New-Service peut encore reussir, mais le DEMARRAGE echouera). #> param( [Parameter(Mandatory = $true)][string]$Account, [string]$ComputerName = $env:COMPUTERNAME ) $compte = Test-OmikaLocalAccount -Account $Account -ComputerName $ComputerName Write-OmikaInfo "Compte de service : $($compte.FormeResolue)" if ($compte.Existe -eq $false) { throw ("Compte de service introuvable : $($compte.FormeResolue). " + "Creez le compte local dedie AVANT ce script (jamais LocalSystem, " + "jamais un compte humain).") } if ($compte.Existe -eq $null) { Write-OmikaWarn "Existence du compte non verifiable ($($compte.Raison))." } elseif ($compte.Actif -eq $false) { Write-OmikaWarn "Le compte $($compte.FormeResolue) est DESACTIVE : le service ne demarrera pas." } $droit = Test-OmikaServiceLogonRight -Account $Account -ComputerName $ComputerName if ($droit -eq $false) { Write-OmikaWarn "Le compte $($compte.FormeResolue) ne possede PAS le droit" Write-OmikaWarn " « Ouvrir une session en tant que service » (SeServiceLogonRight)." Write-OmikaWarn " -> La creation du service reussira, mais le DEMARRAGE echouera (erreur 1069)." Write-OmikaWarn " -> secpol.msc : Attribution des droits utilisateur / Ouvrir une session en tant que service." } elseif ($droit -eq $null) { Write-OmikaWarn "Droit de connexion en tant que service : non verifiable (secedit inaccessible)." } else { Write-OmikaOk "Droit « Ouvrir une session en tant que service » : present." } return $compte } # --- ACL --------------------------------------------------------------------- function Grant-OmikaAcl { <# .SYNOPSIS Pose une ACL NTFS et CONTROLE le code retour d'icacls. .DESCRIPTION L'ancien script ignorait totalement le code retour d'icacls : une ACL refusee passait inapercue a l'installation et ne se manifestait qu'au premier demarrage du service, par une erreur d'acces illisible. Deux cas sont distingues : - dossier absent -> avertissement BRUYANT (sauf -RequireExisting) ; - icacls en echec -> exception (le script s'arrete). .PARAMETER Right M = Modifier (ecriture), R = Lecture seule. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Account, [ValidateSet('M', 'R')][string]$Right = 'M', [switch]$RequireExisting ) $resolu = Resolve-OmikaAccountName -Account $Account if (-not (Test-Path -LiteralPath $Path)) { $message = "Dossier absent, aucune ACL posee : $Path (compte $resolu)" if ($RequireExisting) { throw $message } Write-OmikaWarn $message Write-OmikaWarn ' -> le service subira une erreur d''acces a son premier demarrage.' return $false } $ace = '{0}:(OI)(CI){1}' -f $resolu, $Right if (-not $PSCmdlet.ShouldProcess($Path, "ACL $Right pour $resolu")) { return $false } # icacls ecrit ses erreurs sur stderr : sous $ErrorActionPreference='Stop' une # NativeCommandError deviendrait TERMINANTE et masquerait le vrai code retour. # On isole donc l'appel. $ancien = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $sortie = (& icacls.exe $Path /grant:r $ace 2>&1 | Out-String) $code = $LASTEXITCODE } finally { $ErrorActionPreference = $ancien } if ($code -ne 0) { throw ("icacls a echoue (code $code) sur '$Path' pour '$resolu'.`n" + "Sortie icacls : $($sortie.Trim())`n" + "Verifiez que le compte existe et que la forme du nom est acceptee " + "('ORDINATEUR\compte').") } Write-OmikaOk "ACL $Right : $resolu sur $Path" return $true } # --- Service Windows --------------------------------------------------------- function Get-OmikaServiceState { <# .SYNOPSIS Etat reel d'un service (CIM), ou $null s'il n'existe pas. Permet de detecter un service existant qui pointe sur un AUTRE binaire ou tourne sous un AUTRE compte que ceux attendus. #> param([Parameter(Mandatory = $true)][string]$Name) $service = Get-CimInstance -ClassName Win32_Service -Filter ("Name='{0}'" -f $Name) -ErrorAction SilentlyContinue if (-not $service) { return $null } return [pscustomobject]@{ Nom = $service.Name NomAffiche = $service.DisplayName Etat = $service.State Demarrage = $service.StartMode CheminExe = $service.PathName Compte = $service.StartName Description = $service.Description } } function Install-OmikaService { <# .SYNOPSIS Cree un service Windows (StartupType Manual par defaut), de facon IDEMPOTENTE, et verifie ce qui a ete reellement cree. .DESCRIPTION - Service inexistant -> creation (mot de passe saisi via Get-Credential, jamais en dur, jamais affiche). - Service existant -> aucune creation ; comparaison du binaire et du compte avec les valeurs attendues, avec avertissement si divergence. - Ne demarre JAMAIS le service. .OUTPUTS PSCustomObject : Nom, Cree, ExistaitDeja, Conforme, Etat. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$DisplayName, [Parameter(Mandatory = $true)][string]$ExePath, [Parameter(Mandatory = $true)][string]$Account, [string]$Description = '', [ValidateSet('Manual', 'Automatic', 'Disabled')][string]$StartupType = 'Manual' ) $resultat = [pscustomobject]@{ Nom = $Name Cree = $false ExistaitDeja = $false Conforme = $null Etat = '' } if (-not (Test-Path -LiteralPath $ExePath -PathType Leaf)) { throw "Executable introuvable pour le service '$Name' : $ExePath" } $attendu = '"{0}"' -f $ExePath $existant = Get-OmikaServiceState -Name $Name if ($existant) { $resultat.ExistaitDeja = $true $resultat.Etat = $existant.Etat Write-OmikaInfo "Service '$Name' deja present : aucune creation (idempotent)." $conforme = $true if ($existant.CheminExe -and $existant.CheminExe.Trim('"') -ne $ExePath) { Write-OmikaWarn " Binaire DIFFERENT de celui attendu :" Write-OmikaWarn " attendu : $ExePath" Write-OmikaWarn " reel : $($existant.CheminExe)" $conforme = $false } $compteAttendu = (Resolve-OmikaAccountName -Account $Account).ToLowerInvariant() if ($existant.Compte -and $existant.Compte.ToLowerInvariant() -ne $compteAttendu) { Write-OmikaWarn " Compte DIFFERENT de celui attendu :" Write-OmikaWarn " attendu : $compteAttendu" Write-OmikaWarn " reel : $($existant.Compte)" $conforme = $false } $resultat.Conforme = $conforme return $resultat } if ($PSCmdlet.ShouldProcess($Name, "Creer le service Windows (compte $Account, $StartupType)")) { $cred = Get-Credential -UserName $Account -Message "Mot de passe du compte de service $Name" $parametres = @{ Name = $Name DisplayName = $DisplayName BinaryPathName = $attendu Credential = $cred StartupType = $StartupType } if ($Description) { $parametres['Description'] = $Description } New-Service @parametres | Out-Null Write-OmikaOk "Service cree : $Name ($StartupType, non demarre)." $resultat.Cree = $true $etat = Get-OmikaServiceState -Name $Name if ($etat) { $resultat.Etat = $etat.Etat } } return $resultat } function Remove-OmikaService { <# .SYNOPSIS Supprime un service Windows (rollback). Refuse si le service tourne encore : il faut l'arreter explicitement d'abord (aucun arret automatique). #> [CmdletBinding(SupportsShouldProcess = $true)] param([Parameter(Mandatory = $true)][string]$Name) $existant = Get-OmikaServiceState -Name $Name if (-not $existant) { Write-OmikaInfo "Service '$Name' absent : rien a supprimer." return $false } if ($existant.Etat -eq 'Running') { throw "Le service '$Name' est en cours d'execution : arretez-le explicitement (Stop-Service $Name) avant de le supprimer." } if ($PSCmdlet.ShouldProcess($Name, 'Supprimer le service Windows')) { $ancien = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $sortie = (& sc.exe delete $Name 2>&1 | Out-String) $code = $LASTEXITCODE } finally { $ErrorActionPreference = $ancien } if ($code -ne 0) { throw "sc.exe delete a echoue (code $code) pour '$Name' : $($sortie.Trim())" } Write-OmikaOk "Service supprime : $Name" return $true } return $false } # --- Restitutions ------------------------------------------------------------ function Write-OmikaServiceSummary { <# .SYNOPSIS Affiche un recapitulatif de fin d'installation : etat reel du service et rappels de ce qui reste MANUEL. #> param( [Parameter(Mandatory = $true)][string]$Name, [string]$ExePath = '', [string]$Account = '', [string[]]$Rappels = @(), [switch]$Supprime ) Write-Host '' if ($Supprime) { Write-Host ("{0} : SUPPRIME." -f $Name) -ForegroundColor Green return } Write-Host ("{0} : INSTALLATION TERMINEE (service NON demarre)." -f $Name) -ForegroundColor Green $etat = Get-OmikaServiceState -Name $Name if ($etat) { Write-Host '' Write-Host ' Etat reel du service :' Write-Host (" Nom : {0}" -f $etat.Nom) Write-Host (" Nom affiche : {0}" -f $etat.NomAffiche) Write-Host (" Etat : {0}" -f $etat.Etat) Write-Host (" Demarrage : {0}" -f $etat.Demarrage) Write-Host (" Binaire : {0}" -f $etat.CheminExe) Write-Host (" Compte : {0}" -f $etat.Compte) } else { Write-OmikaWarn "Le service '$Name' est introuvable apres installation (mode -WhatIf ?)." } if ($Rappels.Count -gt 0) { Write-Host '' Write-Host ' RAPPELS (jamais automatises par ce script) :' -ForegroundColor Yellow foreach ($rappel in $Rappels) { Write-Host (" - {0}" -f $rappel) -ForegroundColor Yellow } } Write-Host '' Write-Host ' Le demarrage est une action MANUELLE et explicite :' -ForegroundColor Yellow Write-Host (" Start-Service {0}" -f $Name) -ForegroundColor Yellow Write-Host '' } # --- Port loopback ----------------------------------------------------------- function Get-OmikaDebutPlageEphemere { <# .SYNOPSIS Borne basse de la plage de ports ephemere Windows (49152 par defaut). .DESCRIPTION La sortie de netsh est LOCALISEE : on prend le premier nombre de la reponse, qui est toujours le port de debut, quelle que soit la langue. #> $texte = (netsh int ipv4 show dynamicport tcp | Out-String) $nombres = [regex]::Matches($texte, '\d+') if ($nombres.Count -ge 1) { return [int]$nombres[0].Value } return 49152 } function Get-OmikaPlagesReservees { <# .SYNOPSIS Plages TCP reservees par Windows (Hyper-V / WSL / Docker peuvent en reserver arbitrairement). Necessite une session elevee. #> $plages = @() $ancien = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $lignes = netsh int ipv4 show excludedportrange protocol=tcp } finally { $ErrorActionPreference = $ancien } foreach ($ligne in $lignes) { $m = [regex]::Match([string]$ligne, '^\s*(\d{1,5})\s+(\d{1,5})\s*$') if ($m.Success) { $plages += [pscustomobject]@{ Debut = [int]$m.Groups[1].Value Fin = [int]$m.Groups[2].Value } } } return $plages } function Test-OmikaPortLibre { <# .SYNOPSIS Determine si un port TCP est utilisable sur ce serveur. .DESCRIPTION Quatre causes de refus sont testees : 1. un processus ecoute deja sur ce port ; 2. le port tombe dans une plage RESERVEE par Windows ; 3. le port tombe dans la plage EPHEMERE (une connexion sortante peut le prendre) ; 4. la reservation reelle sur 127.0.0.1 echoue. Le test 4 est la preuve : le port est reellement reserve puis relache. .OUTPUTS PSCustomObject : Port, Libre (booleen), Raisons (texte). #> param( [Parameter(Mandatory = $true)] [ValidateRange(1, 65535)] [int]$Port, [switch]$VerifierPlagesReservees ) $refus = New-Object System.Collections.Generic.List[string] # --- 1. Deja en ecoute ? ----------------------------------------------- $ecoute = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) if ($ecoute.Count -gt 0) { $qui = @() foreach ($connexion in $ecoute) { $processus = Get-Process -Id $connexion.OwningProcess -ErrorAction SilentlyContinue if ($processus) { $qui += ('{0} (PID {1})' -f $processus.ProcessName, $connexion.OwningProcess) } else { $qui += ('PID {0}' -f $connexion.OwningProcess) } } $refus.Add('deja en ecoute par ' + (($qui | Select-Object -Unique) -join ', ')) } # --- 2. Plage reservee ? ----------------------------------------------- if ($VerifierPlagesReservees) { foreach ($plage in (Get-OmikaPlagesReservees)) { if ($Port -ge $plage.Debut -and $Port -le $plage.Fin) { $refus.Add(('dans une plage RESERVEE par Windows ({0} a {1})' -f $plage.Debut, $plage.Fin)) } } } # --- 3. Plage ephemere ? ---------------------------------------------- $debutEphemere = Get-OmikaDebutPlageEphemere if ($Port -ge $debutEphemere) { $refus.Add(('dans la plage EPHEMERE (a partir de {0}) : une connexion sortante peut le prendre' -f $debutEphemere)) } # --- 4. Test reel : reserver le port sur 127.0.0.1 --------------------- $verrou = $null try { $verrou = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) $verrou.Start() } catch { $refus.Add("reservation impossible : $($_.Exception.Message)") } finally { if ($verrou) { try { $verrou.Stop() } catch { } } } return [pscustomobject]@{ Port = $Port Libre = ($refus.Count -eq 0) Raisons = ($refus -join ' ; ') } } Export-ModuleMember -Function @( 'Write-OmikaStep', 'Write-OmikaInfo', 'Write-OmikaOk', 'Write-OmikaWarn', 'Write-OmikaFail', 'Assert-OmikaAdmin', 'Get-OmikaPaths', 'New-OmikaDirectory', 'Resolve-OmikaAccountName', 'Test-OmikaLocalAccount', 'Get-OmikaServiceLogonRight', 'Test-OmikaServiceLogonRight', 'Assert-OmikaServiceAccount', 'Grant-OmikaAcl', 'Get-OmikaServiceState', 'Install-OmikaService', 'Remove-OmikaService', 'Write-OmikaServiceSummary', 'Get-OmikaDebutPlageEphemere', 'Get-OmikaPlagesReservees', 'Test-OmikaPortLibre' )