#Requires -Version 5.1 <# .SYNOPSIS Automates DNS server reconfiguration across a mixed Windows/Linux fleet. .DESCRIPTION Orchestrates DNS migration from legacy Domain Controller IPs to a new DC. Supports three input modes (combinable): -OUPath : Discover Windows servers from an Active Directory OU -ServerFile : Read a CSV or TXT file containing Windows and/or Linux server hostnames -Servers : Inline comma-separated or array of hostnames For each server the script: 1. Resolves the hostname to an IP via DNS 2. Connects remotely (WinRM/PSSession/PsExec for Windows, SSH/plink for Linux) 3. Reads and records the current DNS configuration 4. Writes a rollback JSON file before making any changes 5. Applies the new DNS IP(s) 6. Validates DNS resolution using the new server 7. Writes a detailed CSV report Use -DryRun to probe connectivity and show proposed changes without modifying any server. .PARAMETER OUPath Distinguished name of the AD OU to query for Windows servers. Example: "OU=Servers,OU=DC,DC=corp,DC=local" Can be combined with -ServerFile to cover Windows + Linux in one run. .PARAMETER ServerFile Path to a CSV or TXT file containing Windows and/or Linux servers to process. CSV format: Hostname, OS, Distro (OS = Windows or Linux; Distro is optional) TXT format: one hostname per line (OS auto-detected at runtime via WinRM/SSH probe) Lines starting with # are treated as comments. Both OS types can coexist in the same file — the OS column controls routing. .PARAMETER Servers Inline list of hostnames. Accepts a comma-separated string or an array. OS is auto-detected at runtime via WinRM probe (Windows) or SSH (Linux). Example: -Servers "srv01,srv02,ubuntu-01" .PARAMETER ConfigFile Path to config.psd1. Defaults to .\config.psd1 in the script directory. .PARAMETER OutputDir Directory for CSV reports, rollback JSON, and log files. Defaults to .\logs relative to the script directory. .PARAMETER NewDNS Override the NewDNS value from config.psd1 at runtime. Example: -NewDNS "10.35.40.1" .PARAMETER LinuxSSHPassword Override the LinuxSSHPassword from config.psd1 at runtime. Avoids storing the password in the config file. .PARAMETER WindowsCredential Optional PSCredential for Windows remote connections (WinRM / PSSession / PsExec). If not provided, the script uses implicit authentication — the identity of the user running this script (Kerberos / current logged-in domain account). Use this when you need to connect as a different domain or local account. Example: -WindowsCredential (Get-Credential) .PARAMETER WindowsUsername Optional Windows username as an alternative to -WindowsCredential. Must be used together with -WindowsPassword. Example: -WindowsUsername "CORP\migration-svc" -WindowsPassword "P@ssw0rd" .PARAMETER DryRun Read-only mode. Connects to each server, reads current DNS, shows a diff table of what would change — makes zero modifications. .PARAMETER Parallel Process servers concurrently. Uses ForEach-Object -Parallel (PS7+) or Start-Job (PS5). Controlled by ThrottleLimit in config.psd1. .PARAMETER NoRecurse When using -OUPath, search only the immediate OU (not sub-OUs). .EXAMPLE # Dry run — Windows servers from AD OU .\Invoke-DnsMigration.ps1 -OUPath "OU=Servers,DC=corp,DC=local" -DryRun .EXAMPLE # Full migration — servers from CSV (Windows and Linux mixed) .\Invoke-DnsMigration.ps1 -ServerFile ".\servers.csv" .EXAMPLE # Combined run — AD for Windows + CSV for mixed fleet .\Invoke-DnsMigration.ps1 -OUPath "OU=Servers,DC=corp,DC=local" ` -ServerFile ".\servers.csv" .EXAMPLE # Inline list, dry run .\Invoke-DnsMigration.ps1 -Servers "srv01,srv02,ubuntu-01" -DryRun .EXAMPLE # Explicit Windows credentials (different account) .\Invoke-DnsMigration.ps1 -OUPath "OU=Servers,DC=corp,DC=local" ` -WindowsCredential (Get-Credential) .EXAMPLE # Explicit Windows credentials via username/password params .\Invoke-DnsMigration.ps1 -OUPath "OU=Servers,DC=corp,DC=local" ` -WindowsUsername "CORP\migration-svc" ` -WindowsPassword "P@ssw0rd" #> [CmdletBinding(SupportsShouldProcess)] param( # ── Input sources ───────────────────────────────────────────────────────── [string] $OUPath, [string] $ServerFile, [string[]] $Servers, # ── Configuration ───────────────────────────────────────────────────────── [string] $ConfigFile = "", [string] $OutputDir = "", [string] $NewDNS = "", [string] $LinuxSSHPassword = "", # ── Windows authentication (all optional — omit to use implicit Kerberos) ─ [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $WindowsCredential = [System.Management.Automation.PSCredential]::Empty, [string] $WindowsUsername = "", [string] $WindowsPassword = "", # ── Execution flags ─────────────────────────────────────────────────────── [switch] $DryRun, [switch] $Parallel, [switch] $NoRecurse ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # ── Resolve script root reliably in both PS5 and PS7 ───────────────────────── $ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path $MyInvocation.MyCommand.Path } # ── Load modules ───────────────────────────────────────────────────────────── $modulePaths = @( "modules\Get-ServersFromOU.ps1", "modules\Set-WindowsDns.ps1", "modules\Invoke-LinuxDns.ps1", "modules\Write-MigrationReport.ps1" ) foreach ($mp in $modulePaths) { $fullPath = Join-Path $ScriptDir $mp if (-not (Test-Path $fullPath)) { throw "Required module not found: $fullPath" } . $fullPath } # ── Load configuration ──────────────────────────────────────────────────────── if (-not $ConfigFile) { $ConfigFile = Join-Path $ScriptDir "config.psd1" } if (-not (Test-Path $ConfigFile)) { throw "Configuration file not found: $ConfigFile`nExpected at: $ConfigFile" } $config = Import-PowerShellDataFile $ConfigFile # Apply runtime overrides if ($NewDNS) { $config.NewDNS = @($NewDNS) } if ($LinuxSSHPassword) { $config.LinuxSSHPassword = $LinuxSSHPassword } if ($OutputDir) { $config.OutputDir = $OutputDir } # ── Resolve Windows credential ──────────────────────────────────────────────── # Priority: -WindowsCredential > -WindowsUsername/-WindowsPassword > config.psd1 > implicit $winCredential = [System.Management.Automation.PSCredential]::Empty if ($WindowsCredential -ne [System.Management.Automation.PSCredential]::Empty -and $null -ne $WindowsCredential) { # Explicit PSCredential object passed directly $winCredential = $WindowsCredential Write-Verbose "Windows auth: using -WindowsCredential ($($winCredential.UserName))" } elseif ($WindowsUsername -and $WindowsPassword) { # Username + password params $securePass = ConvertTo-SecureString $WindowsPassword -AsPlainText -Force $winCredential = New-Object System.Management.Automation.PSCredential($WindowsUsername, $securePass) Write-Verbose "Windows auth: using -WindowsUsername/$WindowsPassword ($WindowsUsername)" } elseif ($config.WindowsUsername -and $config.WindowsPassword) { # Credentials from config.psd1 $securePass = ConvertTo-SecureString $config.WindowsPassword -AsPlainText -Force $winCredential = New-Object System.Management.Automation.PSCredential($config.WindowsUsername, $securePass) Write-Verbose "Windows auth: using config.psd1 credentials ($($config.WindowsUsername))" } else { Write-Verbose "Windows auth: implicit (current user / Kerberos)" } # Store resolved credential in config for passing to sub-functions $config._WinCredential = $winCredential # Resolve OutputDir to absolute path if (-not [System.IO.Path]::IsPathRooted($config.OutputDir)) { $config.OutputDir = Join-Path $ScriptDir $config.OutputDir } if (-not (Test-Path $config.OutputDir)) { New-Item -ItemType Directory -Path $config.OutputDir -Force | Out-Null } # ── Validate inputs ─────────────────────────────────────────────────────────── if (-not $OUPath -and -not $ServerFile -and (-not $Servers -or $Servers.Count -eq 0)) { throw "No input source specified. Use -OUPath, -ServerFile, or -Servers." } if (-not $config.NewDNS -or $config.NewDNS.Count -eq 0) { throw "NewDNS is not configured. Set it in config.psd1 or pass -NewDNS." } # ── Banner ──────────────────────────────────────────────────────────────────── $runMode = if ($DryRun) { "DRY RUN — no changes will be made" } else { "LIVE RUN — DNS settings WILL be changed" } Write-Host "" Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ DNS MIGRATION ORCHESTRATOR ║" -ForegroundColor Cyan Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan Write-Host " Mode : " -NoNewline if ($DryRun) { Write-Host $runMode -ForegroundColor Yellow } else { Write-Host $runMode -ForegroundColor Red } Write-Host " New DNS : $($config.NewDNS -join ', ')" Write-Host " Old DNS : $($config.OldDNS -join ', ')" Write-Host " Domain : $($config.Domain)" Write-Host " Win auth : " -NoNewline if ($winCredential -ne [System.Management.Automation.PSCredential]::Empty) { Write-Host "Explicit ($($winCredential.UserName))" -ForegroundColor Yellow } else { Write-Host "Implicit (current user / Kerberos)" -ForegroundColor Gray } Write-Host " Output dir : $($config.OutputDir)" Write-Host " Started at : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Write-Host "" # ── Build server list ───────────────────────────────────────────────────────── Write-Host "── Discovering servers ──────────────────────────────────────────" -ForegroundColor Cyan $allServers = [System.Collections.Generic.List[object]]::new() $sourceLists = [System.Collections.Generic.List[object[]]]::new() # Source 1: Active Directory OU if ($OUPath) { Write-Host " [AD] Querying OU: $OUPath" try { $ouServers = Get-ServersFromOU -OUPath $OUPath -NoRecurse:$NoRecurse if ($ouServers -and $ouServers.Count -gt 0) { $sourceLists.Add($ouServers) Write-Host " [AD] Found $($ouServers.Count) Windows server(s)" -ForegroundColor Green } else { Write-Warning " [AD] No servers found in OU: $OUPath" } } catch { Write-Warning " [AD] Failed to query OU '$OUPath': $($_.Exception.Message)" } } # Source 2: Server file (mixed Windows + Linux) if ($ServerFile) { Write-Host " [File] Parsing: $ServerFile" try { $fileServers = ConvertFrom-ServerListFile -Path $ServerFile if ($fileServers -and $fileServers.Count -gt 0) { $sourceLists.Add($fileServers) Write-Host " [File] Found $($fileServers.Count) server(s)" -ForegroundColor Green } } catch { Write-Warning " [File] Failed to parse '$ServerFile': $($_.Exception.Message)" } } # Source 3: Inline servers if ($Servers -and $Servers.Count -gt 0) { Write-Host " [Inline] Parsing inline server list..." try { $inlineServers = ConvertFrom-InlineServerList -Servers $Servers if ($inlineServers -and $inlineServers.Count -gt 0) { $sourceLists.Add($inlineServers) Write-Host " [Inline] Found $($inlineServers.Count) server(s)" -ForegroundColor Green } } catch { Write-Warning " [Inline] Failed to parse server list: $($_.Exception.Message)" } } # Merge and deduplicate all sources if ($sourceLists.Count -gt 1) { $mergedArr = $sourceLists.ToArray() $allServerArr = Merge-ServerLists -Lists $mergedArr } elseif ($sourceLists.Count -eq 1) { $allServerArr = $sourceLists[0] } else { throw "No servers could be discovered from the provided input sources." } foreach ($s in $allServerArr) { $allServers.Add($s) } if ($allServers.Count -eq 0) { throw "No servers to process after discovery and deduplication." } Write-Host "" Write-Host " Total unique servers to process: $($allServers.Count)" -ForegroundColor Cyan # ── Auto-detect OS for servers with OS = "Unknown" ──────────────────────────── $unknownOS = @($allServers | Where-Object { $_.OS -eq "Unknown" -or $_.OS -eq "" }) if ($unknownOS.Count -gt 0) { Write-Host "" Write-Host "── Auto-detecting OS for $($unknownOS.Count) server(s) ──────────────────" -ForegroundColor Cyan foreach ($srv in $unknownOS) { $detectedOS = Detect-ServerOS -Hostname $srv.Hostname -Config $config $srv.OS = $detectedOS Write-Host " $($srv.Hostname) → $detectedOS" } } # ── Dry run preview table ───────────────────────────────────────────────────── if ($DryRun) { Write-Host "" Write-Host "── DRY RUN PREVIEW ─────────────────────────────────────────────" -ForegroundColor Yellow Write-Host " The following changes WOULD be made:" -ForegroundColor Yellow Write-Host "" $previewHeader = "{0,-35} {1,-10} {2,-12} {3,-28} {4,-15} {5}" ` -f "Hostname", "OS", "Connectivity", "Current DNS", "Proposed DNS", "WouldChange" Write-Host $previewHeader -ForegroundColor White Write-Host ("-" * 115) } # ── Timestamp for output files ──────────────────────────────────────────────── $runTimestamp = Get-Date -Format "yyyyMMdd-HHmmss" $csvOutputPath = Join-Path $config.OutputDir "dns-migration-report-$runTimestamp.csv" # ── Process servers ─────────────────────────────────────────────────────────── Write-Host "" if (-not $DryRun) { Write-Host "── Processing servers ──────────────────────────────────────────" -ForegroundColor Cyan } $allResults = [System.Collections.Concurrent.ConcurrentBag[object]]::new() # Script block used for parallel execution — captures needed functions $processServer = { param($server, $config, $DryRun, $ScriptDir) # Re-dot-source modules in parallel runspace (each runspace is isolated) . (Join-Path $ScriptDir "modules\Set-WindowsDns.ps1") . (Join-Path $ScriptDir "modules\Invoke-LinuxDns.ps1") $hostname = $server.Hostname try { if ($server.OS -eq "Windows") { $result = Set-WindowsDns -Hostname $hostname -NewDNS $config.NewDNS ` -Config $config -DryRun:$DryRun } elseif ($server.OS -eq "Linux") { $result = Invoke-LinuxDns -Hostname $hostname -Config $config ` -DryRun:$DryRun -Distro $server.Distro } else { $result = [PSCustomObject]@{ Hostname = $hostname ResolvedIP = $server.ResolvedIP OS = $server.OS Distro = "" OldDNS = "" NewDNS = $config.NewDNS -join ";" ConnectionMethod = "None" ChangeStatus = "Skipped" ValidationStatus = "Skipped" ModifiedFiles = "" ServiceRestarted = "" ErrorMessage = "OS could not be determined (got: '$($server.OS)')" DryRun = $DryRun Timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss") _RollbackData = $null } } return $result } catch { return [PSCustomObject]@{ Hostname = $hostname ResolvedIP = $server.ResolvedIP OS = $server.OS Distro = "" OldDNS = "" NewDNS = $config.NewDNS -join ";" ConnectionMethod = "Error" ChangeStatus = "Failed" ValidationStatus = "NotRun" ModifiedFiles = "" ServiceRestarted = "" ErrorMessage = "Unhandled exception: $($_.Exception.Message)" DryRun = $DryRun Timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss") _RollbackData = $null } } } if ($Parallel -and $allServers.Count -gt 1) { $throttle = if ($config.ThrottleLimit) { [int]$config.ThrottleLimit } else { 10 } # PS7+: ForEach-Object -Parallel if ($PSVersionTable.PSVersion.Major -ge 7) { Write-Host " Running parallel (PS7, throttle: $throttle)..." -ForegroundColor Gray $serverArray = $allServers.ToArray() $results = $serverArray | ForEach-Object -Parallel { $server = $_ $config = $using:config $DryRun = $using:DryRun $ScriptDir = $using:ScriptDir . (Join-Path $ScriptDir "modules\Set-WindowsDns.ps1") . (Join-Path $ScriptDir "modules\Invoke-LinuxDns.ps1") $hostname = $server.Hostname try { if ($server.OS -eq "Windows") { Set-WindowsDns -Hostname $hostname -NewDNS $config.NewDNS ` -Config $config -Credential $config._WinCredential -DryRun:$DryRun } elseif ($server.OS -eq "Linux") { Invoke-LinuxDns -Hostname $hostname -Config $config ` -DryRun:$DryRun -Distro $server.Distro } else { [PSCustomObject]@{ Hostname="$hostname"; ResolvedIP=$server.ResolvedIP OS=$server.OS; Distro=""; OldDNS=""; NewDNS=$config.NewDNS -join ";" ConnectionMethod="None"; ChangeStatus="Skipped" ValidationStatus="Skipped"; ModifiedFiles=""; ServiceRestarted="" ErrorMessage="Unknown OS: $($server.OS)"; DryRun=$DryRun Timestamp=(Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); _RollbackData=$null } } } catch { [PSCustomObject]@{ Hostname="$hostname"; ResolvedIP=$server.ResolvedIP OS=$server.OS; Distro=""; OldDNS=""; NewDNS=$config.NewDNS -join ";" ConnectionMethod="Error"; ChangeStatus="Failed" ValidationStatus="NotRun"; ModifiedFiles=""; ServiceRestarted="" ErrorMessage="Exception: $($_.Exception.Message)"; DryRun=$DryRun Timestamp=(Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); _RollbackData=$null } } } -ThrottleLimit $throttle foreach ($r in $results) { $allResults.Add($r) } } else { # PS5: Start-Job fallback Write-Host " Running parallel (PS5 jobs, throttle: $throttle)..." -ForegroundColor Gray $jobs = [System.Collections.Generic.List[System.Management.Automation.Job]]::new() $serverArr = $allServers.ToArray() $idx = 0 while ($idx -lt $serverArr.Count) { # Throttle: wait if max active jobs reached while (@($jobs | Where-Object { $_.State -eq "Running" }).Count -ge $throttle) { Start-Sleep -Milliseconds 500 } $srv = $serverArr[$idx] $job = Start-Job -ScriptBlock $processServer ` -ArgumentList $srv, $config, $DryRun.IsPresent, $ScriptDir $jobs.Add($job) $idx++ } # Collect results foreach ($job in $jobs) { $result = $job | Wait-Job | Receive-Job if ($result) { $allResults.Add($result) } Remove-Job $job -Force } } } else { # Sequential execution $i = 0 foreach ($server in $allServers) { $i++ $hostname = $server.Hostname Write-Host " [$i/$($allServers.Count)] $hostname ($($server.OS)) ..." -NoNewline $result = $null try { if ($server.OS -eq "Windows") { $result = Set-WindowsDns -Hostname $hostname -NewDNS $config.NewDNS ` -Config $config -Credential $config._WinCredential -DryRun:$DryRun } elseif ($server.OS -eq "Linux") { $result = Invoke-LinuxDns -Hostname $hostname -Config $config ` -DryRun:$DryRun -Distro $server.Distro } else { $result = [PSCustomObject]@{ Hostname="$hostname"; ResolvedIP=$server.ResolvedIP OS=$server.OS; Distro=""; OldDNS=""; NewDNS=($config.NewDNS -join ";") ConnectionMethod="None"; ChangeStatus="Skipped" ValidationStatus="Skipped"; ModifiedFiles=""; ServiceRestarted="" ErrorMessage="Unknown OS: $($server.OS)"; DryRun=$DryRun.IsPresent Timestamp=(Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); _RollbackData=$null } } } catch { $result = [PSCustomObject]@{ Hostname="$hostname"; ResolvedIP=$server.ResolvedIP OS=$server.OS; Distro=""; OldDNS=""; NewDNS=($config.NewDNS -join ";") ConnectionMethod="Error"; ChangeStatus="Failed" ValidationStatus="NotRun"; ModifiedFiles=""; ServiceRestarted="" ErrorMessage="Exception: $($_.Exception.Message)"; DryRun=$DryRun.IsPresent Timestamp=(Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); _RollbackData=$null } } # Inline status indicator $statusColor = switch ($result.ChangeStatus) { "Success" { "Green" } "DryRun" { "Cyan" } "Failed" { "Red" } "ConnectionFailed" { "Red" } "Skipped" { "Yellow" } default { "White" } } Write-Host " [$($result.ChangeStatus)]" -ForegroundColor $statusColor -NoNewline Write-Host " (Validation: $($result.ValidationStatus))" # Dry run table row if ($DryRun) { $wouldChange = if ($result.OldDNS -and $result.OldDNS -ne $result.NewDNS) { "Yes" } else { "No" } $connStatus = if ($result.ChangeStatus -eq "DryRun") { "OK ($($result.ConnectionMethod))" } elseif ($result.ChangeStatus -eq "Failed") { "FAILED" } else { $result.ConnectionMethod } $row = "{0,-35} {1,-10} {2,-12} {3,-28} {4,-15} {5}" -f ` $hostname.Substring(0, [Math]::Min($hostname.Length, 34)), $result.OS, $connStatus, ($result.OldDNS ?? "Unknown").Substring(0, [Math]::Min(($result.OldDNS ?? "Unknown").Length, 27)), ($result.NewDNS ?? "").Substring(0, [Math]::Min(($result.NewDNS ?? "").Length, 14)), $wouldChange Write-Host " $row" } $allResults.Add($result) } } # ── Collect results ─────────────────────────────────────────────────────────── $resultsArray = @($allResults) if ($resultsArray.Count -eq 0) { Write-Warning "No results collected. Nothing to report." exit 0 } # ── Write rollback file (before/after data is in results) ───────────────────── if (-not $DryRun) { Write-Host "" Write-Host "── Writing rollback file ────────────────────────────────────────" -ForegroundColor Cyan $rollbackPath = Write-RollbackFile -Results $resultsArray -OutputDir $config.OutputDir Write-Host " Rollback file: $rollbackPath" -ForegroundColor Green } # ── Write CSV report ────────────────────────────────────────────────────────── Write-Host "" Write-Host "── Writing migration report ─────────────────────────────────────" -ForegroundColor Cyan Write-MigrationReport -Results $resultsArray -OutputPath $csvOutputPath Write-Host "" Write-Host " Run completed at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Gray if (-not $DryRun) { Write-Host " To rollback: .\Invoke-DnsRollback.ps1 -RollbackFile `"$rollbackPath`"" -ForegroundColor Yellow } Write-Host "" # ── Helper: auto-detect OS via connection probe ─────────────────────────────── function Detect-ServerOS { <# .SYNOPSIS Probes a server to determine if it is Windows or Linux. .DESCRIPTION Tries WinRM first (Windows indicator). Falls back to SSH (Linux indicator). Used when OS column is missing or "Unknown" in the server list. #> param([string]$Hostname, [hashtable]$Config) # Try WinRM — quick test-connection style probe try { $sessionOpt = New-PSSessionOption -OpenTimeout 5000 -OperationTimeout 5000 $invokeParams = @{ ComputerName = $Hostname ScriptBlock = { $true } SessionOption = $sessionOpt ErrorAction = "Stop" } $cred = $Config._WinCredential if ($cred -and $cred -ne [System.Management.Automation.PSCredential]::Empty) { $invokeParams.Credential = $cred } $null = Invoke-Command @invokeParams return "Windows" } catch { } # Try SSH via plink — just attempt a connection with immediate exit if (Test-Path $Config.PlinkPath) { try { $proc = New-Object System.Diagnostics.Process $proc.StartInfo.FileName = $Config.PlinkPath $proc.StartInfo.Arguments = "-ssh -pw `"$($Config.LinuxSSHPassword)`" " + "-o StrictHostKeyChecking=no -batch " + "`"$($Config.LinuxSSHUser)@$Hostname`" `"echo LINUX_OK`"" $proc.StartInfo.UseShellExecute = $false $proc.StartInfo.RedirectStandardOutput = $true $proc.StartInfo.RedirectStandardError = $true $proc.StartInfo.CreateNoWindow = $true $null = $proc.Start() $output = $proc.StandardOutput.ReadToEnd() $proc.WaitForExit(($Config.SSHConnectTimeout + 5) * 1000) | Out-Null if ($output -match "LINUX_OK") { return "Linux" } } catch { } } return "Unknown" }