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:
218
session-logs/2026-02-24-session.md
Normal file
218
session-logs/2026-02-24-session.md
Normal 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
|
||||||
898
tools/Scan-YealinkPhones.ps1
Normal file
898
tools/Scan-YealinkPhones.ps1
Normal 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
183
tools/test-yealink.ps1
Normal 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
|
||||||
|
}
|
||||||
338
tools/yealink-serial-scanner.html
Normal file
338
tools/yealink-serial-scanner.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user