# .SYNOPSIS Scans a subnet for Yealink phones and extracts inventory data from their web UI. .DESCRIPTION Performs a fast parallel ping sweep of a given CIDR subnet, filters for Yealink MAC address prefixes via the ARP table, authenticates to each phone's web UI using digest auth, and extracts MAC, serial number, model, and firmware version. Results are written to CSV and displayed in the console. .PARAMETER Subnet Subnet to scan in CIDR notation (e.g., 192.168.1.0/24). .PARAMETER Username Web UI username for the Yealink phones. Default: admin .PARAMETER Password Web UI password for the Yealink phones. .PARAMETER SiteName Client/site name included in each CSV row for multi-site inventory tracking. .PARAMETER OutputFile Path for the output CSV file. Default: yealink_inventory.csv .PARAMETER Timeout Timeout in milliseconds for network operations. Default: 1000 .EXAMPLE .\Scan-YealinkPhones.ps1 -Subnet "192.168.1.0/24" -Password "mypass" -SiteName "ClientHQ" .EXAMPLE .\Scan-YealinkPhones.ps1 -Subnet "10.0.5.0/24" -Username "admin" -Password "p@ss" -SiteName "BranchOffice" -OutputFile "C:\inventory\phones.csv" -Timeout 2000 .NOTES Compatible with PowerShell 5.1 (Windows built-in). No external module dependencies. Uses runspaces for parallel ping sweep. Supports Yealink digest authentication. #> [CmdletBinding()] param( [Parameter(Mandatory = $true, HelpMessage = "Subnet in CIDR notation, e.g. 192.168.1.0/24")] [ValidatePattern('^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$')] [string]$Subnet, [Parameter(Mandatory = $false)] [string]$Username = "admin", [Parameter(Mandatory = $true, HelpMessage = "Web UI password for the phones")] [string]$Password, [Parameter(Mandatory = $true, HelpMessage = "Client/site name for the CSV output")] [string]$SiteName, [Parameter(Mandatory = $false)] [string]$OutputFile = "yealink_inventory.csv", [Parameter(Mandatory = $false)] [ValidateRange(100, 30000)] [int]$Timeout = 1000 ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # --------------------------------------------------------------------------- # Yealink OUI prefixes (colon-separated, uppercase for comparison) # --------------------------------------------------------------------------- $YealinkPrefixes = @("80:5E:C0", "80:5E:0C", "80:5A:35", "00:15:65", "28:6D:97", "24:4B:FE") # --------------------------------------------------------------------------- # CIDR Calculation # --------------------------------------------------------------------------- function Get-SubnetAddresses { <# .SYNOPSIS Returns all usable host IP addresses for a given CIDR subnet. #> param( [Parameter(Mandatory)] [string]$CidrSubnet ) $parts = $CidrSubnet -split '/' $networkAddress = [System.Net.IPAddress]::Parse($parts[0]) $prefixLength = [int]$parts[1] if ($prefixLength -lt 8 -or $prefixLength -gt 30) { throw "Prefix length must be between /8 and /30. Got /$prefixLength." } $networkBytes = $networkAddress.GetAddressBytes() # Convert to UInt32 (big-endian) $networkUInt = ([uint32]$networkBytes[0] -shl 24) -bor ` ([uint32]$networkBytes[1] -shl 16) -bor ` ([uint32]$networkBytes[2] -shl 8) -bor ` ([uint32]$networkBytes[3]) $hostBits = 32 - $prefixLength $totalAddresses = [math]::Pow(2, $hostBits) $subnetMask = ([uint32]::MaxValue) -shl $hostBits -band [uint32]::MaxValue $networkStart = $networkUInt -band $subnetMask $addresses = [System.Collections.Generic.List[string]]::new() # Skip network address (first) and broadcast address (last) for ($i = 1; $i -lt ($totalAddresses - 1); $i++) { $ipUInt = $networkStart + $i $octet1 = ($ipUInt -shr 24) -band 0xFF $octet2 = ($ipUInt -shr 16) -band 0xFF $octet3 = ($ipUInt -shr 8) -band 0xFF $octet4 = $ipUInt -band 0xFF $addresses.Add("$octet1.$octet2.$octet3.$octet4") } return $addresses } # --------------------------------------------------------------------------- # Parallel Ping Sweep using Runspaces # --------------------------------------------------------------------------- function Invoke-PingSweep { <# .SYNOPSIS Pings all IPs in parallel using runspaces. Returns list of responding IPs. #> param( [Parameter(Mandatory)] [System.Collections.Generic.List[string]]$IPAddresses, [int]$TimeoutMs = 1000, [int]$ThrottleLimit = 64 ) $runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $ThrottleLimit) $runspacePool.Open() $scriptBlock = { param([string]$IP, [int]$Timeout) $pinger = New-Object System.Net.NetworkInformation.Ping try { $result = $pinger.Send($IP, $Timeout) if ($result.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) { return $IP } } catch { # Host unreachable or other error — skip silently } finally { $pinger.Dispose() } return $null } $jobs = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($ip in $IPAddresses) { $ps = [System.Management.Automation.PowerShell]::Create() $ps.RunspacePool = $runspacePool [void]$ps.AddScript($scriptBlock) [void]$ps.AddArgument($ip) [void]$ps.AddArgument($TimeoutMs) $handle = $ps.BeginInvoke() $jobs.Add([PSCustomObject]@{ PowerShell = $ps Handle = $handle }) } $liveHosts = [System.Collections.Generic.List[string]]::new() $completed = 0 $total = $jobs.Count foreach ($job in $jobs) { try { $result = $job.PowerShell.EndInvoke($job.Handle) if ($result -and $result[0]) { $liveHosts.Add($result[0]) } } catch { # Silently skip failed pings } finally { $job.PowerShell.Dispose() } $completed++ if ($completed % 50 -eq 0 -or $completed -eq $total) { $pct = [math]::Round(($completed / $total) * 100) Write-Host "`r Ping progress: $completed/$total ($pct%)" -NoNewline } } Write-Host "" $runspacePool.Close() $runspacePool.Dispose() return $liveHosts } # --------------------------------------------------------------------------- # ARP Table MAC Lookup # --------------------------------------------------------------------------- function Get-ArpMac { <# .SYNOPSIS Retrieves MAC address for an IP from the local ARP table. Returns MAC in colon-separated uppercase format, or $null if not found. #> param( [Parameter(Mandatory)] [string]$IPAddress ) try { $arpOutput = & arp -a $IPAddress 2>$null if (-not $arpOutput) { return $null } foreach ($line in $arpOutput) { $line = $line.Trim() # Windows ARP format: "192.168.1.1 80-5e-c0-aa-bb-cc dynamic" if ($line -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+([\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2})') { $mac = $Matches[1].ToUpper() -replace '-', ':' return $mac } } } catch { # ARP lookup failed — not critical } return $null } # --------------------------------------------------------------------------- # Yealink MAC Prefix Check # --------------------------------------------------------------------------- function Test-YealinkMac { <# .SYNOPSIS Returns $true if the MAC address belongs to a known Yealink OUI prefix. #> param( [Parameter(Mandatory)] [string]$Mac ) $macUpper = $Mac.ToUpper() -replace '-', ':' $prefix = ($macUpper -split ':')[0..2] -join ':' return ($YealinkPrefixes -contains $prefix) } # --------------------------------------------------------------------------- # Digest Authentication Helper # --------------------------------------------------------------------------- function Set-UnsafeHeaderParsing { <# .SYNOPSIS Enables useUnsafeHeaderParsing to tolerate non-standard HTTP headers from embedded devices like Yealink phones. #> $netAssembly = [System.Reflection.Assembly]::GetAssembly([System.Net.Configuration.SettingsSection]) if ($netAssembly) { $bindingFlags = [System.Reflection.BindingFlags]::Static -bor [System.Reflection.BindingFlags]::GetProperty -bor [System.Reflection.BindingFlags]::NonPublic $settingsType = $netAssembly.GetType("System.Net.Configuration.SettingsSectionInternal") if ($settingsType) { $instance = $settingsType.InvokeMember("Section", $bindingFlags, $null, $null, @()) if ($instance) { $useUnsafeField = $settingsType.GetField("useUnsafeHeaderParsing", [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance) if ($useUnsafeField) { $useUnsafeField.SetValue($instance, $true) } } } } } function Invoke-DigestAuthRequest { <# .SYNOPSIS Performs an HTTP GET with digest authentication. Compatible with PowerShell 5.1. #> param( [Parameter(Mandatory)] [string]$Uri, [Parameter(Mandatory)] [string]$User, [Parameter(Mandatory)] [string]$Pass, [int]$TimeoutMs = 5000 ) # Enable lenient header parsing for Yealink's non-standard HTTP responses Set-UnsafeHeaderParsing # Ignore SSL certificate errors (phones use self-signed certs) [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } # Ensure TLS 1.2 support [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls -bor [System.Net.SecurityProtocolType]::Ssl3 $credential = New-Object System.Net.NetworkCredential($User, $Pass) $credCache = New-Object System.Net.CredentialCache $credCache.Add([Uri]$Uri, "Digest", $credential) # Also add Basic in case some models use it $credCache.Add([Uri]$Uri, "Basic", $credential) $request = [System.Net.HttpWebRequest]::Create($Uri) $request.Credentials = $credCache $request.PreAuthenticate = $false $request.Timeout = $TimeoutMs $request.ReadWriteTimeout = $TimeoutMs $request.Method = "GET" $request.UserAgent = "Mozilla/5.0 (YealinkScanner)" try { $response = $request.GetResponse() $stream = $response.GetResponseStream() $reader = New-Object System.IO.StreamReader($stream) $body = $reader.ReadToEnd() $reader.Close() $stream.Close() $response.Close() return $body } catch [System.Net.WebException] { $ex = $_.Exception if ($ex.Response) { $statusCode = [int]$ex.Response.StatusCode if ($statusCode -eq 401) { throw "Authentication failed (HTTP 401)" } throw "HTTP error $statusCode" } throw "Connection failed: $($ex.Message)" } } # --------------------------------------------------------------------------- # Yealink Data Extraction # --------------------------------------------------------------------------- function Get-YealinkPhoneData { <# .SYNOPSIS Queries a Yealink phone's web UI and extracts inventory data. Tries the structured data endpoint first, then falls back to the HTML status page. #> param( [Parameter(Mandatory)] [string]$IPAddress, [Parameter(Mandatory)] [string]$User, [Parameter(Mandatory)] [string]$Pass, [int]$TimeoutMs = 5000 ) $phoneData = [PSCustomObject]@{ MAC = "" Serial = "" Model = "" FirmwareVersion = "" IP = $IPAddress Success = $false Error = "" } # Enable lenient header parsing and SSL bypass Set-UnsafeHeaderParsing [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls -bor [System.Net.SecurityProtocolType]::Ssl3 $body = $null $protocols = @("https", "http") # Disable Expect: 100-continue globally (Yealink returns 417 otherwise) [System.Net.ServicePointManager]::Expect100Continue = $false foreach ($proto in $protocols) { $baseUrl = "${proto}://$IPAddress" # --- Method 1: Cookie-based login (T4x/T5x with newer firmware) --- try { $cookieContainer = New-Object System.Net.CookieContainer # Step 1: POST login $loginUrl = "$baseUrl/servlet?m=mod_listener&p=login&q=login&Ession=0" $loginRequest = [System.Net.HttpWebRequest]::Create($loginUrl) $loginRequest.Method = "POST" $loginRequest.ContentType = "application/x-www-form-urlencoded" $loginRequest.ServicePoint.Expect100Continue = $false $loginRequest.CookieContainer = $cookieContainer $loginRequest.Timeout = $TimeoutMs $loginRequest.ReadWriteTimeout = $TimeoutMs $loginRequest.UserAgent = "Mozilla/5.0 (YealinkScanner)" $loginRequest.AllowAutoRedirect = $true $loginBody = "username=$User&pwd=$Pass" $loginBytes = [System.Text.Encoding]::UTF8.GetBytes($loginBody) $loginRequest.ContentLength = $loginBytes.Length $reqStream = $loginRequest.GetRequestStream() $reqStream.Write($loginBytes, 0, $loginBytes.Length) $reqStream.Close() $loginResponse = $loginRequest.GetResponse() $loginReader = New-Object System.IO.StreamReader($loginResponse.GetResponseStream()) $loginResult = $loginReader.ReadToEnd() $loginReader.Close() $loginResponse.Close() # Step 2: Fetch status page with session cookie $statusUrl = "$baseUrl/servlet?m=mod_data&p=status-status&q=load" $statusRequest = [System.Net.HttpWebRequest]::Create($statusUrl) $statusRequest.Method = "GET" $statusRequest.CookieContainer = $cookieContainer $statusRequest.Timeout = $TimeoutMs $statusRequest.ReadWriteTimeout = $TimeoutMs $statusRequest.UserAgent = "Mozilla/5.0 (YealinkScanner)" $statusResponse = $statusRequest.GetResponse() $statusReader = New-Object System.IO.StreamReader($statusResponse.GetResponseStream()) $body = $statusReader.ReadToEnd() $statusReader.Close() $statusResponse.Close() # Check if we got actual data (not a login page) if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') { break } # Try alternate status endpoint $statusUrl2 = "$baseUrl/servlet?p=status-status" $statusRequest2 = [System.Net.HttpWebRequest]::Create($statusUrl2) $statusRequest2.Method = "GET" $statusRequest2.CookieContainer = $cookieContainer $statusRequest2.Timeout = $TimeoutMs $statusRequest2.ReadWriteTimeout = $TimeoutMs $statusRequest2.UserAgent = "Mozilla/5.0 (YealinkScanner)" $statusResponse2 = $statusRequest2.GetResponse() $statusReader2 = New-Object System.IO.StreamReader($statusResponse2.GetResponseStream()) $body = $statusReader2.ReadToEnd() $statusReader2.Close() $statusResponse2.Close() if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') { break } $body = $null } catch { $phoneData.Error = $_.Exception.Message $body = $null continue } # --- Method 2: Digest auth fallback (older firmware) --- $endpoints = @( "$baseUrl/servlet?m=mod_data&p=status-status&q=load", "$baseUrl/servlet?p=status-status" ) foreach ($endpoint in $endpoints) { try { $body = Invoke-DigestAuthRequest -Uri $endpoint -User $User -Pass $Pass -TimeoutMs $TimeoutMs if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') { break } $body = $null } catch { $phoneData.Error = $_.Exception.Message $body = $null continue } } if ($body) { break } } if (-not $body) { if (-not $phoneData.Error) { $phoneData.Error = "No valid response from any status endpoint" } return $phoneData } # DEBUG: dump raw response to temp file for inspection $debugFile = Join-Path $env:TEMP "yealink_debug_$($IPAddress -replace '\.','_').txt" $body | Out-File -FilePath $debugFile -Encoding UTF8 -Force Write-Host " DEBUG: Raw response saved to $debugFile" -ForegroundColor DarkGray # --- Parse structured JSON response (mod_data endpoint) --- try { # Attempt JSON parse first (mod_data endpoint returns JSON on many models) $jsonData = $body | ConvertFrom-Json -ErrorAction Stop # Different models use different JSON field names. # Common patterns observed across T2x/T4x/T5x series: $macFields = @("MacAddress", "MAC", "mac", "Mac_Address", "MACAddress") $serialFields = @("SerialNumber", "Serial", "serial", "Machine_ID", "MachineID", "SN") $modelFields = @("ModelName", "Model", "model", "ProductName", "Product", "DeviceModel") $fwFields = @("FirmwareVersion", "Firmware", "firmware", "FWVersion", "fw_version", "SoftwareVersion") foreach ($field in $macFields) { $val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1 if ($val -and $val.Value) { $phoneData.MAC = ($val.Value -replace '-', ':').ToUpper(); break } } foreach ($field in $serialFields) { $val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1 if ($val -and $val.Value) { $phoneData.Serial = [string]$val.Value; break } } foreach ($field in $modelFields) { $val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1 if ($val -and $val.Value) { $phoneData.Model = [string]$val.Value; break } } foreach ($field in $fwFields) { $val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1 if ($val -and $val.Value) { $phoneData.FirmwareVersion = [string]$val.Value; break } } # Some models nest data under a "body" or "data" property $nestedContainers = @("body", "data", "Body", "Data", "status") foreach ($container in $nestedContainers) { $nested = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $container } | Select-Object -First 1 if ($nested -and $nested.Value -and $nested.Value -is [PSCustomObject]) { $obj = $nested.Value if (-not $phoneData.MAC) { foreach ($field in $macFields) { $val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1 if ($val -and $val.Value) { $phoneData.MAC = ($val.Value -replace '-', ':').ToUpper(); break } } } if (-not $phoneData.Serial) { foreach ($field in $serialFields) { $val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1 if ($val -and $val.Value) { $phoneData.Serial = [string]$val.Value; break } } } if (-not $phoneData.Model) { foreach ($field in $modelFields) { $val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1 if ($val -and $val.Value) { $phoneData.Model = [string]$val.Value; break } } } if (-not $phoneData.FirmwareVersion) { foreach ($field in $fwFields) { $val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1 if ($val -and $val.Value) { $phoneData.FirmwareVersion = [string]$val.Value; break } } } } } } catch { # Not JSON — fall through to HTML/text parsing } # --- Fallback: Parse HTML/text response --- if (-not $phoneData.MAC -or -not $phoneData.Serial -or -not $phoneData.Model -or -not $phoneData.FirmwareVersion) { # MAC Address patterns in HTML if (-not $phoneData.MAC) { if ($body -match '(?i)(?:mac\s*(?:address)?|MAC)\s*[:=]\s*([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})') { $phoneData.MAC = ($Matches[1] -replace '-', ':').ToUpper() } } # Serial / Machine ID if (-not $phoneData.Serial) { if ($body -match '(?i)(?:serial\s*(?:number)?|machine\s*id|SN)\s*[:=]\s*([A-Za-z0-9]+)') { $phoneData.Serial = $Matches[1] } } # Model if (-not $phoneData.Model) { # Look for Yealink model patterns like T46S, T54W, SIP-T48G, VP59, CP920, etc. if ($body -match '(?i)(?:model|product\s*name|device\s*model)\s*[:=]\s*((?:SIP-)?[A-Za-z]{1,4}[\-]?[0-9]{2,4}[A-Za-z]?)') { $phoneData.Model = $Matches[1] } elseif ($body -match '(?i)(Yealink\s+(?:SIP-)?[A-Za-z]{1,4}[\-]?[0-9]{2,4}[A-Za-z]?)') { $phoneData.Model = $Matches[1] } } # Firmware Version if (-not $phoneData.FirmwareVersion) { if ($body -match '(?i)(?:firmware|software)\s*(?:version)?\s*[:=]\s*([0-9]+\.[0-9]+\.[0-9]+[.\-][0-9A-Za-z.]+)') { $phoneData.FirmwareVersion = $Matches[1] } elseif ($body -match '(?i)(?:firmware|software)\s*(?:version)?\s*[:=]\s*(\S+)') { $phoneData.FirmwareVersion = $Matches[1] } } # Try parsing HTML table rows (common Yealink status page format): #