sync: Add Yealink tools and session log for 2026-02-24/25

Session covering YMCS setup, Yealink phone scanner tool development,
and Peaceful Spirit UCG Ultra speed diagnostics (ECM crash-loop, Cox plant issue).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 07:46:44 -07:00
parent 8b6f0bcc96
commit 92f3dd696f
4 changed files with 1637 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
# Session Log: 2026-02-24
## Session Summary
Two major topics covered this session:
### 1. Yealink YMCS Setup & Phone Scanner Tool
Set up Yealink Management Cloud Service (YMCS) for managing phones across ACG clients. Built a PowerShell scanner tool to discover Yealink phones on client networks and extract serial numbers for RPS/YMCS registration.
### 2. Peaceful Spirit (Country Club) - UCG Ultra Speed Issues
Diagnosed severe speed degradation on a Cox 300/30 circuit behind a Unifi Cloud Gateway Ultra. Root cause identified as ECM hardware offload engine crash-looping combined with Suricata IDS/IPS on High consuming excessive CPU.
---
## Topic 1: Yealink YMCS Setup
### What Was Accomplished
- Reviewed YMCS dashboard structure: Arizona Computer Guru LLC org with sites VWP and GuruHQ
- Confirmed YMCS pass-through/relay provisioning works - YMCS redirects phones to PacketDials for SIP config
- Two phones already online in YMCS:
- **ACG Test Phone**: MAC `805ec097dacf`, SIP-T46S, firmware 66.86.0.15, IP 172.16.1.58
- **Winter**: MAC `805e0c08fefa`, SIP-T46S, firmware 66.86.0.15, IP 172.16.1.29
- YMCS Site Configuration (GuruHQ) already has relay config to PacketDials:
```
auto_provision.pnp_enable=1
auto_provision.power_on=1
auto_provision.repeat.enable=1
auto_provision.repeat.minutes=30
auto_provision.server.password=********
auto_provision.server.url=ftp://p.packetdials.net
auto_provision.server.username=lrshwh
firmware.url=ftp://p.packetdials.net
static.zero_touch.enable=1
```
### Migration Plan (wlcomm to OIT VoIP)
- YMCS acts as relay/pass-through to provider's provisioning server
- When ready: change `auto_provision.server.url` in YMCS site config from PacketDials to OIT
- Push config, phones re-provision from OIT on next check-in (every 30 min) or reboot
- Each client in PacketDials/Whitelabel has shared device password, username always `admin`
### Winter Phone SIP Details (for reference)
- SIP Server: `computerguru.voip.packetdials.net`
- Username: `5f54f3c8b216`
- Password: `3eb7d67260efe017`
- Transport: DNS NAPTR
- Expires: 360
- Assigned to: Winter Williams
- E911: (520) 304-8300 - 7437 E 22...
- Line Keys: Device (Winter), Park 1-4 (*31-*34), BLF Mike (7003), BLF Rob (7007), Speed Dial Mike-Cell (1-520-289-1912), Howard-Cell (1-520-585-1310), Rob-Cell (1-520-303-6791)
### Yealink Phone Scanner Tool
Built `tools/Scan-YealinkPhones.ps1` - PowerShell script to scan subnets for Yealink phones.
**What works:**
- Ping sweep using .NET SendPingAsync (parallel batches)
- ARP table + Get-NetNeighbor parsing to find Yealink MACs
- Yealink OUI prefixes: `80:5E:C0`, `80:5E:0C`, `80:5A:35`, `00:15:65`, `28:6D:97`, `24:4B:FE`
- SSL certificate bypass for self-signed certs
- Unsafe header parsing for Yealink's non-standard HTTP responses
- CSV output with append capability
**What doesn't work (yet):**
- Serial number extraction from web UI - Yealink T46S firmware 66.86.0.15 uses RSA+AES encrypted login
- Login flow: AES-128-CBC encrypts password (with random prefix + JSESSIONID), RSA encrypts AES key/IV
- Implemented the crypto in PowerShell but got error -3 (authentication format mismatch)
- The JS crypto uses CryptoJS AES with ZeroPadding + custom RSA (pkcs1pad2)
- Issue likely related to session/nonce handling
**Alternative approaches tried:**
- SSDP/UPnP discovery: No response from Yealink phones
- SNMP (community: public): No response
- Digest auth on cgiServer.exx: 401 (auth not accepted)
- Various API endpoints: All return login page or 403
**Backup tool created:** `tools/yealink-serial-scanner.html` - Browser-based scanner that uses the phone's own JavaScript crypto. Not yet tested.
**Recommended approach:** Yealink IP Discovery Tool (official tool, not publicly available - request from Yealink distributor or check YMCS Resources section)
### Files Created/Modified
- `tools/Scan-YealinkPhones.ps1` - Main scanner script
- `tools/test-yealink.ps1` - Debug/test script (can be deleted)
- `tools/yealink-serial-scanner.html` - Browser-based scanner (backup approach)
### Credentials
- GuruHQ Yealink phone web UI: admin / b4e765c3
- PacketDials provisioning: username `lrshwh` (password masked in YMCS)
- YMCS RPS example serial: `3146019091637071` (ACG Test Phone)
---
## Topic 2: Peaceful Spirit Country Club - UCG Ultra Speed Issues
### Problem
Cox 300/30 Mbps circuit delivering 1 Mbps download with hardware acceleration ON + auto MSS clamping. Was working at full speed a few days prior.
### Equipment
- **Gateway:** Unifi Cloud Gateway Ultra (UCG-PST-CC)
- **Firmware:** UniFi OS 5.0.12, Network 10.1.85 (Official channel, auto-update ON)
- **Kernel:** 5.4.213-ui-ipq5322 (aarch64)
- **WAN:** eth4, 2500 Mbps full duplex to Cox modem
- **VPN:** WireGuard site-to-site (wgsts1000, MTU 1420) + tun1 (Teleport)
- **Cox IP:** 98.190.129.150 (wsip-98-190-129-150.ph.ph.cox.net)
- **LAN:** 192.168.0.0/24
- **Modem:** New, replaced day before session
### Test Results
| Configuration | Download | Upload |
|--------------|----------|--------|
| HW accel ON + Auto MSS | ~1 Mbps | 29 Mbps |
| HW accel ON + MSS 1300 | 28 Mbps | 29 Mbps |
| HW accel OFF + Auto MSS | 28 Mbps | 22 Mbps |
| HW accel ON + MSS 1452 | <1 Mbps | - |
| HW accel ON + MSS disabled | <2 Mbps | - |
| Later (no changes) | 150 Mbps | - |
| Later (no changes) | 271 Mbps | - |
### Root Cause Analysis (via SSH)
1. **Suricata IDS/IPS running on HIGH** - consuming 20.3% RAM (614MB), forcing all traffic through CPU
2. **ECM hardware offload NOT loaded** - `lsmod | grep ecm` returned empty; ECM is disabled when IDS/IPS is active
3. **ECM was crash-looping** in dmesg - repeated `ECM exit / ECM init` cycles
4. **MSS clamping rules only apply to tun1 (VPN)**, NOT to WAN (eth4) - UI MSS setting had no effect on WAN traffic
5. **QUIC reassembly failures** in dmesg: `[quic_sm_reassemble_func#1025]: failed to allocate reassemble cont.`
6. **WAN link flapped** - eth4 went down/up during the session period
### Key Finding
MSS clamping in the Unifi UI was a red herring - iptables showed MSS rules only on `tun1`, not `eth4`. The real issue was Suricata on High preventing hardware offload, combined with ECM instability.
### Resolution
Speed recovered to 271 Mbps without making changes - likely ECM crash loop resolved itself. Monitoring recommended.
### Recommendations
- Consider switching IDS/IPS from High to Medium/Low for better throughput
- Monitor for ECM crash recurrence
- If speeds drop again, reboot UCG Ultra to reset ECM state
- Keep SSH key in place for future diagnostics
### SSH Access
- **Host:** 192.168.0.10 (via VPN) or 98.190.129.150 (WAN)
- **User:** root (also requires password via GUI-added key)
- **Key:** `~/.ssh/ucg_peaceful_spirit` (ed25519)
- **Public key:** `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKBw+BK25MXpm91XBtDsSp7K0nTcKwFDLFZDx7tAO/N8 claude@claudetools`
- **Note:** Key was added via Unifi GUI; SSH still prompts for password in addition to key
### Infrastructure
- UCG Ultra hostname: UCG-PST-CC
- WAN interface: eth4 (NOT eth0)
- LAN interfaces: eth0-eth3 on switch0, br0
- VPN: wgsts1000 (WireGuard site-to-site), tun1 (Teleport)
---
## MSS Clamping Reference (Cox Cable)
- Cox uses standard DOCSIS, MTU 1500, no PPPoE
- Standard MSS: 1460 (1500 - 20 IP - 20 TCP)
- With IPsec VPN: ~1390-1400
- With WireGuard: 1420
- UCG Ultra max MSS input: 1452
---
## Pending/Incomplete Tasks
### Yealink YMCS
- [ ] Get Yealink IP Discovery Tool from distributor (for serial number extraction)
- [ ] Test browser-based scanner (`tools/yealink-serial-scanner.html`) as fallback
- [ ] Onboard remaining phones across all client sites into YMCS
- [ ] Build OIT VoIP config templates in YMCS when ready for migration
- [ ] Clean up test files (`tools/test-yealink.ps1`)
### Peaceful Spirit
- [ ] Monitor UCG Ultra speed stability over coming days
- [ ] If speeds drop again, consider IDS/IPS High -> Medium/Low
- [ ] Investigate why GUI-added SSH key still requires password
- [ ] Consider disabling auto-update on UCG to prevent firmware regressions
---
## Update: 2026-02-25 Follow-up
### Peaceful Spirit - Continued Degradation
After initial recovery to 278 Mbps (HW accel ON, auto MSS), speeds dropped back to 1 Mbps within minutes. ECM confirmed crash-looping again via SSH dmesg — cycling every ~6 minutes (init -> run -> exit -> repeat).
### IDS/IPS Disabled
- Switched IDS/IPS from High to disabled entirely
- Speed still unstable: initial 200+ Mbps then **decays to ~70 Mbps under sustained load**
- This speed decay pattern (burst then drop) indicates external plant issue, not gateway
### Conclusion: Cox Plant Issue
- ECM crash-looping is a SYMPTOM, not the cause
- Gateway offload engine crashing because it's receiving corrupted/incomplete frames from modem
- Speed decay under sustained load consistent with:
- Upstream noise/ingress causing CMTS power level adjustments
- Overheating or failing amplifier in plant
- Partial bonding failure (marginal channels dropping under load)
- T3 timeouts accumulating as modem loses sync on noisy channels
- **Cox tech dispatched** — needs line tech with meter at the tap
### Summary Provided to Cox Tech
- 300/30 circuit delivering 70-200 Mbps (intermittent drops to <1 Mbps)
- 50% packet loss at all packet sizes
- New modem (replaced day prior), same issue
- Speed starts 200+ then decays to 70 under sustained load
- Download severely impacted, upload less affected = downstream RF/signal issue
- Need tech to check: downstream SNR, power levels, uncorrectable codewords, T3/T4 timeouts, physical plant, RF ingress
---
## Files Reference
- `tools/Scan-YealinkPhones.ps1` - Yealink phone subnet scanner
- `tools/test-yealink.ps1` - Debug script (temporary)
- `tools/yealink-serial-scanner.html` - Browser-based serial scanner
- `~/.ssh/ucg_peaceful_spirit` - SSH key for Peaceful Spirit UCG Ultra
- `C:\temp\phones.csv` - Scanner output (test data)
- `C:\temp\yealink_common.js` - Yealink phone JS (for crypto analysis)
- `C:\temp\yealink_login.js` - Yealink login JS
- `C:\temp\yealink_loginform.txt` - Login form response dump

View File

@@ -0,0 +1,898 @@
<#
.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):
# <tr><td>Label</td><td>Value</td></tr>
$tablePattern = '<tr[^>]*>\s*<td[^>]*>\s*(?<label>[^<]+)\s*</td>\s*<td[^>]*>\s*(?<value>[^<]+)\s*</td>\s*</tr>'
$tableMatches = [regex]::Matches($body, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
foreach ($m in $tableMatches) {
$label = $m.Groups['label'].Value.Trim()
$value = $m.Groups['value'].Value.Trim()
if (-not $phoneData.MAC -and $label -match '(?i)mac') {
$phoneData.MAC = ($value -replace '-', ':').ToUpper()
}
if (-not $phoneData.Serial -and $label -match '(?i)(serial|machine)') {
$phoneData.Serial = $value
}
if (-not $phoneData.Model -and $label -match '(?i)(model|product)') {
$phoneData.Model = $value
}
if (-not $phoneData.FirmwareVersion -and $label -match '(?i)(firmware|software)') {
$phoneData.FirmwareVersion = $value
}
}
}
# If we got at least one meaningful field, consider it a success
if ($phoneData.MAC -or $phoneData.Serial -or $phoneData.Model -or $phoneData.FirmwareVersion) {
$phoneData.Success = $true
$phoneData.Error = ""
}
elseif (-not $phoneData.Error) {
$phoneData.Error = "Could not parse any fields from status page response"
}
return $phoneData
}
# ---------------------------------------------------------------------------
# CSV Output
# ---------------------------------------------------------------------------
function Export-PhoneInventory {
<#
.SYNOPSIS
Appends phone inventory data to a CSV file. Creates the file with headers if it does not exist.
#>
param(
[Parameter(Mandatory)]
[array]$PhoneRecords,
[Parameter(Mandatory)]
[string]$FilePath,
[Parameter(Mandatory)]
[string]$Site
)
$csvRows = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($phone in $PhoneRecords) {
$csvRows.Add([PSCustomObject]@{
MAC = $phone.MAC
Serial = $phone.Serial
Model = $phone.Model
FirmwareVersion = $phone.FirmwareVersion
IP = $phone.IP
SiteName = $Site
})
}
$fileExists = Test-Path -Path $FilePath -PathType Leaf
if ($fileExists) {
# Append without header
$csvRows | Export-Csv -Path $FilePath -NoTypeInformation -Append -Force
}
else {
$csvRows | Export-Csv -Path $FilePath -NoTypeInformation -Force
}
}
# ===========================================================================
# MAIN EXECUTION
# ===========================================================================
$scriptStartTime = Get-Date
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Yealink Phone Scanner" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Subnet: $Subnet"
Write-Host " Site: $SiteName"
Write-Host " Output: $OutputFile"
Write-Host " Timeout: ${Timeout}ms"
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
# --- Step 1: Calculate subnet addresses ---
Write-Host "[INFO] Calculating IP addresses for $Subnet..." -ForegroundColor Yellow
try {
$ipList = Get-SubnetAddresses -CidrSubnet $Subnet
}
catch {
Write-Host "[ERROR] Invalid subnet: $_" -ForegroundColor Red
exit 1
}
Write-Host "[OK] $($ipList.Count) host addresses in range." -ForegroundColor Green
Write-Host ""
# --- Step 2: Ping sweep to populate ARP table ---
Write-Host "[INFO] Pinging $($ipList.Count) addresses to populate ARP table..." -ForegroundColor Yellow
# Use ping in batches — send async pings using .NET Ping.SendPingAsync for speed
$pingTasks = [System.Collections.Generic.List[object]]::new()
$batchSize = 100
for ($i = 0; $i -lt $ipList.Count; $i += $batchSize) {
$batch = $ipList[$i..[math]::Min($i + $batchSize - 1, $ipList.Count - 1)]
foreach ($ip in $batch) {
$pinger = New-Object System.Net.NetworkInformation.Ping
try {
$task = $pinger.SendPingAsync($ip, $Timeout)
$pingTasks.Add(@{ Task = $task; Pinger = $pinger })
}
catch {
$pinger.Dispose()
}
}
# Wait for this batch to finish
foreach ($pt in $pingTasks) {
try { [void]$pt.Task.Wait(($Timeout + 500)) } catch {}
$pt.Pinger.Dispose()
}
$pingTasks.Clear()
$done = [math]::Min($i + $batchSize, $ipList.Count)
$pct = [math]::Round(($done / $ipList.Count) * 100)
Write-Host "`r Ping progress: $done/$($ipList.Count) ($pct%)" -NoNewline
}
Write-Host ""
Write-Host "[OK] Ping sweep complete." -ForegroundColor Green
Write-Host ""
# --- Step 3: Parse ARP table for Yealink MACs within our subnet ---
Write-Host "[INFO] Scanning ARP table for Yealink MAC prefixes..." -ForegroundColor Yellow
$yealinkDevices = [System.Collections.Generic.List[PSCustomObject]]::new()
# Build a HashSet of IPs in our target subnet for fast lookup
$subnetIPs = [System.Collections.Generic.HashSet[string]]::new()
foreach ($ip in $ipList) { [void]$subnetIPs.Add($ip) }
# Parse the full ARP table
$arpLines = & arp -a 2>$null
foreach ($line in $arpLines) {
$line = $line.Trim()
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})') {
$ip = $Matches[1]
$mac = $Matches[2].ToUpper() -replace '-', ':'
if ($subnetIPs.Contains($ip) -and (Test-YealinkMac -Mac $mac)) {
$yealinkDevices.Add([PSCustomObject]@{
IP = $ip
MAC = $mac
})
Write-Host " Found: $ip -> $mac" -ForegroundColor Cyan
}
}
}
# Also try Get-NetNeighbor as a fallback (more reliable on Windows 10/11)
try {
$neighbors = Get-NetNeighbor -State Reachable,Stale,Delay,Probe -ErrorAction SilentlyContinue
foreach ($n in $neighbors) {
$ip = $n.IPAddress
$mac = ($n.LinkLayerAddress -replace '-', ':').ToUpper()
if ($subnetIPs.Contains($ip) -and (Test-YealinkMac -Mac $mac)) {
# Avoid duplicates
$already = $yealinkDevices | Where-Object { $_.IP -eq $ip }
if (-not $already) {
$yealinkDevices.Add([PSCustomObject]@{
IP = $ip
MAC = $mac
})
Write-Host " Found: $ip -> $mac (via NetNeighbor)" -ForegroundColor Cyan
}
}
}
}
catch {
Write-Host " [INFO] Get-NetNeighbor not available, using ARP table only." -ForegroundColor DarkGray
}
Write-Host "[OK] Found $($yealinkDevices.Count) Yealink devices." -ForegroundColor Green
Write-Host ""
if ($yealinkDevices.Count -eq 0) {
Write-Host "[WARNING] No Yealink devices detected on this subnet. Exiting." -ForegroundColor DarkYellow
exit 0
}
# Display detected devices
Write-Host " Detected Yealink devices:" -ForegroundColor Cyan
foreach ($dev in $yealinkDevices) {
Write-Host " $($dev.IP) ($($dev.MAC))"
}
Write-Host ""
# --- Step 4: Query each Yealink phone's web UI ---
Write-Host "[INFO] Querying Yealink phone web UIs for inventory data..." -ForegroundColor Yellow
Write-Host ""
$successfulScrapes = [System.Collections.Generic.List[PSCustomObject]]::new()
$failedScrapes = [System.Collections.Generic.List[PSCustomObject]]::new()
$deviceIndex = 0
foreach ($device in $yealinkDevices) {
$deviceIndex++
Write-Host " [$deviceIndex/$($yealinkDevices.Count)] Querying $($device.IP) ($($device.MAC))..." -NoNewline
$phoneData = Get-YealinkPhoneData -IPAddress $device.IP -User $Username -Pass $Password -TimeoutMs ($Timeout * 5)
# If we didn't get MAC from the web UI, use the ARP MAC
if (-not $phoneData.MAC) {
$phoneData.MAC = $device.MAC
}
if ($phoneData.Success) {
$successfulScrapes.Add($phoneData)
Write-Host " [OK] $($phoneData.Model) / $($phoneData.Serial)" -ForegroundColor Green
}
else {
$failedScrapes.Add([PSCustomObject]@{
IP = $device.IP
MAC = $device.MAC
Error = $phoneData.Error
})
Write-Host " [FAILED] $($phoneData.Error)" -ForegroundColor Red
}
}
Write-Host ""
# --- Step 5: Output results ---
if ($successfulScrapes.Count -gt 0) {
# Write to CSV
Write-Host "[INFO] Writing $($successfulScrapes.Count) records to $OutputFile..." -ForegroundColor Yellow
try {
Export-PhoneInventory -PhoneRecords $successfulScrapes -FilePath $OutputFile -Site $SiteName
Write-Host "[OK] CSV updated: $OutputFile" -ForegroundColor Green
}
catch {
Write-Host "[ERROR] Failed to write CSV: $_" -ForegroundColor Red
}
# Display results table
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Scan Results" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
$successfulScrapes | ForEach-Object {
[PSCustomObject]@{
IP = $_.IP
MAC = $_.MAC
Model = $_.Model
Serial = $_.Serial
FirmwareVersion = $_.FirmwareVersion
}
} | Format-Table -AutoSize | Out-String | Write-Host
}
# Report failures
if ($failedScrapes.Count -gt 0) {
Write-Host " Failed devices:" -ForegroundColor Red
foreach ($fail in $failedScrapes) {
Write-Host " $($fail.IP) ($($fail.MAC)): $($fail.Error)" -ForegroundColor Red
}
Write-Host ""
}
# --- Summary ---
$elapsed = (Get-Date) - $scriptStartTime
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Summary" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " Site: $SiteName"
Write-Host " Subnet: $Subnet"
Write-Host " IPs scanned: $($ipList.Count)"
Write-Host " Yealink detected: $($yealinkDevices.Count)"
Write-Host " Successfully scraped: $($successfulScrapes.Count)" -ForegroundColor Green
Write-Host " Failed: $($failedScrapes.Count)" -ForegroundColor $(if ($failedScrapes.Count -gt 0) { "Red" } else { "Green" })
Write-Host " Elapsed time: $([math]::Round($elapsed.TotalSeconds, 1))s"
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""

183
tools/test-yealink.ps1 Normal file
View File

@@ -0,0 +1,183 @@
# Test SSDP/UPnP discovery for Yealink phones
param(
[string]$IP = "172.16.1.29",
[int]$DiscoveryTimeout = 5
)
# --- Test 1: SSDP M-SEARCH broadcast ---
Write-Host "=== Test 1: SSDP M-SEARCH (broadcast) ===" -ForegroundColor Yellow
Write-Host " Sending M-SEARCH to 239.255.255.250:1900..." -ForegroundColor DarkGray
$ssdpMessage = @"
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 3
ST: ssdp:all
"@
$ssdpBytes = [System.Text.Encoding]::ASCII.GetBytes($ssdpMessage.Replace("`n", "`r`n"))
$udpClient = New-Object System.Net.Sockets.UdpClient
$udpClient.Client.ReceiveTimeout = ($DiscoveryTimeout * 1000)
$udpClient.Client.SetSocketOption([System.Net.Sockets.SocketOptionLevel]::Socket,
[System.Net.Sockets.SocketOptionName]::ReuseAddress, $true)
$multicastEp = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Parse("239.255.255.250"), 1900)
$udpClient.Send($ssdpBytes, $ssdpBytes.Length, $multicastEp) | Out-Null
$responses = @()
$sw = [System.Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt $DiscoveryTimeout) {
try {
$remoteEp = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)
$data = $udpClient.Receive([ref]$remoteEp)
$response = [System.Text.Encoding]::ASCII.GetString($data)
$sourceIp = $remoteEp.Address.ToString()
if ($response -match 'yealink|Yealink|YEALINK|SIP-T') {
Write-Host " [YEALINK] Response from $sourceIp" -ForegroundColor Green
Write-Host $response -ForegroundColor Cyan
$responses += @{ IP = $sourceIp; Response = $response }
}
elseif ($sourceIp -eq $IP) {
Write-Host " Response from TARGET $sourceIp" -ForegroundColor Green
Write-Host $response -ForegroundColor Cyan
$responses += @{ IP = $sourceIp; Response = $response }
}
}
catch [System.Net.Sockets.SocketException] {
break # Timeout
}
}
$udpClient.Close()
Write-Host " Received $($responses.Count) Yealink/target responses" -ForegroundColor DarkGray
# --- Test 2: Direct SSDP to the phone's IP ---
Write-Host ""
Write-Host "=== Test 2: Direct SSDP M-SEARCH to $IP ===" -ForegroundColor Yellow
$udpClient2 = New-Object System.Net.Sockets.UdpClient
$udpClient2.Client.ReceiveTimeout = 3000
$directEp = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Parse($IP), 1900)
$udpClient2.Send($ssdpBytes, $ssdpBytes.Length, $directEp) | Out-Null
try {
$remoteEp2 = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)
$data2 = $udpClient2.Receive([ref]$remoteEp2)
$response2 = [System.Text.Encoding]::ASCII.GetString($data2)
Write-Host " Response from $($remoteEp2.Address):" -ForegroundColor Green
Write-Host $response2 -ForegroundColor Cyan
}
catch {
Write-Host " No response (timeout)" -ForegroundColor DarkYellow
}
$udpClient2.Close()
# --- Test 3: Try fetching UPnP device description if we got a LOCATION header ---
Write-Host ""
Write-Host "=== Test 3: UPnP device description URLs ===" -ForegroundColor Yellow
# Try common UPnP description URLs
$descUrls = @(
"http://${IP}:1900/description.xml",
"http://${IP}/description.xml",
"http://${IP}:49152/description.xml",
"http://${IP}:5060/description.xml",
"http://${IP}/DeviceDescription.xml",
"http://${IP}/upnp/description.xml"
)
# Also extract LOCATION from any SSDP responses
foreach ($r in $responses) {
if ($r.Response -match 'LOCATION:\s*(http[^\s\r\n]+)') {
$loc = $Matches[1].Trim()
if ($descUrls -notcontains $loc) { $descUrls = @($loc) + $descUrls }
}
}
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
[System.Net.ServicePointManager]::Expect100Continue = $false
# Enable unsafe header parsing
$netAssembly = [System.Reflection.Assembly]::GetAssembly([System.Net.Configuration.SettingsSection])
if ($netAssembly) {
$settingsType = $netAssembly.GetType("System.Net.Configuration.SettingsSectionInternal")
if ($settingsType) {
$bf = [System.Reflection.BindingFlags]::Static -bor [System.Reflection.BindingFlags]::GetProperty -bor [System.Reflection.BindingFlags]::NonPublic
$instance = $settingsType.InvokeMember("Section", $bf, $null, $null, @())
if ($instance) {
$field = $settingsType.GetField("useUnsafeHeaderParsing", [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance)
if ($field) { $field.SetValue($instance, $true) }
}
}
}
foreach ($url in $descUrls) {
Write-Host " Trying: $url" -ForegroundColor DarkGray -NoNewline
try {
$req = [System.Net.HttpWebRequest]::Create($url)
$req.Timeout = 3000
$req.UserAgent = "UPnP/1.0"
$resp = $req.GetResponse()
$reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
$body = $reader.ReadToEnd()
$reader.Close()
$resp.Close()
Write-Host " -> OK ($($body.Length) chars)" -ForegroundColor Green
Write-Host $body -ForegroundColor Cyan
# Save if it looks like XML device description
if ($body -match 'serialNumber|modelName|manufacturer') {
Write-Host ""
Write-Host " [FOUND] Device description with useful fields!" -ForegroundColor Green
$body | Out-File "C:\temp\yealink_upnp.xml" -Encoding UTF8
}
}
catch {
Write-Host " -> FAIL" -ForegroundColor Red
}
}
# --- Test 4: Try SNMP if available ---
Write-Host ""
Write-Host "=== Test 4: SNMP probe (community: public) ===" -ForegroundColor Yellow
Write-Host " Trying SNMPv2c GET on common OIDs..." -ForegroundColor DarkGray
# Build a simple SNMPv2c GET request for sysDescr (1.3.6.1.2.1.1.1.0)
# This is a minimal hand-crafted SNMP packet
$snmpGet = [byte[]]@(
0x30, 0x29, # SEQUENCE, length 41
0x02, 0x01, 0x01, # INTEGER: version = 1 (SNMPv2c)
0x04, 0x06, # OCTET STRING: community
0x70, 0x75, 0x62, 0x6C, 0x69, 0x63, # "public"
0xA0, 0x1C, # GET-REQUEST, length 28
0x02, 0x04, 0x01, 0x02, 0x03, 0x04, # request-id
0x02, 0x01, 0x00, # error-status: 0
0x02, 0x01, 0x00, # error-index: 0
0x30, 0x0E, # varbind list
0x30, 0x0C, # varbind
0x06, 0x08, # OID
0x2B, 0x06, 0x01, 0x02, 0x01, 0x01, 0x01, 0x00, # 1.3.6.1.2.1.1.1.0 (sysDescr)
0x05, 0x00 # NULL
)
try {
$snmpClient = New-Object System.Net.Sockets.UdpClient
$snmpClient.Client.ReceiveTimeout = 3000
$snmpEp = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Parse($IP), 161)
$snmpClient.Send($snmpGet, $snmpGet.Length, $snmpEp) | Out-Null
$snmpRemote = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)
$snmpData = $snmpClient.Receive([ref]$snmpRemote)
$snmpResponse = [System.Text.Encoding]::ASCII.GetString($snmpData)
Write-Host " SNMP response received ($($snmpData.Length) bytes)" -ForegroundColor Green
# Try to extract readable text from the response
$readable = ($snmpData | ForEach-Object { if ($_ -ge 32 -and $_ -le 126) { [char]$_ } else { "." } }) -join ""
Write-Host " Readable: $readable" -ForegroundColor Cyan
$snmpClient.Close()
}
catch {
Write-Host " No SNMP response (timeout or blocked)" -ForegroundColor DarkYellow
}

File diff suppressed because one or more lines are too long