Windows Sunucularında Donanım, Yazılım ve Güvenlik Konfigürasyonlarının Karşılaştırılması
Kurumsal BT altyapılarında, özellikle kritik sunucuların yönetiminde, sistemlerin yalnızca çalışır durumda olması yeterli değildir. Aynı görevi üstlenen sunucuların donanım, işletim sistemi, yazılım, sürücü ve güvenlik yapılandırmalarının tutarlı olması; sistem sürekliliği, güvenlik ve operasyonel verimlilik açısından büyük önem taşır. Ancak iki sunucunun bütün yapılandırma parametrelerini manuel olarak karşılaştırmak, oldukça zaman alan ve gözden kaçabilecek farklılıklar nedeniyle hata riski taşıyan bir süreçtir.
Bu ihtiyaca yönelik geliştirdiğimiz VOLSYS Host Configuration Compare, Windows tabanlı fiziksel ve sanal sunucuların mevcut yapılandırmalarını otomatik olarak analiz eden, farklılıkları tespit eden ve sonuçları kurumsal raporlar halinde sunan PowerShell tabanlı bir karşılaştırma aracıdır.
Araç, iki sunucudan topladığı konfigürasyon bilgilerini karşılaştırarak donanım özellikleri, CPU ve bellek yapılandırmaları, BIOS bilgileri, diskler, işletim sistemi sürümü ve build numarası, Windows güncellemeleri, yüklü roller ve özellikler, uygulamalar, sürücüler ve servisler arasındaki farklılıkları ortaya çıkarır. Bunun yanında ağ adaptörleri, gelişmiş NIC parametreleri, DNS yapılandırmaları, IP interface ayarları ve Windows Firewall konfigürasyonları da karşılaştırma kapsamındadır.
Güvenlik tarafında ise Secure Boot, TPM, Virtualization-Based Security (VBS), Device Guard ve etkin Audit Policy gibi kritik yapılandırmaların görünürlüğü sağlanır. Böylece aynı görevi üstlenen iki sunucunun yalnızca yazılım ve donanım açısından değil, güvenlik yapılandırmaları açısından da ne ölçüde tutarlı olduğu değerlendirilebilir.
VOLSYS Host Configuration Compare’ın en önemli özelliklerinden biri tamamen read-only çalışmasıdır. Karşılaştırma sırasında hedef sunucuların yapılandırmalarında herhangi bir değişiklik yapılmaz, servisler yeniden başlatılmaz ve mevcut sistem ayarlarına müdahale edilmez. Bu yaklaşım, aracın üretim ortamlarında ön değerlendirme ve denetim amacıyla kullanılmasını kolaylaştırır.
Karşılaştırma tamamlandığında sistem, Configuration Parity yüzdesini ve MATCH, DIFFERENT, NOT DETECTED ve EXPECTED DIFFERENCE sonuçlarını içeren kapsamlı bir rapor oluşturur. Özellikle Differences Summary bölümü sayesinde hangi kategorilerde ne kadar farklılık bulunduğu hızlıca görülebilir. Differences Only görünümü ise yöneticilerin yüzlerce konfigürasyon parametresi arasında yalnızca dikkat edilmesi gereken farklılıklara odaklanmasını sağlar. Hostname gibi doğal olarak farklı olması beklenen sistem kimlikleri de karşılaştırma sonuçlarında ayrıca değerlendirilir.
Geliştirilen kullanıcı arayüzü üzerinden karşılaştırılacak sunucular seçilebilir, işlem adımları Live Log ekranından takip edilebilir ve sonuçlar HTML, CSV veya PDF formatlarında dışarı aktarılabilir. Ayrıca yalnızca seçilen kategorilerin raporlanması mümkündür. Kurumsal VOLSYS logosunu içeren HTML ve PDF çıktıları, teknik incelemelerin yanı sıra denetim, değişiklik yönetimi ve proje dokümantasyonunda da kullanılabilir.
Aracın kullanım alanları arasında Active Directory Domain Controller karşılaştırmaları, Windows Server migration projeleri, Disaster Recovery hazırlıkları, cluster node tutarlılık kontrolleri, sunucu yenileme çalışmaları ve güvenlik sıkılaştırma projeleri bulunmaktadır. Özellikle bir sunucunun başka bir sunucuyla aynı yapılandırmaya sahip olması gereken senaryolarda, farklılıkların önceden belirlenmesine önemli katkı sağlar.
Fiziksel sunucularda RAID, HBA, BMC ve bazı gelişmiş BIOS/firmware bilgilerinin tamamı Windows üzerinden elde edilemeyebileceğinden, bu alanlarda üreticiye özgü yönetim araçlarıyla ek doğrulama gerekebilir. Bu nedenle Configuration Parity sonucu, taranan parametreler için bir tutarlılık göstergesi olarak değerlendirilmelidir.
VOLSYS Host Configuration Compare, sistem yöneticilerine ve güvenlik ekiplerine iki Windows sunucusu arasındaki konfigürasyon farklılıklarını hızlı, sistematik ve anlaşılır biçimde değerlendirme imkânı sunmak amacıyla geliştirilmiştir. Hedefimiz, manuel kontrolleri azaltırken görünürlüğü artırmak ve daha güvenli, tutarlı ve yönetilebilir Windows altyapılarına katkı sağlamaktır.




NOT: Tool’u powershell olarak kaydettikten sonra administrator yetkisi ile çalıştırabilirsiniz. Öncelikli olarak test ortamında çalıştırmanızı öneririz.
#requires -version 5.1<#VOLSYS Host Configuration Compare v0.1.4Volkan DEMİRCİ#>Set-StrictMode -Version 2.0$ErrorActionPreference = 'Stop'Add-Type -AssemblyName System.Windows.FormsAdd-Type -AssemblyName System.Drawing$script:Credential = $null$script:LatestReport = $null$script:LogBox = $null$script:Progress = $null$script:Form = $null$script:RunLog = $null$script:LastRows = $null$script:LastHost1 = $null$script:LastHost2 = $null$script:LastReportsFolder = $null$script:ExportButton = $nullfunction Write-Log { param([string]$Message,[ValidateSet('INFO','OK','WARN','ERROR')][string]$Level='INFO') $line = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),$Level,$Message if($script:LogBox){ $script:LogBox.AppendText($line + [Environment]::NewLine) $script:LogBox.SelectionStart = $script:LogBox.TextLength $script:LogBox.ScrollToCaret() [System.Windows.Forms.Application]::DoEvents() } if($script:RunLog){ Add-Content -LiteralPath $script:RunLog -Value $line -Encoding UTF8 }}function Get-Collector { { $ErrorActionPreference='SilentlyContinue' function S($v){ if($null -eq $v){''}else{([string]$v).Trim()} } function GB($v){ if($v){[math]::Round(([double]$v/1GB),2)}else{$null} } $cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' $os = Get-CimInstance Win32_OperatingSystem $cs = Get-CimInstance Win32_ComputerSystem $bios = Get-CimInstance Win32_BIOS | Select-Object -First 1 $bb = Get-CimInstance Win32_BaseBoard | Select-Object -First 1 $summary=[ordered]@{ ComputerName=$env:COMPUTERNAME Manufacturer=S $cs.Manufacturer Model=S $cs.Model TotalMemoryGB=GB $cs.TotalPhysicalMemory ProcessorSockets=$cs.NumberOfProcessors LogicalProcessors=$cs.NumberOfLogicalProcessors BIOSManufacturer=S $bios.Manufacturer BIOSVersion=S $bios.SMBIOSBIOSVersion BIOSReleaseDate=if($bios.ReleaseDate){([datetime]$bios.ReleaseDate).ToString('yyyy-MM-dd')}else{''} BaseBoardManufacturer=S $bb.Manufacturer BaseBoardProduct=S $bb.Product BaseBoardVersion=S $bb.Version ProductName=S $cv.ProductName EditionID=S $cv.EditionID DisplayVersion=S $cv.DisplayVersion CurrentBuild=S $cv.CurrentBuild UBR=S $cv.UBR OSArchitecture=S $os.OSArchitecture SystemType=S $cs.SystemType Domain=S $cs.Domain } $cpu=@(Get-CimInstance Win32_Processor | Sort-Object DeviceID | ForEach-Object { [pscustomobject]@{Key=S $_.DeviceID;Name=S $_.Name;Socket=S $_.SocketDesignation;Cores=$_.NumberOfCores;Threads=$_.NumberOfLogicalProcessors;MaxMHz=$_.MaxClockSpeed} }) $memory=@(Get-CimInstance Win32_PhysicalMemory | Sort-Object DeviceLocator | ForEach-Object { [pscustomobject]@{Key=S $_.DeviceLocator;Bank=S $_.BankLabel;Manufacturer=S $_.Manufacturer;PartNumber=S $_.PartNumber;CapacityGB=GB $_.Capacity;Speed=$_.Speed;ConfiguredMHz=$_.ConfiguredClockSpeed;SMBIOSMemoryType=$_.SMBIOSMemoryType} }) $disks=@(Get-CimInstance Win32_DiskDrive | Sort-Object Index | ForEach-Object { [pscustomobject]@{Key="Disk$($_.Index)";Model=S $_.Model;Interface=S $_.InterfaceType;Media=S $_.MediaType;SizeGB=GB $_.Size;Firmware=S $_.FirmwareRevision} }) $physicalDisks=@() if(Get-Command Get-PhysicalDisk -ErrorAction SilentlyContinue){ $i=0 $physicalDisks=@(Get-PhysicalDisk | Sort-Object FriendlyName,Size | ForEach-Object { $i++ [pscustomobject]@{Key="PhysicalDisk$i";FriendlyName=S $_.FriendlyName;MediaType=S $_.MediaType;BusType=S $_.BusType;SizeGB=GB $_.Size;Health=S $_.HealthStatus;Operational=S ($_.OperationalStatus -join ',')} }) } $nics=@() if(Get-Command Get-NetAdapter -ErrorAction SilentlyContinue){ $nics=@(Get-NetAdapter -Physical | Sort-Object Name | ForEach-Object { [pscustomobject]@{Key=S $_.Name;Name=S $_.Name;Description=S $_.InterfaceDescription;Status=S $_.Status;LinkSpeed=S $_.LinkSpeed;DriverVersion=S $_.DriverVersion;DriverDate=if($_.DriverDate){([datetime]$_.DriverDate).ToString('yyyy-MM-dd')}else{''}} }) } $nicAdvanced=@() if(Get-Command Get-NetAdapterAdvancedProperty -ErrorAction SilentlyContinue){ $nicAdvanced=@(Get-NetAdapterAdvancedProperty -AllProperties | Where-Object DisplayName | Sort-Object Name,DisplayName | ForEach-Object { [pscustomobject]@{Key="$(S $_.Name)|$(S $_.DisplayName)";Adapter=S $_.Name;Property=S $_.DisplayName;Value=S $_.DisplayValue} }) } $ipif=@() if(Get-Command Get-NetIPInterface -ErrorAction SilentlyContinue){ $ipif=@(Get-NetIPInterface | Where-Object {$_.InterfaceAlias -notmatch 'Loopback|isatap|Teredo'} | Sort-Object InterfaceAlias,AddressFamily | ForEach-Object { [pscustomobject]@{Key="$(S $_.InterfaceAlias)|$(S $_.AddressFamily)";Interface=S $_.InterfaceAlias;Family=S $_.AddressFamily;MTU=$_.NlMtu;DHCP=S $_.Dhcp;Metric=$_.InterfaceMetric} }) } $dns=@() if(Get-Command Get-DnsClientServerAddress -ErrorAction SilentlyContinue){ $dns=@(Get-DnsClientServerAddress | Where-Object {$_.InterfaceAlias -notmatch 'Loopback|isatap|Teredo'} | Sort-Object InterfaceAlias,AddressFamily | ForEach-Object { [pscustomobject]@{Key="$(S $_.InterfaceAlias)|$(S $_.AddressFamily)";Interface=S $_.InterfaceAlias;Family=S $_.AddressFamily;Servers=(@($_.ServerAddresses)|Sort-Object)-join ', '} }) } $hotfixes=@(Get-HotFix | Where-Object HotFixID | Sort-Object HotFixID -Unique | ForEach-Object {[pscustomobject]@{Key=S $_.HotFixID;HotFixID=S $_.HotFixID}}) $features=@() if(Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue){ $features=@(Get-WindowsFeature | Where-Object Installed | Sort-Object Name | ForEach-Object {[pscustomobject]@{Key=S $_.Name;Name=S $_.Name}}) } $software=@() foreach($p in @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')){ $arch=if($p -match 'WOW6432Node'){'x86'}else{'x64'} Get-ItemProperty $p | Where-Object DisplayName | ForEach-Object { $software += [pscustomobject]@{Key="$(S $_.DisplayName)|$(S $_.Publisher)|$arch";Name=S $_.DisplayName;Version=S $_.DisplayVersion;Publisher=S $_.Publisher;Architecture=$arch} } } $software=@($software | Sort-Object Key -Unique) $drivers=@(Get-CimInstance Win32_PnPSignedDriver | Where-Object {$_.DeviceName -and $_.DriverVersion} | Sort-Object DeviceName,DriverProviderName | ForEach-Object { [pscustomobject]@{Key="$(S $_.DeviceName)|$(S $_.DriverProviderName)";Device=S $_.DeviceName;Provider=S $_.DriverProviderName;Version=S $_.DriverVersion;Date=if($_.DriverDate){([datetime]$_.DriverDate).ToString('yyyy-MM-dd')}else{''}} }) $services=@(Get-CimInstance Win32_Service | Sort-Object Name | ForEach-Object {[pscustomobject]@{Key=S $_.Name;Name=S $_.Name;StartMode=S $_.StartMode}}) $fw=@() if(Get-Command Get-NetFirewallProfile -ErrorAction SilentlyContinue){ $fw=@(Get-NetFirewallProfile | Sort-Object Name | ForEach-Object {[pscustomobject]@{Key=S $_.Name;Name=S $_.Name;Enabled=$_.Enabled;Inbound=S $_.DefaultInboundAction;Outbound=S $_.DefaultOutboundAction}}) } $pagefile=@(Get-CimInstance Win32_PageFileSetting | Sort-Object Name | ForEach-Object {[pscustomobject]@{Key=S $_.Name;Name=S $_.Name;Initial=$_.InitialSize;Maximum=$_.MaximumSize}}) $secureBoot='NotDetected' try{ if(Get-Command Confirm-SecureBootUEFI -ErrorAction SilentlyContinue){$secureBoot=[string](Confirm-SecureBootUEFI -ErrorAction Stop)} }catch{$secureBoot='UnsupportedOrUnavailable'} $tpm=[ordered]@{Present='';Ready='';Enabled=''} try{ if(Get-Command Get-Tpm -ErrorAction SilentlyContinue){$t=Get-Tpm;$tpm.Present=[string]$t.TpmPresent;$tpm.Ready=[string]$t.TpmReady;$tpm.Enabled=[string]$t.TpmEnabled} }catch{} $dg=[ordered]@{VBS='';Configured='';Running=''} try{$d=Get-CimInstance -Namespace root\Microsoft\Windows\DeviceGuard -ClassName Win32_DeviceGuard;$dg.VBS=S $d.VirtualizationBasedSecurityStatus;$dg.Configured=(@($d.SecurityServicesConfigured)|Sort-Object)-join ',';$dg.Running=(@($d.SecurityServicesRunning)|Sort-Object)-join ','}catch{} $powerPlan='' try{$pp=Get-CimInstance -Namespace root\cimv2\power -ClassName Win32_PowerPlan | Where-Object IsActive | Select-Object -First 1;if($pp){$powerPlan=S $pp.ElementName}}catch{} $audit='' try{$audit=((& auditpol.exe /get /category:* 2>$null)-join "`n").Trim()}catch{} [ordered]@{CollectedAt=(Get-Date).ToString('yyyy-MM-dd HH:mm:ss');Summary=$summary;CPU=$cpu;Memory=$memory;DiskDrives=$disks;PhysicalDisks=$physicalDisks;NetAdapters=$nics;NICAdvanced=$nicAdvanced;IPInterfaces=$ipif;DNS=$dns;Hotfixes=$hotfixes;Features=$features;Software=$software;Drivers=$drivers;Services=$services;Firewall=$fw;Pagefile=$pagefile;SecureBoot=$secureBoot;TPM=$tpm;DeviceGuard=$dg;PowerPlan=$powerPlan;AuditPolicy=$audit} | ConvertTo-Json -Depth 9 -Compress }}function Test-IsLocalHost([string]$ComputerName){ if([string]::IsNullOrWhiteSpace($ComputerName)){ return $false } $n=$ComputerName.Trim().TrimEnd('.') $localShort=$env:COMPUTERNAME $localFqdn='' try{ $localFqdn=[System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName }catch{} $names=@('.','localhost',$localShort,$localFqdn) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim().TrimEnd('.').ToLowerInvariant() } return ($names -contains $n.ToLowerInvariant())}function Resolve-TargetComputer([string]$ComputerName){ if([string]::IsNullOrWhiteSpace($ComputerName)){ throw 'Computer name is empty.' } $inputName=$ComputerName.Trim().TrimEnd('.') if(Test-IsLocalHost $inputName){ $localFqdnResolved = $env:COMPUTERNAME try { $localEntry = [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME) if($localEntry -and -not [string]::IsNullOrWhiteSpace($localEntry.HostName)){ $localFqdnResolved = $localEntry.HostName.TrimEnd('.') } } catch { $localFqdnResolved = $env:COMPUTERNAME } return [pscustomobject]@{ InputName = $inputName EffectiveName = $env:COMPUTERNAME FQDN = $localFqdnResolved IP = '' IsLocal = $true } } $candidates=New-Object System.Collections.Generic.List[string] $candidates.Add($inputName) # If a short name is entered, explicitly try the current AD DNS suffix as FQDN. if($inputName -notmatch '\.' -and -not [string]::IsNullOrWhiteSpace($env:USERDNSDOMAIN)){ $fqCandidate="$inputName.$($env:USERDNSDOMAIN)" if(-not $candidates.Contains($fqCandidate)){ $candidates.Add($fqCandidate) } } # Also try the primary DNS suffix from the local computer. try{ $localCS=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop if($inputName -notmatch '\.' -and -not [string]::IsNullOrWhiteSpace($localCS.Domain) -and $localCS.Domain -ne 'WORKGROUP'){ $fqCandidate="$inputName.$($localCS.Domain)" if(-not $candidates.Contains($fqCandidate)){ $candidates.Add($fqCandidate) } } }catch{} foreach($candidate in $candidates){ try{ $entry=[System.Net.Dns]::GetHostEntry($candidate) $ip=($entry.AddressList | Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork } | Select-Object -First 1).IPAddressToString $effective=if(-not [string]::IsNullOrWhiteSpace($entry.HostName)){$entry.HostName}else{$candidate} return [pscustomobject]@{ InputName=$inputName EffectiveName=$effective.TrimEnd('.') FQDN=$effective.TrimEnd('.') IP=$ip IsLocal=(Test-IsLocalHost $effective) } }catch{} } $tried=($candidates -join ', ') throw "The host '$inputName' could not be resolved in DNS. Tried: $tried. Verify the computer name, DNS record and DNS suffix/search configuration."}function Get-Snapshot([string]$ComputerName){ $sb=Get-Collector try{ Write-Log "Pre-flight check for $ComputerName ..." $target=Resolve-TargetComputer $ComputerName if($target.IsLocal){ Write-Log "Host '$ComputerName' identified as the local computer ($($target.FQDN))." 'OK' Write-Log "Collecting configuration from $($target.EffectiveName) ..." $json=& $sb } else{ if($target.InputName -ne $target.EffectiveName){ $ipText=if($target.IP){" [$($target.IP)]"}else{''} Write-Log "Resolved '$($target.InputName)' as '$($target.EffectiveName)'$ipText." 'OK' } else { $ipText=if($target.IP){" [$($target.IP)]"}else{''} Write-Log "DNS resolution successful for '$($target.EffectiveName)'$ipText." 'OK' } Write-Log "Testing WinRM on $($target.EffectiveName) ..." try{ Test-WSMan -ComputerName $target.EffectiveName -ErrorAction Stop | Out-Null Write-Log "WinRM reachable on $($target.EffectiveName)." 'OK' }catch{ throw "DNS resolution succeeded for '$($target.EffectiveName)', but WinRM is not reachable. Verify WinRM/PowerShell Remoting, firewall and access rights. Detail: $($_.Exception.Message)" } Write-Log "Collecting configuration from $($target.EffectiveName) ..." if($script:Credential){ $json=Invoke-Command -ComputerName $target.EffectiveName -Credential $script:Credential -ScriptBlock $sb -ErrorAction Stop } else { $json=Invoke-Command -ComputerName $target.EffectiveName -ScriptBlock $sb -ErrorAction Stop } } $o=(($json|Out-String).Trim()|ConvertFrom-Json) Write-Log "Configuration collected from $($o.Summary.ComputerName)." 'OK' return $o } catch{ $msg=$_.Exception.Message Write-Log "Collection failed for $ComputerName : $msg" 'ERROR' throw $msg }}function Val($o,[string[]]$props){ if($null -eq $o){return ''}; (($props|ForEach-Object{"$_=$($o.$_)"}) -join '; ') }function AddRow([ref]$rows,$cat,$item,$v1,$v2,$status,$notes=''){$rows.Value += [pscustomobject]@{Category=$cat;Item=$item;Host1Value=[string]$v1;Host2Value=[string]$v2;Status=$status;Notes=$notes}}function CompareScalar([ref]$r,$cat,$item,$v1,$v2,[switch]$Expected,$notes=''){ $a=if($null -eq $v1){''}else{[string]$v1};$b=if($null -eq $v2){''}else{[string]$v2} $st=if($Expected){'EXPECTED DIFFERENCE'}elseif([string]::IsNullOrWhiteSpace($a)-and[string]::IsNullOrWhiteSpace($b)){'NOT DETECTED'}elseif($a -eq $b){'MATCH'}else{'DIFFERENT'} AddRow $r $cat $item $a $b $st $notes}function CompareCollection([ref]$r,$cat,$c1,$c2,[string[]]$props){ $a=@{};$b=@{};foreach($x in @($c1)){if($x -and $x.Key){$a[[string]$x.Key]=$x}};foreach($x in @($c2)){if($x -and $x.Key){$b[[string]$x.Key]=$x}} $keys=@($a.Keys+$b.Keys|Sort-Object -Unique) if(!$keys.Count){AddRow $r $cat '(No data)' '' '' 'NOT DETECTED';return} foreach($k in $keys){$o1=if($a.ContainsKey($k)){$a[$k]}else{$null};$o2=if($b.ContainsKey($k)){$b[$k]}else{$null};$v1=if($o1){Val $o1 $props}else{'(Missing)'};$v2=if($o2){Val $o2 $props}else{'(Missing)'};$st=if($o1 -and $o2 -and $v1 -eq $v2){'MATCH'}else{'DIFFERENT'};AddRow $r $cat $k $v1 $v2 $st}}function Compare-Snapshots($h1,$h2){ $rows=@();$r=[ref]$rows Write-Log 'Comparing hardware and firmware ...' CompareScalar $r 'Identity' 'Computer Name' $h1.Summary.ComputerName $h2.Summary.ComputerName -Expected -notes 'Expected to be unique.' foreach($p in @('Manufacturer','Model','TotalMemoryGB','ProcessorSockets','LogicalProcessors','BIOSManufacturer','BIOSVersion','BIOSReleaseDate','BaseBoardManufacturer','BaseBoardProduct','BaseBoardVersion')){CompareScalar $r 'Hardware / Firmware' $p $h1.Summary.$p $h2.Summary.$p} CompareCollection $r 'CPU' $h1.CPU $h2.CPU @('Name','Socket','Cores','Threads','MaxMHz') CompareCollection $r 'Memory' $h1.Memory $h2.Memory @('Bank','Manufacturer','PartNumber','CapacityGB','Speed','ConfiguredMHz','SMBIOSMemoryType') CompareCollection $r 'Disk Drives' $h1.DiskDrives $h2.DiskDrives @('Model','Interface','Media','SizeGB','Firmware') CompareCollection $r 'Physical Disks' $h1.PhysicalDisks $h2.PhysicalDisks @('FriendlyName','MediaType','BusType','SizeGB','Health','Operational') Write-Log 'Comparing OS, patches and roles ...' foreach($p in @('ProductName','EditionID','DisplayVersion','CurrentBuild','UBR','OSArchitecture','SystemType','Domain')){CompareScalar $r 'Operating System' $p $h1.Summary.$p $h2.Summary.$p} CompareCollection $r 'Windows Updates' $h1.Hotfixes $h2.Hotfixes @('HotFixID') CompareCollection $r 'Windows Roles & Features' $h1.Features $h2.Features @('Name') Write-Log 'Comparing software, drivers and services ...' CompareCollection $r 'Installed Software' $h1.Software $h2.Software @('Name','Version','Publisher','Architecture') CompareCollection $r 'Drivers' $h1.Drivers $h2.Drivers @('Device','Provider','Version','Date') CompareCollection $r 'Services' $h1.Services $h2.Services @('Name','StartMode') Write-Log 'Comparing network configuration ...' CompareCollection $r 'Network Adapters' $h1.NetAdapters $h2.NetAdapters @('Name','Description','Status','LinkSpeed','DriverVersion','DriverDate') CompareCollection $r 'NIC Advanced Settings' $h1.NICAdvanced $h2.NICAdvanced @('Adapter','Property','Value') CompareCollection $r 'IP Interface Settings' $h1.IPInterfaces $h2.IPInterfaces @('Interface','Family','MTU','DHCP','Metric') CompareCollection $r 'DNS Client Settings' $h1.DNS $h2.DNS @('Interface','Family','Servers') Write-Log 'Comparing security and system configuration ...' CompareCollection $r 'Windows Firewall' $h1.Firewall $h2.Firewall @('Name','Enabled','Inbound','Outbound') CompareCollection $r 'Pagefile' $h1.Pagefile $h2.Pagefile @('Name','Initial','Maximum') CompareScalar $r 'Security' 'Secure Boot' $h1.SecureBoot $h2.SecureBoot foreach($p in @('Present','Ready','Enabled')){CompareScalar $r 'Security / TPM' $p $h1.TPM.$p $h2.TPM.$p} foreach($p in @('VBS','Configured','Running')){CompareScalar $r 'Security / Device Guard' $p $h1.DeviceGuard.$p $h2.DeviceGuard.$p} CompareScalar $r 'System Configuration' 'Power Plan' $h1.PowerPlan $h2.PowerPlan CompareScalar $r 'Security' 'Effective Audit Policy' $h1.AuditPolicy $h2.AuditPolicy @($rows)}function HtmlEncode([string]$s){[System.Net.WebUtility]::HtmlEncode($s)}function New-Report($rows,$h1,$h2,[string]$path,[string]$ReportTitle='VOLSYS Host Configuration Compare',[string]$ScopeLabel='All Categories'){ $m=@($rows|? Status -eq 'MATCH').Count;$d=@($rows|? Status -eq 'DIFFERENT').Count;$n=@($rows|? Status -eq 'NOT DETECTED').Count;$e=@($rows|? Status -eq 'EXPECTED DIFFERENCE').Count;$den=$m+$d;$pct=if($den){[math]::Round(100*$m/$den,1)}else{0} $name1=HtmlEncode ([string]$h1.Summary.ComputerName);$name2=HtmlEncode ([string]$h2.Summary.ComputerName) $trs=@($rows|ForEach-Object{$cls=switch($_.Status){'MATCH'{'match'}'DIFFERENT'{'diff'}'EXPECTED DIFFERENCE'{'expected'}default{'unknown'}};"<tr class='$cls'><td>$(HtmlEncode $_.Category)</td><td>$(HtmlEncode $_.Item)</td><td><pre>$(HtmlEncode $_.Host1Value)</pre></td><td><pre>$(HtmlEncode $_.Host2Value)</pre></td><td><b>$(HtmlEncode $_.Status)</b></td><td>$(HtmlEncode $_.Notes)</td></tr>"}) $html=@"<!doctype html><html><head><meta charset='utf-8'><title>$(HtmlEncode $ReportTitle)</title><style>body{font-family:Segoe UI,Arial;margin:0;background:#f4f6f8;color:#1f2937}header{background:#111827;color:#fff;padding:24px 30px;border-bottom:4px solid #b91c1c}.wrap{padding:24px 30px}.cards{display:flex;gap:12px;flex-wrap:wrap}.card{background:#fff;border:1px solid #ddd;border-radius:10px;padding:14px 18px;min-width:140px}.n{font-size:26px;font-weight:700}.t{font-size:11px;color:#6b7280}.info{background:#fff;border-left:4px solid #2563eb;padding:12px 14px;margin:18px 0}.warn{background:#fffbeb;border-left:4px solid #d97706;padding:12px 14px;margin:18px 0}input,select{padding:8px;margin:0 8px 12px 0}table{width:100%;border-collapse:collapse;background:#fff;font-size:12px}th{background:#1f2937;color:#fff;text-align:left;padding:9px;position:sticky;top:0}td{padding:7px 9px;border-bottom:1px solid #e5e7eb;vertical-align:top}pre{margin:0;white-space:pre-wrap;font:11px Consolas}tr.match{background:#f0fdf4}tr.diff{background:#fef2f2}tr.expected{background:#eff6ff}tr.unknown{background:#f8fafc}</style><script>function f(){let q=document.getElementById('q').value.toLowerCase(),s=document.getElementById('s').value;document.querySelectorAll('tbody tr').forEach(r=>{let ok=(q==''||r.innerText.toLowerCase().includes(q))&&(s=='ALL'||r.cells[4].innerText.trim()==s);r.style.display=ok?'':'none'})}</script></head><body><header><h1>$(HtmlEncode $ReportTitle)</h1><div>$name1 vs $name2 | Generated $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Scope: $(HtmlEncode $ScopeLabel)</div></header><div class='wrap'><div class='cards'><div class='card'><div class='n'>$pct%</div><div class='t'>CONFIGURATION PARITY</div></div><div class='card'><div class='n'>$m</div><div class='t'>MATCH</div></div><div class='card'><div class='n'>$d</div><div class='t'>DIFFERENT</div></div><div class='card'><div class='n'>$n</div><div class='t'>NOT DETECTED</div></div><div class='card'><div class='n'>$e</div><div class='t'>EXPECTED DIFFERENCE</div></div></div><div class='info'><b>Scope:</b> hardware, Windows-visible firmware, OS/build/patches, roles/features, installed software, drivers, services, network and selected security configuration. Hostname, serial numbers, MAC addresses and assigned host IPs are excluded from parity scoring.</div><div class='warn'><b>Physical server note:</b> deep RAID/HBA/BMC firmware and BIOS setting comparison may require Dell OpenManage/iDRAC, HPE iLO/SSA/SUM or Lenovo XClarity. v0.1 reports what Windows exposes; vendor collectors can be added next.</div><input id='q' size='40' placeholder='Search...' onkeyup='f()'><select id='s' onchange='f()'><option>ALL</option><option>MATCH</option><option>DIFFERENT</option><option>NOT DETECTED</option><option>EXPECTED DIFFERENCE</option></select><table><thead><tr><th>Category</th><th>Item</th><th>$name1</th><th>$name2</th><th>Status</th><th>Notes</th></tr></thead><tbody>$($trs -join "`n")</tbody></table></div></body></html>"@ Set-Content -LiteralPath $path -Value $html -Encoding UTF8}function Show-CategoryExportDialog { if(-not $script:LastRows -or -not $script:LastHost1 -or -not $script:LastHost2){ [System.Windows.Forms.MessageBox]::Show( 'Run a host comparison first.', 'VOLSYS Host Compare' ) | Out-Null return } $dlg = New-Object System.Windows.Forms.Form $dlg.Text = 'Export Selected Categories' $dlg.Size = New-Object System.Drawing.Size(620,620) $dlg.StartPosition = 'CenterParent' $dlg.Font = New-Object System.Drawing.Font('Segoe UI',9) $dlg.MinimizeBox = $false $dlg.MaximizeBox = $false $dlg.FormBorderStyle = 'FixedDialog' $title = New-Object System.Windows.Forms.Label $title.Text = 'Select categories to export' $title.Location = New-Object System.Drawing.Point(20,18) $title.Size = New-Object System.Drawing.Size(400,26) $title.Font = New-Object System.Drawing.Font('Segoe UI Semibold',12) $dlg.Controls.Add($title) $help = New-Object System.Windows.Forms.Label $help.Text = 'Only rows belonging to the checked categories will be included in the CSV/HTML export.' $help.Location = New-Object System.Drawing.Point(20,48) $help.Size = New-Object System.Drawing.Size(560,36) $help.ForeColor = [System.Drawing.Color]::DimGray $dlg.Controls.Add($help) $list = New-Object System.Windows.Forms.CheckedListBox $list.Location = New-Object System.Drawing.Point(20,90) $list.Size = New-Object System.Drawing.Size(560,350) $list.CheckOnClick = $true $list.HorizontalScrollbar = $true $categories = @($script:LastRows | Select-Object -ExpandProperty Category -Unique | Sort-Object) foreach($category in $categories){ [void]$list.Items.Add([string]$category,$true) } $dlg.Controls.Add($list) $selectAll = New-Object System.Windows.Forms.Button $selectAll.Text = 'Select All' $selectAll.Location = New-Object System.Drawing.Point(20,455) $selectAll.Size = New-Object System.Drawing.Size(100,30) $selectAll.Add_Click({ for($i=0;$i -lt $list.Items.Count;$i++){ $list.SetItemChecked($i,$true) } }) $dlg.Controls.Add($selectAll) $clearAll = New-Object System.Windows.Forms.Button $clearAll.Text = 'Clear All' $clearAll.Location = New-Object System.Drawing.Point(130,455) $clearAll.Size = New-Object System.Drawing.Size(100,30) $clearAll.Add_Click({ for($i=0;$i -lt $list.Items.Count;$i++){ $list.SetItemChecked($i,$false) } }) $dlg.Controls.Add($clearAll) $diffOnly = New-Object System.Windows.Forms.CheckBox $diffOnly.Text = 'Export differences only' $diffOnly.Location = New-Object System.Drawing.Point(260,460) $diffOnly.Size = New-Object System.Drawing.Size(180,24) $dlg.Controls.Add($diffOnly) $csvBtn = New-Object System.Windows.Forms.Button $csvBtn.Text = 'Export CSV' $csvBtn.Location = New-Object System.Drawing.Point(20,505) $csvBtn.Size = New-Object System.Drawing.Size(150,38) $dlg.Controls.Add($csvBtn) $htmlBtn = New-Object System.Windows.Forms.Button $htmlBtn.Text = 'Export HTML' $htmlBtn.Location = New-Object System.Drawing.Point(185,505) $htmlBtn.Size = New-Object System.Drawing.Size(150,38) $dlg.Controls.Add($htmlBtn) $bothBtn = New-Object System.Windows.Forms.Button $bothBtn.Text = 'Export Both' $bothBtn.Location = New-Object System.Drawing.Point(350,505) $bothBtn.Size = New-Object System.Drawing.Size(150,38) $dlg.Controls.Add($bothBtn) $closeBtn = New-Object System.Windows.Forms.Button $closeBtn.Text = 'Close' $closeBtn.Location = New-Object System.Drawing.Point(510,505) $closeBtn.Size = New-Object System.Drawing.Size(70,38) $closeBtn.Add_Click({ $dlg.Close() }) $dlg.Controls.Add($closeBtn) function Get-SelectedExportRows { $selected = @() foreach($item in $list.CheckedItems){ $selected += [string]$item } if($selected.Count -eq 0){ [System.Windows.Forms.MessageBox]::Show( 'Select at least one category.', 'VOLSYS Host Compare', 'OK', 'Warning' ) | Out-Null return $null } $filtered = @($script:LastRows | Where-Object { $selected -contains $_.Category }) if($diffOnly.Checked){ $filtered = @($filtered | Where-Object { $_.Status -eq 'DIFFERENT' -or $_.Status -eq 'NOT DETECTED' }) } if($filtered.Count -eq 0){ [System.Windows.Forms.MessageBox]::Show( 'No rows match the selected export criteria.', 'VOLSYS Host Compare', 'OK', 'Information' ) | Out-Null return $null } return [pscustomobject]@{ Rows = $filtered Categories = $selected } } function Get-ExportBaseName { param([string[]]$Categories) $h1Name = ([string]$script:LastHost1.Summary.ComputerName) -replace '[^\w\.-]','_' $h2Name = ([string]$script:LastHost2.Summary.ComputerName) -replace '[^\w\.-]','_' $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' if($Categories.Count -eq 1){ $scope = ($Categories[0] -replace '[^\w\.-]','_') } else { $scope = "Selected-$($Categories.Count)-Categories" } return "HostCompare-$h1Name-vs-$h2Name-$scope-$stamp" } $doCsv = { $result = Get-SelectedExportRows if($null -eq $result){ return } $base = Get-ExportBaseName -Categories $result.Categories $path = Join-Path $script:LastReportsFolder "$base.csv" $result.Rows | Export-Csv -LiteralPath $path -NoTypeInformation -Encoding UTF8 Write-Log "Selected-category CSV exported: $path" 'OK' [System.Windows.Forms.MessageBox]::Show( "CSV export completed.`r`n`r`n$path", 'VOLSYS Host Compare', 'OK', 'Information' ) | Out-Null } $doHtml = { $result = Get-SelectedExportRows if($null -eq $result){ return } $base = Get-ExportBaseName -Categories $result.Categories $path = Join-Path $script:LastReportsFolder "$base.html" $scopeText = ($result.Categories -join ', ') if($diffOnly.Checked){ $scopeText += ' | Differences Only' } New-Report -rows $result.Rows -h1 $script:LastHost1 -h2 $script:LastHost2 ` -path $path -ReportTitle 'VOLSYS Host Configuration Compare - Selected Categories' ` -ScopeLabel $scopeText Write-Log "Selected-category HTML exported: $path" 'OK' [System.Windows.Forms.MessageBox]::Show( "HTML export completed.`r`n`r`n$path", 'VOLSYS Host Compare', 'OK', 'Information' ) | Out-Null } $csvBtn.Add_Click($doCsv) $htmlBtn.Add_Click($doHtml) $bothBtn.Add_Click({ $result = Get-SelectedExportRows if($null -eq $result){ return } $base = Get-ExportBaseName -Categories $result.Categories $csvPath = Join-Path $script:LastReportsFolder "$base.csv" $htmlPath = Join-Path $script:LastReportsFolder "$base.html" $scopeText = ($result.Categories -join ', ') if($diffOnly.Checked){ $scopeText += ' | Differences Only' } $result.Rows | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8 New-Report -rows $result.Rows -h1 $script:LastHost1 -h2 $script:LastHost2 ` -path $htmlPath -ReportTitle 'VOLSYS Host Configuration Compare - Selected Categories' ` -ScopeLabel $scopeText Write-Log "Selected-category CSV exported: $csvPath" 'OK' Write-Log "Selected-category HTML exported: $htmlPath" 'OK' [System.Windows.Forms.MessageBox]::Show( "CSV and HTML exports completed.`r`n`r`n$csvPath`r`n$htmlPath", 'VOLSYS Host Compare', 'OK', 'Information' ) | Out-Null }) [void]$dlg.ShowDialog($script:Form)}function Start-Compare([string]$a,[string]$b,[string]$root){ if([string]::IsNullOrWhiteSpace($a)-or[string]::IsNullOrWhiteSpace($b)){[System.Windows.Forms.MessageBox]::Show('Enter both host names.','VOLSYS Host Compare')|Out-Null;return} if([string]::IsNullOrWhiteSpace($root)){$root=Join-Path $PSScriptRoot 'Output'} $stamp=Get-Date -Format 'yyyyMMdd-HHmmss';$run=Join-Path $root "HostCompare-$stamp";$reports=Join-Path $run 'Reports';$snaps=Join-Path $run 'Snapshots';$logs=Join-Path $run 'Logs';New-Item -ItemType Directory -Force -Path $reports,$snaps,$logs|Out-Null;$script:RunLog=Join-Path $logs 'HostCompare.log' try{$script:Progress.Visible=$true;$script:Progress.Style='Marquee';$script:Form.UseWaitCursor=$true;Write-Log 'VOLSYS Host Configuration Compare v0.1.4 started.';$h1=Get-Snapshot $a;$h2=Get-Snapshot $b;$safe1=$h1.Summary.ComputerName -replace '[^\w\.-]','_';$safe2=$h2.Summary.ComputerName -replace '[^\w\.-]','_';$h1|ConvertTo-Json -Depth 9|Set-Content (Join-Path $snaps "$safe1.json") -Encoding UTF8;$h2|ConvertTo-Json -Depth 9|Set-Content (Join-Path $snaps "$safe2.json") -Encoding UTF8;$rows=Compare-Snapshots $h1 $h2; $script:LastRows = @($rows) $script:LastHost1 = $h1 $script:LastHost2 = $h2 $script:LastReportsFolder = $reports if($script:ExportButton){ $script:ExportButton.Enabled = $true } $csv=Join-Path $reports "HostCompare-$safe1-vs-$safe2.csv";$html=Join-Path $reports "HostCompare-$safe1-vs-$safe2.html";$rows|Export-Csv $csv -NoTypeInformation -Encoding UTF8;New-Report $rows $h1 $h2 $html;$script:LatestReport=$html;$d=@($rows|? Status -eq 'DIFFERENT').Count;Write-Log "Comparison completed. Differences: $d" 'OK';Write-Log "HTML: $html" 'OK';[System.Windows.Forms.MessageBox]::Show("Comparison completed.`r`nDifferences: $d`r`n`r`n$html",'VOLSYS Host Compare')|Out-Null}catch{ $err=$_.Exception.Message Write-Log $err 'ERROR' if($err -match '<f:WSManFault|<WSManFault'){ $err='Remote connection failed. Check DNS resolution, the entered host name/FQDN, WinRM/PowerShell Remoting, firewall, and administrative rights. See Live Log for details.' } [System.Windows.Forms.MessageBox]::Show($err,'VOLSYS Host Compare - Error')|Out-Null }finally{$script:Progress.Visible=$false;$script:Form.UseWaitCursor=$false}}$form=New-Object System.Windows.Forms.Form;$form.Text='VOLSYS Host Configuration Compare v0.1.4';$form.Size=New-Object Drawing.Size(980,700);$form.StartPosition='CenterScreen';$form.Font=New-Object Drawing.Font('Segoe UI',9);$script:Form=$form$title=New-Object Windows.Forms.Label;$title.Text='VOLSYS Host Configuration Compare';$title.Location=New-Object Drawing.Point(20,15);$title.Size=New-Object Drawing.Size(650,34);$title.Font=New-Object Drawing.Font('Segoe UI Semibold',18);$form.Controls.Add($title)$sub=New-Object Windows.Forms.Label;$sub.Text='Read-only physical host hardware / software / configuration parity assessment';$sub.Location=New-Object Drawing.Point(23,50);$sub.Size=New-Object Drawing.Size(760,22);$sub.ForeColor=[Drawing.Color]::DimGray;$form.Controls.Add($sub)$l1=New-Object Windows.Forms.Label;$l1.Text='Host 1';$l1.Location=New-Object Drawing.Point(24,92);$l1.Size=New-Object Drawing.Size(60,22);$l1.AutoSize=$false;$form.Controls.Add($l1)$t1=New-Object Windows.Forms.TextBox;$t1.Location=New-Object Drawing.Point(95,88);$t1.Size=New-Object Drawing.Size(290,24);$t1.Text=$env:COMPUTERNAME;$t1.SelectionStart=0;$t1.SelectionLength=0;$form.Controls.Add($t1)$l2=New-Object Windows.Forms.Label;$l2.Text='Host 2';$l2.Location=New-Object Drawing.Point(410,92);$l2.Size=New-Object Drawing.Size(60,22);$l2.AutoSize=$false;$form.Controls.Add($l2)$t2=New-Object Windows.Forms.TextBox;$t2.Location=New-Object Drawing.Point(480,88);$t2.Size=New-Object Drawing.Size(290,24);$t2.SelectionStart=0;$t2.SelectionLength=0;$form.Controls.Add($t2)$cred=New-Object Windows.Forms.Button;$cred.Text='Credentials...';$cred.Location=New-Object Drawing.Point(795,86);$cred.Size=New-Object Drawing.Size(135,28);$cred.Add_Click({$script:Credential=Get-Credential -Message 'Optional credentials for remote hosts';if($script:Credential){Write-Log "Credential selected: $($script:Credential.UserName)"}});$form.Controls.Add($cred)$lo=New-Object Windows.Forms.Label;$lo.Text='Output';$lo.Location=New-Object Drawing.Point(24,133);$lo.Size=New-Object Drawing.Size(60,22);$lo.AutoSize=$false;$form.Controls.Add($lo)$to=New-Object Windows.Forms.TextBox;$to.Location=New-Object Drawing.Point(95,129);$to.Size=New-Object Drawing.Size(675,24);$to.Text=Join-Path $PSScriptRoot 'Output';$to.SelectionStart=0;$to.SelectionLength=0;$form.Controls.Add($to)$browse=New-Object Windows.Forms.Button;$browse.Text='Browse...';$browse.Location=New-Object Drawing.Point(795,127);$browse.Size=New-Object Drawing.Size(135,28);$browse.Add_Click({$f=New-Object Windows.Forms.FolderBrowserDialog;$f.SelectedPath=$to.Text;if($f.ShowDialog() -eq 'OK'){$to.Text=$f.SelectedPath}});$form.Controls.Add($browse)$go=New-Object Windows.Forms.Button;$go.Text='Compare Hosts';$go.Location=New-Object Drawing.Point(95,172);$go.Size=New-Object Drawing.Size(160,36);$go.Font=New-Object Drawing.Font('Segoe UI Semibold',10);$go.Add_Click({Start-Compare $t1.Text.Trim() $t2.Text.Trim() $to.Text.Trim()});$form.Controls.Add($go)$open=New-Object Windows.Forms.Button;$open.Text='Open Latest Report';$open.Location=New-Object Drawing.Point(270,172);$open.Size=New-Object Drawing.Size(160,36);$open.Add_Click({if($script:LatestReport -and (Test-Path $script:LatestReport)){Start-Process $script:LatestReport}else{[Windows.Forms.MessageBox]::Show('No report generated in this session.','VOLSYS Host Compare')|Out-Null}});$form.Controls.Add($open)$export=New-Object Windows.Forms.Button;$export.Text='Export Selected...';$export.Location=New-Object Drawing.Point(445,172);$export.Size=New-Object Drawing.Size(150,36);$export.Enabled=$false;$export.Add_Click({Show-CategoryExportDialog});$script:ExportButton=$export;$form.Controls.Add($export)$note=New-Object Windows.Forms.Label;$note.Text='Short names auto-resolve to FQDN. Remote hosts require DNS + WinRM and administrative rights.';$note.Location=New-Object Drawing.Point(610,180);$note.Size=New-Object Drawing.Size(315,35);$note.ForeColor=[Drawing.Color]::DimGray;$form.Controls.Add($note)$progress=New-Object Windows.Forms.ProgressBar;$progress.Location=New-Object Drawing.Point(24,225);$progress.Size=New-Object Drawing.Size(906,12);$progress.Visible=$false;$script:Progress=$progress;$form.Controls.Add($progress)$ll=New-Object Windows.Forms.Label;$ll.Text='Live Log';$ll.Location=New-Object Drawing.Point(24,252);$ll.Font=New-Object Drawing.Font('Segoe UI Semibold',10);$form.Controls.Add($ll)$log=New-Object Windows.Forms.RichTextBox;$log.Location=New-Object Drawing.Point(24,278);$log.Size=New-Object Drawing.Size(906,320);$log.ReadOnly=$true;$log.Font=New-Object Drawing.Font('Consolas',9);$log.BackColor=[Drawing.Color]::FromArgb(248,250,252);$script:LogBox=$log;$form.Controls.Add($log)$foot=New-Object Windows.Forms.Label;$foot.Text='v0.1.4 | Read-only | No changes are made to target hosts';$foot.Location=New-Object Drawing.Point(24,620);$foot.Size=New-Object Drawing.Size(600,24);$foot.ForeColor=[Drawing.Color]::Gray;$form.Controls.Add($foot)Write-Log 'Ready. Enter two host names and click Compare Hosts.'; Write-Log 'v0.1.4: Selected-category CSV/HTML export is available after comparison.'[void]$form.ShowDialog()