diff --git a/session-logs/2026-02-24-session.md b/session-logs/2026-02-24-session.md new file mode 100644 index 0000000..54e44cb --- /dev/null +++ b/session-logs/2026-02-24-session.md @@ -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 diff --git a/tools/Scan-YealinkPhones.ps1 b/tools/Scan-YealinkPhones.ps1 new file mode 100644 index 0000000..8e8892f --- /dev/null +++ b/tools/Scan-YealinkPhones.ps1 @@ -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): + # LabelValue + $tablePattern = ']*>\s*]*>\s*(?