Modules/Common.ps1
|
# Script: Common.ps1 # Synopsis: Part of EDCA (Exchange Deployment & Compliance Assessment) # https://github.com/michelderooij/EDCA # Author: Michel de Rooij # Website: https://eightwone.com Set-StrictMode -Version Latest function Resolve-EDCAPath { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$BasePath ) if ([System.IO.Path]::IsPathRooted($Path)) { return $Path } return [System.IO.Path]::GetFullPath((Join-Path -Path $BasePath -ChildPath $Path)) } function New-EDCADirectoryIfMissing { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Path ) if (-not (Test-Path -Path $Path)) { $null = New-Item -Path $Path -ItemType Directory -Force } } function Write-EDCALog { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Message, [ValidateSet('INFO', 'WARN', 'ERROR')] [string]$Level = 'INFO' ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' # Collapse embedded newlines so every log entry stays on a single line. $normalizedMessage = ($Message -replace '\r?\n\s*', ' | ').Trim() Write-Host ('[{0}] [{1}] {2}' -f $timestamp, $Level, $normalizedMessage) } function Get-EDCAExceptionMessage { <# .SYNOPSIS Returns a single-line diagnostic string by walking the full exception chain. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) $parts = [System.Collections.Generic.List[string]]::new() $ex = $ErrorRecord.Exception while ($null -ne $ex) { $msg = ($ex.Message -replace '\r?\n\s*', ' ').Trim() if (-not [string]::IsNullOrWhiteSpace($msg) -and -not $parts.Contains($msg)) { $parts.Add($msg) } $ex = $ex.InnerException } return ($parts -join ' -> ') } function Invoke-EDCAServerCommand { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Server, [Parameter(Mandatory = $true)] [scriptblock]$ScriptBlock, [object[]]$ArgumentList = @() ) $localAliases = @($env:COMPUTERNAME, $env:COMPUTERNAME.ToLowerInvariant(), 'localhost', '.') if ($localAliases -contains $Server -or $Server.Equals($env:COMPUTERNAME, [System.StringComparison]::OrdinalIgnoreCase)) { Write-Verbose ('Executing script block locally on {0}.' -f $env:COMPUTERNAME) return & $ScriptBlock @ArgumentList } Write-Verbose ('Executing script block remotely on {0} via WinRM.' -f $Server) return Invoke-Command -ComputerName $Server -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop } function Test-EDCAServerRemoteConnectivity { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Server ) $localAliases = @($env:COMPUTERNAME, $env:COMPUTERNAME.ToLowerInvariant(), 'localhost', '.') if ($localAliases -contains $Server -or $Server.Equals($env:COMPUTERNAME, [System.StringComparison]::OrdinalIgnoreCase)) { Write-Verbose ('Connectivity precheck for {0}: local target, remoting check skipped.' -f $Server) return [pscustomobject]@{ Server = $Server IsLocal = $true CanReachPort = $true CanConnect = $true CanReadRemoteRegistry = $true Details = 'Local execution target.' } } $details = @() $canConnect = $true $canReachPort = $false $canReadRemoteRegistry = $false # Fast TCP port 80 reachability check — if this port is unreachable the # remote endpoint is guaranteed to be unreachable, so we short-circuit immediately. try { Write-Verbose ('Connectivity precheck for {0}: TCP port 80 reachability check.' -f $Server) $tcpClient = [System.Net.Sockets.TcpClient]::new() try { $connectTask = $tcpClient.ConnectAsync($Server, 80) if (-not $connectTask.Wait(5000)) { $canConnect = $false $details += 'TCP port 80 reachability check timed out after 5 seconds.' } elseif ($connectTask.IsFaulted) { $canConnect = $false $innerMsg = if ($null -ne $connectTask.Exception -and $null -ne $connectTask.Exception.InnerException) { $connectTask.Exception.InnerException.Message } else { [string]$connectTask.Exception } $details += ('TCP port 80 reachability check failed: {0}' -f $innerMsg) } else { $canReachPort = $true $details += 'TCP port 80 reachability check passed.' } } finally { $tcpClient.Dispose() } } catch { $canConnect = $false $details += ('TCP port 80 reachability check error: {0}' -f $_.Exception.Message) } if ($canConnect) { try { Write-Verbose ('Connectivity precheck for {0}: validating remote command and registry access.' -f $Server) $probe = Invoke-Command -ComputerName $Server -ScriptBlock { [pscustomobject]@{ ComputerName = $env:COMPUTERNAME CanReadRegistry = (Test-Path -Path 'HKLM:\SOFTWARE') } } -ErrorAction Stop $canReadRemoteRegistry = [bool]$probe.CanReadRegistry if (-not $canReadRemoteRegistry) { $details += 'Remote session succeeded, but registry access test failed.' } else { $details += ('Remote session established to {0} and registry probe succeeded.' -f [string]$probe.ComputerName) } } catch { $canConnect = $false $details += ('Remote command execution failed: {0}' -f $_.Exception.Message) } } return [pscustomobject]@{ Server = $Server IsLocal = $false CanReachPort = $canReachPort CanConnect = $canConnect CanReadRemoteRegistry = $canReadRemoteRegistry Details = ($details -join ' ') } } function ConvertTo-EDCAJson { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$InputObject ) return ($InputObject | ConvertTo-Json -Depth 12) } function ConvertFrom-EDCAJson { # Wrapper around ConvertFrom-Json that handles JSON files containing keys with different # casing (e.g. EnhancedTimeSpan serialises with both a "value" field and a "Value" property). # When PS7's strict parser rejects such a file, falls back to -AsHashTable and recursively # converts the resulting hashtable tree back to PSCustomObjects so all downstream code that # uses .PSObject.Properties continues to work without modification. [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string]$InputObject ) try { return $InputObject | ConvertFrom-Json } catch { if ($_.Exception.Message -notmatch 'duplicated keys|keys with different casing') { throw } if ($PSVersionTable.PSVersion.Major -ge 6) { # PS 6.2+ supports -AsHashtable, which tolerates duplicate case-variant keys. return ConvertFrom-EDCAJsonHashTableNode ($InputObject | ConvertFrom-Json -AsHashtable) } # PS 5.1 fallback: JavaScriptSerializer (always present in .NET 4.x) absorbs # duplicate case-variant keys via last-write-wins without throwing. $null = [System.Reflection.Assembly]::LoadWithPartialName('System.Web.Extensions') $jss = New-Object System.Web.Script.Serialization.JavaScriptSerializer $jss.MaxJsonLength = [int]::MaxValue return ConvertFrom-EDCAJsonHashTableNode ($jss.DeserializeObject($InputObject)) } } function ConvertFrom-EDCAJsonHashTableNode { param([object]$Node) if ($Node -is [System.Collections.IDictionary]) { $obj = [pscustomobject]@{} foreach ($k in $Node.Keys) { $obj | Add-Member -MemberType NoteProperty -Name ([string]$k) -Value (ConvertFrom-EDCAJsonHashTableNode $Node[$k]) -Force } return $obj } if ($Node -is [System.Collections.IList] -and $Node -isnot [string]) { return , @($Node | ForEach-Object { ConvertFrom-EDCAJsonHashTableNode $_ }) } return $Node } |