Compare commits

...

2 Commits

Author SHA1 Message Date
92f3dd696f 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>
2026-02-25 07:46:44 -07:00
8b6f0bcc96 sync: Multi-project updates - SolverBot, GuruRMM, Dataforth
SolverBot:
- Inject active project path into agent system prompts so agents
  know which directory to scope file operations to

GuruRMM:
- Bump agent version to 0.6.0
- Add serde aliases for PowerShell/ClaudeTask command types
- Add typed CommandType enum on server for proper serialization
- Support claude_task command type in send_command API

Dataforth:
- Fix SCP space-escaping in Sync-FromNAS.ps1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:16:18 -07:00
41 changed files with 3181 additions and 15 deletions

View File

@@ -68,9 +68,9 @@ function Copy-FromNAS {
New-Item -ItemType Directory -Path $localDir -Force | Out-Null
}
# FIX: Use -i for key auth, remove embedded quotes around remote path
# DOS 8.3 filenames have no spaces - embedded quotes caused "ambiguous target"
$result = & $SCP -O -i $SSH_KEY -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" "${NAS_USER}@${NAS_IP}:${RemotePath}" "$LocalPath" 2>&1
# Escape spaces in remote path for SCP (unescaped spaces cause "ambiguous target")
$escapedRemote = $RemotePath -replace ' ', '\ '
$result = & $SCP -O -i $SSH_KEY -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" "${NAS_USER}@${NAS_IP}:${escapedRemote}" "$LocalPath" 2>&1
if ($LASTEXITCODE -ne 0) {
$errorMsg = $result | Out-String
Write-Log " SCP PULL ERROR (exit $LASTEXITCODE): $errorMsg"
@@ -93,9 +93,9 @@ function Copy-ToNAS {
$remoteDir = ($RemotePath -replace '[^/]+$', '').TrimEnd('/')
Invoke-NASCommand "mkdir -p '$remoteDir'" | Out-Null
# FIX: Use -i for key auth, remove embedded quotes around remote path
# DOS 8.3 filenames have no spaces - embedded quotes caused "ambiguous target"
$result = & $SCP -O -i $SSH_KEY -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" "$LocalPath" "${NAS_USER}@${NAS_IP}:${RemotePath}" 2>&1
# Escape spaces in remote path for SCP (unescaped spaces cause "ambiguous target")
$escapedRemote = $RemotePath -replace ' ', '\ '
$result = & $SCP -O -i $SSH_KEY -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" "$LocalPath" "${NAS_USER}@${NAS_IP}:${escapedRemote}" 2>&1
if ($LASTEXITCODE -ne 0) {
$errorMsg = $result | Out-String
Write-Log " SCP PUSH ERROR (exit $LASTEXITCODE): $errorMsg"

View File

@@ -0,0 +1,606 @@
# GuruRMM - Complete Reference Documentation
**Project:** GuruRMM - Remote Monitoring and Management Platform
**Version:** Server 0.2.0 / Agent 0.3.5 (deployed as 0.5.1)
**Last Updated:** 2026-02-17
---
## Table of Contents
1. [Project Overview](#project-overview)
2. [Architecture](#architecture)
3. [API Endpoints](#api-endpoints)
4. [WebSocket Protocol](#websocket-protocol)
5. [Command Execution](#command-execution)
6. [Claude Code Integration](#claude-code-integration)
7. [Agent Configuration](#agent-configuration)
8. [Deployed Agents](#deployed-agents)
9. [Database](#database)
10. [Authentication](#authentication)
11. [Auto-Update System](#auto-update-system)
12. [Known Issues](#known-issues)
13. [Development](#development)
14. [File Structure](#file-structure)
---
## Project Overview
GuruRMM is a Remote Monitoring and Management (RMM) platform built entirely in Rust. It provides real-time agent monitoring, remote command execution, system metrics collection, and service watchdog capabilities for managed IT environments.
### Technology Stack
| Component | Technology | Version |
|------------|-----------------------------------------|---------|
| Server | Rust (Axum 0.7, SQLx 0.8, PostgreSQL) | 0.2.0 |
| Agent | Rust (cross-platform, native service) | 0.3.5 (deployed as 0.5.1) |
| Dashboard | React + TypeScript + Vite | -- |
| Real-time | WebSocket (tokio-tungstenite) | -- |
| Database | PostgreSQL | -- |
---
## Architecture
### Server
- **Internal Address:** 172.16.3.30:3001
- **Production URL:** https://rmm-api.azcomputerguru.com
- **WebSocket Endpoint:** wss://rmm-api.azcomputerguru.com/ws
- **Database:** PostgreSQL (same server)
- **Service:** systemd unit `gururmm-server`
- **Source:** `D:\ClaudeTools\projects\msp-tools\guru-rmm\server\`
### Agent
- **Windows Service Name:** GuruRMM (uses native-service feature)
- **Legacy Mode:** NSSM wrapper for Windows 7 / Server 2008 R2
- **Config Path:** `C:\ProgramData\GuruRMM\agent.toml`
- **Binary Path:** `C:\Program Files\GuruRMM\gururmm-agent.exe`
- **Source:** `D:\ClaudeTools\projects\msp-tools\guru-rmm\agent\`
### Communication Model
```
+-------------------+ WebSocket (persistent, bidirectional) +-------------------+
| GuruRMM Agent | <-----------------------------------------------> | GuruRMM Server |
| (Windows/Linux) | | (Axum + Tokio) |
+-------------------+ +-------------------+
|
| REST API (JWT)
v
+-------------------+
| Dashboard |
| (React + TS) |
+-------------------+
```
- **Primary:** WebSocket -- persistent bidirectional connection between agent and server
- **Legacy Fallback:** REST heartbeat polling -- [WARNING] NOT FULLY IMPLEMENTED
- **Auth:** API key sent in initial WebSocket authentication message
- **Site-Based Auth:** WORD-WORD-NUMBER format site codes combined with device_id
---
## API Endpoints
### Authentication
| Method | Path | Description | Auth Required |
|--------|--------------------|----------------------------|---------------|
| POST | /api/auth/login | User login (email/password -> JWT) | No |
| POST | /api/auth/register | User registration | No (disabled) |
| GET | /api/auth/me | Get current user info | Yes |
### Clients
| Method | Path | Description | Auth Required |
|--------|-------------------------|-------------------------|---------------|
| GET | /api/clients | List all clients | Yes |
| POST | /api/clients | Create client | Yes |
| GET | /api/clients/:id | Get client by ID | Yes |
| PUT | /api/clients/:id | Update client | Yes |
| DELETE | /api/clients/:id | Delete client | Yes |
| GET | /api/clients/:id/sites | List client's sites | Yes |
### Sites
| Method | Path | Description | Auth Required |
|--------|--------------------------------|--------------------------|---------------|
| GET | /api/sites | List all sites | Yes |
| POST | /api/sites | Create site | Yes |
| GET | /api/sites/:id | Get site by ID | Yes |
| PUT | /api/sites/:id | Update site | Yes |
| DELETE | /api/sites/:id | Delete site | Yes |
| POST | /api/sites/:id/regenerate-key | Regenerate site API key | Yes |
### Agents
| Method | Path | Description | Auth Required |
|--------|--------------------------|--------------------------------------|---------------|
| GET | /api/agents | List all agents | Yes |
| POST | /api/agents | Register agent (authenticated) | Yes |
| GET | /api/agents/stats | Agent statistics | Yes |
| GET | /api/agents/unassigned | List unassigned agents | Yes |
| GET | /api/agents/:id | Get agent details | Yes |
| DELETE | /api/agents/:id | Delete agent | Yes |
| POST | /api/agents/:id/move | Move agent to different site | Yes |
| GET | /api/agents/:id/state | Get agent state (network, metrics) | Yes |
### Commands
| Method | Path | Description | Auth Required |
|--------|----------------------------|----------------------------|---------------|
| POST | /api/agents/:id/command | Send command to agent | Yes |
| GET | /api/commands | List recent commands | Yes |
| GET | /api/commands/:id | Get command status/result | Yes |
### Metrics
| Method | Path | Description | Auth Required |
|--------|----------------------------|---------------------------|---------------|
| GET | /api/agents/:id/metrics | Get agent metrics history | Yes |
| GET | /api/metrics/summary | Metrics summary | Yes |
### Legacy Agent Endpoints
These endpoints do **not** require JWT authentication. They are used by agents in legacy polling mode.
| Method | Path | Description | Auth Required |
|--------|-------------------------------|------------------------------|---------------|
| POST | /api/agent/register-legacy | Register with site code | No |
| POST | /api/agent/heartbeat | Agent heartbeat | No |
| POST | /api/agent/command-result | Submit command result | No |
[WARNING] Legacy heartbeat returns empty `pending_commands` -- not implemented (agents.rs line 334).
[WARNING] Legacy command-result endpoint does not store results (agents.rs lines 354-360).
### WebSocket
| Method | Path | Description | Auth Required |
|--------|------|------------------------|---------------------|
| GET | /ws | WebSocket upgrade | API key in auth msg |
---
## WebSocket Protocol
### Connection Flow
1. Client initiates WebSocket upgrade to `wss://rmm-api.azcomputerguru.com/ws`
2. Agent sends authentication message with API key and device info
3. Server validates API key (SHA256 hash match or site code lookup)
4. On success, server registers the WebSocket connection for the agent
5. Bidirectional message exchange begins
### Message Types
**Agent -> Server:**
- `Auth` -- Initial authentication payload (api_key, hostname, os_info, version)
- `Heartbeat` -- Periodic keepalive
- `MetricsReport` -- System metrics (CPU, memory, disk, network)
- `NetworkState` -- Network configuration snapshot (hash-based change detection)
- `CommandResult` -- Result of executed command (exit_code, stdout, stderr, duration)
- `WatchdogEvent` -- Service monitoring event
**Server -> Agent:**
- `AuthResponse` -- Success/failure of authentication
- `Command` -- Command to execute (CommandPayload)
- `Update` -- Auto-update instruction (download_url, checksum)
- `Ping` -- Keepalive ping
---
## Command Execution
### Command Types
| Type | Description | Shell Used |
|--------------|----------------------------------------------|---------------------------------------------|
| shell | System shell command | cmd.exe (Windows), /bin/sh (Unix) |
| powershell | PowerShell command | powershell -NoProfile -NonInteractive -Command |
| python | Python inline code | python -c |
| script | Custom interpreter | Configurable |
| claude_task | Claude Code task execution (special handler) | Claude Code CLI |
### Command Flow
```
1. Dashboard sends POST /api/agents/:id/command
Body: { command_type, command, timeout_seconds, elevated }
2. Server creates command record in database (status = pending)
3. If agent is connected via WebSocket:
-> Server sends command via WebSocket
-> Status updated to "running"
4. If agent is offline:
-> Command stays as "pending" (queued)
5. Agent receives command and executes it
6. Agent sends CommandResult back via WebSocket
-> { id, exit_code, stdout, stderr, duration_ms }
7. Server updates database with result
```
### Command States
| State | Description |
|-----------|------------------------------------------------|
| pending | Created, agent offline or not yet sent |
| running | Sent to agent via WebSocket, awaiting result |
| completed | Agent reported exit_code = 0 |
| failed | Agent reported exit_code != 0 |
### [BUG] Server-Agent Command Type Mismatch
This is a **critical** known bug that prevents all remote command execution.
**Root Cause:**
The server's `CommandPayload` serializes `command_type` as a plain JSON string:
```json
{"command_type": "powershell", "command": "Get-Process", ...}
```
The agent's `CommandPayload` expects `command_type` as a Rust enum (`CommandType::PowerShell`), which serde deserializes from an object or tagged format, not a bare string.
**Result:** Serde deserialization fails silently on the agent side. Commands are never executed. All commands remain in "running" state permanently because no `CommandResult` is ever sent back.
**Fix Required:** Either:
- Change the server to serialize `command_type` in the enum format the agent expects, OR
- Change the agent to accept plain string values for `command_type`
---
## Claude Code Integration
### Architecture
The agent includes a built-in Claude Code executor for running AI-assisted tasks.
- **Singleton:** Global `ClaudeExecutor` via `once_cell::Lazy`
- **Working Directory:** Restricted to `C:\Shares\test\` only
- **Rate Limit:** 10 tasks per hour (sliding window)
- **Max Concurrent:** 2 simultaneous tasks
- **Default Timeout:** 300 seconds (max 600)
- **Input Sanitization:** Blocks `& | ; $ ( ) < > \` \n \r`
### Claude Task Command Format
The server sends:
```json
{
"type": "command",
"payload": {
"id": "uuid",
"command_type": "claude_task",
"command": "task description",
"timeout_seconds": 300,
"elevated": false
}
}
```
[WARNING] This also suffers from the command type mismatch bug. The agent expects `command_type` to be an object for ClaudeTask:
```json
{
"claude_task": {
"task": "...",
"working_directory": "...",
"context_files": [...]
}
}
```
### Exit Codes
| Code | Meaning |
|------|------------------------------------------|
| 0 | Task completed successfully |
| 1 | Task failed |
| 124 | Task timed out |
| -1 | Executor error (rate limit, validation) |
---
## Agent Configuration
### agent.toml Format
```toml
[server]
url = "wss://rmm-api.azcomputerguru.com/ws"
api_key = "SITE-CODE-1234" # or grmm_xxxxx API key
[metrics]
interval_seconds = 60 # Range: 10-3600, default: 60
collect_cpu = true
collect_memory = true
collect_disk = true
collect_network = true
[watchdog]
enabled = true
check_interval_seconds = 30
[[watchdog.services]]
name = "ServiceName"
action = "restart"
max_restarts = 3
restart_cooldown_seconds = 60
```
### Hardcoded Intervals
These values are currently not configurable via `agent.toml`:
| Interval | Value | Notes |
|----------------------------|-------------|--------------------------------|
| Heartbeat | 30 seconds | |
| Network state check | 30 seconds | Uses hash-based change detection |
| Connection idle timeout | 90 seconds | |
| Auth timeout | 10 seconds | |
| Reconnect delay | 10 seconds | |
| Command execution timeout | 300 seconds | Configurable per command |
---
## Deployed Agents
| Hostname | Agent ID (prefix) | Version | OS | Status |
|-------------|--------------------|---------|-----------------------------|---------|
| ACG-M-L5090 | 97f63c3b-... | 0.5.1 | Windows 11 (26200) | online |
| AD2 | d28a1c90-... | 0.5.1 | Windows Server 2016 (14393) | online |
| gururmm | 8cd0440f-... | 0.5.1 | Ubuntu 22.04 | offline |
| SL-SERVER | 2585f6d5-... | 0.5.1 | unknown | offline |
| SL-SERVER | dff818e6-... | 0.5.1 | unknown | online |
---
## Database
### Connection Details
| Parameter | Value |
|-----------|------------------------------------|
| Host | 172.16.3.30 |
| Port | 5432 |
| Database | gururmm |
| User | gururmm |
| Password | 43617ebf7eb242e814ca9988cc4df5ad |
### Key Tables
| Table | Description |
|---------------------|------------------------------------------------|
| users | User accounts (JWT auth, Argon2id hashing) |
| clients | Client organizations |
| sites | Physical locations with API keys |
| agents | RMM agent instances |
| agent_state | Latest agent state (network, metrics snapshot) |
| agent_updates | Agent update tracking |
| alerts | System alerts |
| commands | Remote command execution log |
| metrics | Performance metrics time series |
| policies | Configuration policies |
| registration_tokens | Agent registration tokens |
| watchdog_events | Service monitoring events |
---
## Authentication
### API Authentication (JWT)
1. Send `POST /api/auth/login` with `{ email, password }`
2. Server validates credentials (Argon2id password hash)
3. Returns JWT token (24-hour expiry)
4. Include token in subsequent requests: `Authorization: Bearer <token>`
**Admin Credentials:**
| Field | Value |
|----------|------------------------------------|
| Email | claude-api@azcomputerguru.com |
| Password | ClaudeAPI2026!@# |
### Agent Authentication (API Key)
Two authentication modes:
1. **Direct API Key** -- Agent sends `grmm_xxxxx` format key, server matches against `api_key_hash` (SHA256) in agents table
2. **Site-Based** -- Agent sends site code (WORD-WORD-NUMBER format, e.g., `DARK-GROVE-7839`) combined with `device_id`, server looks up site and registers/matches agent
### SSO (Optional)
- **Provider:** Microsoft Entra ID
- **Client ID:** 18a15f5d-7ab8-46f4-8566-d7b5436b84b6
---
## Auto-Update System
### Update Flow
```
1. Agent connects via WebSocket and sends its version in the auth payload
2. Server checks if a newer version is available for the agent's OS/architecture
3. If update needed:
-> Server sends Update message with download_url and SHA256 checksum
4. Agent downloads the new binary from the download URL
5. Agent verifies the SHA256 checksum
6. Agent replaces its own binary and restarts
7. On reconnection, agent reports previous_version in auth payload
8. Server marks the update as completed
```
### Download Location
- **Server Path:** `/var/www/gururmm/downloads/`
- **Public URL:** `https://rmm-api.azcomputerguru.com/downloads/`
---
## Known Issues
### CRITICAL
| ID | Type | Description |
|----|------------|--------------------------------------------------------------------------------------------|
| 1 | [BUG] | Command type mismatch between server (String) and agent (Enum) -- commands never execute |
| 2 | [TODO] | Legacy heartbeat returns empty pending_commands (agents.rs line 334) |
| 3 | [TODO] | Legacy command-result endpoint does not store results (agents.rs lines 354-360) |
| 4 | [SECURITY] | CORS configured with AllowOrigin::Any -- should be restricted to known origins |
### MAJOR
| ID | Description |
|----|--------------------------------------------------------------------------------|
| 1 | No command timeout enforcement on server side |
| 2 | No retry logic for failed WebSocket sends |
| 3 | Database inconsistency: agent shows "online" but command sends fail silently |
| 4 | Missing database indexes on frequently queried columns |
| 5 | No rate limiting on command submissions |
### MINOR
| ID | Description |
|----|--------------------------------------------------------------------------|
| 1 | Hardcoded intervals (heartbeat, network check) not configurable |
| 2 | Watchdog events logged but not stored in database |
| 3 | No log rotation configured |
| 4 | Unicode characters in agent output (should use ASCII per coding guidelines) |
---
## Development
### Building
```bash
# Server
cd server && cargo build --release
# Agent (Windows, native service mode)
cd agent && cargo build --release
# Agent (Legacy mode for Windows 7 / Server 2008 R2)
cd agent && cargo build --release --features legacy --no-default-features
```
### Testing
```bash
cargo test # Run unit tests
cargo clippy # Run linter
cargo fmt --check # Check formatting
```
### Deploying the Server
```bash
# On gururmm server (172.16.3.30)
systemctl stop gururmm-server
cp target/release/gururmm-server /opt/gururmm/
systemctl start gururmm-server
journalctl -u gururmm-server -f
```
### Deploying the Agent
```cmd
REM On target Windows machine
sc stop GuruRMM
copy gururmm-agent.exe "C:\Program Files\GuruRMM\gururmm-agent.exe"
sc start GuruRMM
```
---
## File Structure
```
D:\ClaudeTools\projects\msp-tools\guru-rmm\
|
+-- agent/
| +-- src/
| | +-- main.rs # Entry point, CLI parsing, service install
| | +-- config.rs # TOML config loading and validation
| | +-- claude.rs # Claude Code executor (rate-limited singleton)
| | +-- service.rs # Windows service handler (native-service feature)
| | +-- device_id.rs # Hardware-based device ID generation
| | +-- transport/
| | | +-- mod.rs # Message types (AgentMessage, ServerMessage, CommandType enum)
| | | +-- websocket.rs # WebSocket client, reconnection, command execution
| | +-- metrics/
| | | +-- mod.rs # System metrics collection, network state hashing
| | +-- updater/
| | +-- mod.rs # Self-update logic (download, verify, replace)
| +-- deploy/ # Deployment configs per site
| +-- Cargo.toml # v0.3.5, features: native-service, legacy
|
+-- server/
| +-- src/
| | +-- main.rs # Axum server setup, router, middleware
| | +-- api/
| | | +-- mod.rs # Route definitions and grouping
| | | +-- agents.rs # Agent management + legacy polling endpoints
| | | +-- commands.rs # Command dispatch and status tracking
| | | +-- auth.rs # JWT login, registration, user info
| | | +-- clients.rs # Client CRUD operations
| | | +-- sites.rs # Site management and API key regeneration
| | | +-- metrics.rs # Metrics query endpoints
| | +-- ws/
| | | +-- mod.rs # WebSocket handler, ServerMessage types, CommandPayload (String type)
| | +-- db/
| | | +-- agents.rs # Agent database operations
| | | +-- commands.rs # Command database operations
| | +-- auth/
| | +-- mod.rs # JWT middleware and token validation
| +-- Cargo.toml # v0.2.0
|
+-- dashboard/ # React frontend (if present)
|
+-- docs/
+-- FEATURE_ROADMAP.md # Complete feature plan (654 lines)
+-- REMEDIATION_PLAN.md # Security and code review (1277 lines)
```
---
## Quick Reference
| Item | Value |
|--------------------|---------------------------------------------|
| Server URL | https://rmm-api.azcomputerguru.com |
| WebSocket URL | wss://rmm-api.azcomputerguru.com/ws |
| Internal Address | 172.16.3.30:3001 |
| Database | PostgreSQL @ 172.16.3.30:5432/gururmm |
| Service Name | gururmm-server (systemd) |
| Agent Service | GuruRMM (Windows SCM) |
| Agent Config | C:\ProgramData\GuruRMM\agent.toml |
| Agent Binary | C:\Program Files\GuruRMM\gururmm-agent.exe |
| Downloads | https://rmm-api.azcomputerguru.com/downloads/ |
| Admin Email | claude-api@azcomputerguru.com |
| SSO Client ID | 18a15f5d-7ab8-46f4-8566-d7b5436b84b6 |
---
*Document generated 2026-02-17. Source of truth for GuruRMM project reference.*

View File

@@ -1,6 +1,6 @@
[package]
name = "gururmm-agent"
version = "0.3.5"
version = "0.6.0"
edition = "2021"
description = "GuruRMM Agent - Cross-platform RMM agent"
authors = ["GuruRMM"]

View File

@@ -199,6 +199,9 @@ pub enum CommandType {
Shell,
/// PowerShell command (Windows)
/// Alias "powershell" for backwards compatibility with servers that send
/// the command type as a plain string instead of snake_case enum format.
#[serde(alias = "powershell")]
PowerShell,
/// Python script
@@ -208,6 +211,7 @@ pub enum CommandType {
Script { interpreter: String },
/// Claude Code task execution
#[serde(alias = "claude_task")]
ClaudeTask {
/// Task description for Claude Code
task: String,

View File

@@ -0,0 +1,67 @@
import requests, json, time, sys
with open("/tmp/agent_b64.txt") as f:
b64 = f.read().strip()
print("Base64 length:", len(b64))
token_r = requests.post("http://localhost:3001/api/auth/login", json={"email": "claude-api@azcomputerguru.com", "password": "ClaudeAPI2026!@#"})
token = token_r.json()["token"]
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
agent_id = "d28a1c90-47d7-448f-a287-197bc8892234"
chunk_size = 200000
chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]
print("Chunks:", len(chunks))
def send_cmd(cmd, timeout=60, wait=15):
r = requests.post(
"http://localhost:3001/api/agents/" + agent_id + "/command",
headers=headers,
json={"command_type": "powershell", "command": cmd, "timeout_seconds": timeout}
)
cmd_id = r.json()["command_id"]
time.sleep(wait)
r2 = requests.get("http://localhost:3001/api/commands/" + cmd_id, headers=headers)
return r2.json()
# Chunk 1: create file
cmd = "$b = [Convert]::FromBase64String('" + chunks[0] + "'); New-Item -ItemType Directory -Path C:/Temp -Force | Out-Null; [System.IO.File]::WriteAllBytes('C:/Temp/agent_chunk.bin', $b); Write-Output ('Chunk 1: ' + $b.Length.ToString() + ' bytes')"
d = send_cmd(cmd)
print("Chunk 1:", d["status"], d.get("stdout",""))
if d["status"] != "completed":
print("ERROR:", (d.get("stderr","") or "")[:300])
sys.exit(1)
# Append remaining chunks
for i, chunk in enumerate(chunks[1:], 2):
cmd = "$b = [Convert]::FromBase64String('" + chunk + "'); $f = [System.IO.File]::Open('C:/Temp/agent_chunk.bin', [System.IO.FileMode]::Append); $f.Write($b, 0, $b.Length); $f.Close(); Write-Output ('Chunk " + str(i) + ": ' + $b.Length.ToString() + ' bytes')"
d = send_cmd(cmd, wait=10)
print("Chunk", i, ":", d["status"], d.get("stdout",""))
if d["status"] != "completed":
print("ERROR:", (d.get("stderr","") or "")[:300])
sys.exit(1)
# Verify final size
d = send_cmd("(Get-Item C:/Temp/agent_chunk.bin).Length", wait=5)
print("Final size:", d.get("stdout","").strip(), "(expected 3577856)")
# Now create update script that stops service, replaces binary, starts service
update_cmd = """
$src = 'C:/Temp/agent_chunk.bin'
$dst = 'C:/Program Files/GuruRMM/gururmm-agent.exe'
$bak = 'C:/Program Files/GuruRMM/gururmm-agent.exe.bak'
Copy-Item $dst $bak -Force
Stop-Service GuruRMMAgent -Force
Start-Sleep -Seconds 2
Copy-Item $src $dst -Force
Start-Service GuruRMMAgent
Write-Output 'Agent updated and restarted'
"""
print("\nNow creating scheduled task to perform the update...")
sched_cmd = 'schtasks /create /tn AgentUpdate /tr "powershell.exe -ExecutionPolicy Bypass -Command \\"' + update_cmd.replace('\n', '; ').strip() + '\\"" /sc ONCE /st 00:00 /sd 01/01/2030 /ru SYSTEM /f'
d = send_cmd(sched_cmd, wait=5)
print("Sched task create:", d["status"], d.get("stdout",""), d.get("stderr","")[:200] if d.get("stderr") else "")
d = send_cmd("schtasks /run /tn AgentUpdate", wait=5)
print("Sched task run:", d["status"], d.get("stdout",""))
print("\nAgent will restart momentarily. Wait 30s then check connection.")

View File

@@ -0,0 +1,88 @@
"""Deploy agent binary to AD2 by writing base64 text chunks to a file, then decoding."""
import requests, json, time, sys, base64
# Read and encode the binary
with open("agent/target/release/gururmm-agent.exe", "rb") as f:
binary = f.read()
b64 = base64.b64encode(binary).decode('ascii')
print(f"Binary: {len(binary)} bytes, Base64: {len(b64)} chars")
# Auth
token_r = requests.post('http://172.16.3.30:3001/api/auth/login', json={
'email': 'claude-api@azcomputerguru.com',
'password': 'ClaudeAPI2026!@#'
})
token = token_r.json()['token']
headers = {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}
agent_id = 'd28a1c90-47d7-448f-a287-197bc8892234'
def send_cmd(cmd, timeout=60, wait=10):
r = requests.post(
'http://172.16.3.30:3001/api/agents/' + agent_id + '/command',
headers=headers,
json={'command_type': 'powershell', 'command': cmd, 'timeout_seconds': timeout}
)
data = r.json()
cmd_id = data['command_id']
time.sleep(wait)
# Poll until complete
for attempt in range(10):
r2 = requests.get('http://172.16.3.30:3001/api/commands/' + cmd_id, headers=headers)
d = r2.json()
if d['status'] != 'running':
return d
time.sleep(5)
return d
# Step 1: Delete old file and create fresh one
print("Step 1: Preparing temp file...")
d = send_cmd("Remove-Item C:/Temp/agent.b64 -Force -ErrorAction SilentlyContinue; "
"New-Item -ItemType Directory -Path C:/Temp -Force | Out-Null; "
"'' | Set-Content C:/Temp/agent.b64 -NoNewline; "
"Write-Output 'Ready'", wait=8)
print(f" {d['status']}: {d.get('stdout','').strip()}")
if d['status'] != 'completed':
print(f" ERROR: {(d.get('stderr','') or '')[:300]}")
sys.exit(1)
# Step 2: Write base64 text in chunks
# Windows command line limit is ~32KB, keep chunks under 20KB to be safe
chunk_size = 20000
chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]
print(f"Step 2: Writing {len(chunks)} chunks of ~{chunk_size} chars each...")
for i, chunk in enumerate(chunks):
# Use Add-Content to append text (no base64 decode here, just text)
# Escape single quotes in base64 (shouldn't have any, but just in case)
safe_chunk = chunk.replace("'", "''")
cmd = f"Add-Content -Path C:/Temp/agent.b64 -Value '{safe_chunk}' -NoNewline; Write-Output 'chunk{i+1}ok'"
d = send_cmd(cmd, wait=5)
status = d['status']
stdout = d.get('stdout', '').strip()
if status != 'completed' or f'chunk{i+1}ok' not in stdout:
print(f" Chunk {i+1}/{len(chunks)} FAILED: {status} - {stdout}")
print(f" stderr: {(d.get('stderr','') or '')[:300]}")
sys.exit(1)
if (i+1) % 10 == 0 or i == 0 or i == len(chunks)-1:
print(f" Chunk {i+1}/{len(chunks)}: OK")
# Step 3: Verify base64 file size
print("Step 3: Verifying base64 file...")
d = send_cmd(f"$f = Get-Item C:/Temp/agent.b64; Write-Output $f.Length", wait=5)
remote_size = d.get('stdout', '').strip()
print(f" Remote b64 size: {remote_size} (expected: {len(b64)})")
# Step 4: Decode base64 file to binary
print("Step 4: Decoding base64 to binary...")
cmd = ("$b64 = Get-Content C:/Temp/agent.b64 -Raw; "
"$bytes = [Convert]::FromBase64String($b64); "
"[System.IO.File]::WriteAllBytes('C:/Temp/gururmm-agent-new.exe', $bytes); "
"$f = Get-Item C:/Temp/gururmm-agent-new.exe; "
"Write-Output ('Decoded: ' + $f.Length.ToString() + ' bytes')")
d = send_cmd(cmd, timeout=120, wait=15)
print(f" {d['status']}: {d.get('stdout','').strip()}")
if d.get('stderr'):
print(f" stderr: {(d.get('stderr','') or '')[:300]}")
print(f"\nExpected binary size: {len(binary)} bytes")
print("Done!")

View File

@@ -0,0 +1,35 @@
import requests, json, time, sys
# Auth
token_r = requests.post('http://172.16.3.30:3001/api/auth/login', json={
'email': 'claude-api@azcomputerguru.com',
'password': 'ClaudeAPI2026!@#'
})
token = token_r.json()['token']
headers = {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}
agent_id = 'd28a1c90-47d7-448f-a287-197bc8892234'
# Send download command via PowerShell
cmd = (
"New-Item -ItemType Directory -Path C:/Temp -Force | Out-Null; "
"Invoke-WebRequest -Uri 'http://172.16.3.30/gururmm-agent-new.exe' "
"-OutFile 'C:/Temp/gururmm-agent-new.exe' -UseBasicParsing; "
"$f = Get-Item 'C:/Temp/gururmm-agent-new.exe'; "
"Write-Output ('Downloaded: ' + $f.Length.ToString() + ' bytes')"
)
r = requests.post(
'http://172.16.3.30:3001/api/agents/' + agent_id + '/command',
headers=headers,
json={'command_type': 'powershell', 'command': cmd, 'timeout_seconds': 120}
)
print('Send:', r.json())
cmd_id = r.json()['command_id']
# Wait and check
time.sleep(20)
r2 = requests.get('http://172.16.3.30:3001/api/commands/' + cmd_id, headers=headers)
d = r2.json()
print('Status:', d['status'])
print('stdout:', d.get('stdout', ''))
print('stderr:', (d.get('stderr', '') or '')[:500])

View File

@@ -0,0 +1,46 @@
import requests, json, time, sys
# Auth
token_r = requests.post('http://172.16.3.30:3001/api/auth/login', json={
'email': 'claude-api@azcomputerguru.com',
'password': 'ClaudeAPI2026!@#'
})
token = token_r.json()['token']
headers = {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}
agent_id = 'd28a1c90-47d7-448f-a287-197bc8892234'
def send_cmd(cmd, timeout=120, wait=20):
r = requests.post(
'http://172.16.3.30:3001/api/agents/' + agent_id + '/command',
headers=headers,
json={'command_type': 'powershell', 'command': cmd, 'timeout_seconds': timeout}
)
data = r.json()
print('Sent:', data.get('status', 'error'), data.get('message', ''))
cmd_id = data['command_id']
time.sleep(wait)
r2 = requests.get('http://172.16.3.30:3001/api/commands/' + cmd_id, headers=headers)
d = r2.json()
print('Status:', d['status'])
print('stdout:', d.get('stdout', ''))
stderr = d.get('stderr', '') or ''
if stderr:
print('stderr:', stderr[:500])
print('exit_code:', d.get('exit_code'))
return d
# First, check what transfer tools are available on AD2
print("=== Checking available tools ===")
d = send_cmd("Get-Command scp,ssh,curl -ErrorAction SilentlyContinue | Select-Object Name,Source | Format-Table -AutoSize", wait=10)
print("\n=== Trying SCP from AD2 to RMM server ===")
# Use scp with StrictHostKeyChecking=no for automated transfer
cmd = (
"scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL "
"guru@172.16.3.30:/tmp/gururmm-agent-new.exe C:/Temp/gururmm-agent-new.exe 2>&1; "
"if (Test-Path C:/Temp/gururmm-agent-new.exe) { "
" $f = Get-Item C:/Temp/gururmm-agent-new.exe; "
" Write-Output ('File size: ' + $f.Length.ToString() + ' bytes') "
"} else { Write-Output 'File not found after SCP' }"
)
d = send_cmd(cmd, wait=30)

View File

@@ -10,16 +10,16 @@ use uuid::Uuid;
use crate::auth::AuthUser;
use crate::db::{self, Command};
use crate::ws::{CommandPayload, ServerMessage};
use crate::ws::{CommandPayload, CommandType, ServerMessage};
use crate::AppState;
/// Request to send a command to an agent
#[derive(Debug, Deserialize)]
pub struct SendCommandRequest {
/// Command type (shell, powershell, python, script)
/// Command type (shell, powershell, python, script, claude_task)
pub command_type: String,
/// Command text to execute
/// Command text to execute (also used as task description for claude_task)
pub command: String,
/// Timeout in seconds (optional, default 300)
@@ -27,6 +27,12 @@ pub struct SendCommandRequest {
/// Run as elevated/admin (optional, default false)
pub elevated: Option<bool>,
/// Working directory for claude_task (optional, default C:\Shares\test)
pub working_directory: Option<String>,
/// Context files for claude_task (optional)
pub context_files: Option<Vec<String>>,
}
/// Response after sending a command
@@ -59,6 +65,18 @@ pub async fn send_command(
"Command sent by user"
);
// Validate and build command type
let command_type = if CommandType::is_claude_task(&req.command_type) {
CommandType::new_claude_task(
req.command.clone(),
req.working_directory.clone(),
req.context_files.clone(),
)
} else {
CommandType::from_api_string(&req.command_type)
.map_err(|e| (StatusCode::BAD_REQUEST, e))?
};
// Verify agent exists
let _agent = db::get_agent_by_id(&state.db, agent_id)
.await
@@ -66,9 +84,10 @@ pub async fn send_command(
.ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
// Create command record with user ID for audit trail
// Store the canonical db string format for consistency
let create = db::CreateCommand {
agent_id,
command_type: req.command_type.clone(),
command_type: command_type.as_db_string().to_string(),
command_text: req.command.clone(),
created_by: Some(user.user_id),
};
@@ -80,10 +99,11 @@ pub async fn send_command(
// Check if agent is connected
let agents = state.agents.read().await;
if agents.is_connected(&agent_id) {
// Send command via WebSocket
// Send command via WebSocket using the proper enum type
// This serializes as snake_case to match the agent's expected format
let cmd_msg = ServerMessage::Command(CommandPayload {
id: command.id,
command_type: req.command_type,
command_type,
command: req.command,
timeout_seconds: req.timeout_seconds,
elevated: req.elevated.unwrap_or(false),

View File

@@ -193,10 +193,74 @@ pub struct NetworkStatePayload {
pub state_hash: String,
}
/// Types of commands that can be sent to agents.
/// Must match the agent's CommandType enum serialization format.
/// Uses snake_case to match the agent's #[serde(rename_all = "snake_case")].
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandType {
/// Shell command (cmd on Windows, sh on Unix)
Shell,
/// PowerShell command (Windows)
PowerShell,
/// Python script
Python,
/// Raw script execution
Script,
/// Claude Code task execution
ClaudeTask {
/// Task description for Claude Code
task: String,
/// Optional working directory
working_directory: Option<String>,
/// Optional context files
context_files: Option<Vec<String>>,
},
}
impl CommandType {
/// Parse a command type string from the API into the enum.
/// Accepts both snake_case ("power_shell") and common formats ("powershell").
/// Note: ClaudeTask requires additional fields - use `new_claude_task()` instead.
pub fn from_api_string(s: &str) -> Result<Self, String> {
match s.to_lowercase().as_str() {
"shell" => Ok(Self::Shell),
"powershell" | "power_shell" => Ok(Self::PowerShell),
"python" => Ok(Self::Python),
"script" => Ok(Self::Script),
"claude_task" | "claudetask" => Err(
"claude_task type requires task field - use the claude_task-specific API fields".to_string()
),
_ => Err(format!("Unknown command type: '{}'. Valid types: shell, powershell, python, script, claude_task", s)),
}
}
/// Check if a command type string represents a claude_task.
pub fn is_claude_task(s: &str) -> bool {
matches!(s.to_lowercase().as_str(), "claude_task" | "claudetask")
}
/// Create a ClaudeTask command type with the required fields.
pub fn new_claude_task(task: String, working_directory: Option<String>, context_files: Option<Vec<String>>) -> Self {
Self::ClaudeTask { task, working_directory, context_files }
}
/// Convert back to the string format stored in the database.
pub fn as_db_string(&self) -> &'static str {
match self {
Self::Shell => "shell",
Self::PowerShell => "powershell",
Self::Python => "python",
Self::Script => "script",
Self::ClaudeTask { .. } => "claude_task",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandPayload {
pub id: Uuid,
pub command_type: String,
pub command_type: CommandType,
pub command: String,
pub timeout_seconds: Option<u64>,
pub elevated: bool,

View File

@@ -0,0 +1,80 @@
"""Create scheduled task on AD2 to swap agent binary and restart service."""
import requests, time
# Auth
token_r = requests.post('http://172.16.3.30:3001/api/auth/login', json={
'email': 'claude-api@azcomputerguru.com',
'password': 'ClaudeAPI2026!@#'
})
token = token_r.json()['token']
headers = {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}
agent_id = 'd28a1c90-47d7-448f-a287-197bc8892234'
# Create a scheduled task to swap the binary and restart
# Uses schtasks for simplicity - runs a PowerShell script that:
# 1. Stops the agent service
# 2. Backs up old binary
# 3. Copies new binary in place
# 4. Starts the service
# 5. Writes result to file
cmd = (
"$script = @'\n"
"Stop-Service GuruRMMAgent -Force\n"
"Start-Sleep -Seconds 3\n"
"Copy-Item \"C:\\Program Files\\GuruRMM\\gururmm-agent.exe\" \"C:\\Program Files\\GuruRMM\\gururmm-agent.exe.bak\" -Force\n"
"Copy-Item \"C:\\Temp\\gururmm-agent-new.exe\" \"C:\\Program Files\\GuruRMM\\gururmm-agent.exe\" -Force\n"
"Start-Sleep -Seconds 1\n"
"Start-Service GuruRMMAgent\n"
"\"Agent updated at $(Get-Date)\" | Out-File C:\\Temp\\update_result.txt\n"
"'@\n"
"$script | Out-File C:/Temp/update_agent.ps1 -Encoding UTF8\n"
"Write-Output ('Script written: ' + (Get-Item C:/Temp/update_agent.ps1).Length.ToString() + ' bytes')"
)
print("Step 1: Writing update script to AD2...")
r = requests.post(
'http://172.16.3.30:3001/api/agents/' + agent_id + '/command',
headers=headers,
json={'command_type': 'powershell', 'command': cmd, 'timeout_seconds': 30}
)
print('Send:', r.json())
cmd_id = r.json()['command_id']
time.sleep(10)
r2 = requests.get('http://172.16.3.30:3001/api/commands/' + cmd_id, headers=headers)
d = r2.json()
print('Status:', d['status'])
print('stdout:', d.get('stdout', ''))
if d.get('stderr'):
print('stderr:', (d.get('stderr', '') or '')[:300])
if d['status'] != 'completed':
print("FAILED to write script")
exit(1)
# Step 2: Create and run scheduled task
print("\nStep 2: Creating scheduled task to run update...")
cmd2 = (
"schtasks /create /tn AgentBinaryUpdate /tr "
"\"powershell.exe -ExecutionPolicy Bypass -File C:\\Temp\\update_agent.ps1\" "
"/sc ONCE /st 00:00 /sd 01/01/2030 /ru SYSTEM /rl HIGHEST /f; "
"schtasks /run /tn AgentBinaryUpdate; "
"Write-Output 'Update task started - agent will restart momentarily'"
)
r = requests.post(
'http://172.16.3.30:3001/api/agents/' + agent_id + '/command',
headers=headers,
json={'command_type': 'powershell', 'command': cmd2, 'timeout_seconds': 30}
)
print('Send:', r.json())
cmd_id = r.json()['command_id']
time.sleep(10)
r2 = requests.get('http://172.16.3.30:3001/api/commands/' + cmd_id, headers=headers)
d = r2.json()
print('Status:', d['status'])
print('stdout:', d.get('stdout', ''))
if d.get('stderr'):
print('stderr:', (d.get('stderr', '') or '')[:300])
print("\nAgent will stop, binary will be swapped, then service restarts.")
print("Wait ~30 seconds then check if agent reconnects.")

Submodule projects/solverbot updated: 342fe0fdf4...60b2617651

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

20
tmp_backup_ad2.ps1 Normal file
View File

@@ -0,0 +1,20 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
Write-Output "=== DRIVE MAPPED ==="
# Step 3: Back up the current script
$src = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
$bak = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1.bak.2026-02-17"
Copy-Item -Path $src -Destination $bak -Force
Write-Output "=== BACKUP CREATED: $bak ==="
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
}

56
tmp_bulksync.ps1 Normal file
View File

@@ -0,0 +1,56 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
# Write a one-time bulk sync runner script
$bulkScript = @"
# One-time bulk sync with 86400 minute window (60 days)
# This catches all stranded files from the last ~35 days
# Auto-deletes itself after running
Set-Location "C:\Shares\test\scripts"
& powershell -NoProfile -ExecutionPolicy Bypass -File "C:\Shares\test\scripts\Sync-FromNAS.ps1" -MaxAgeMinutes 86400 -Verbose
# Log completion
Add-Content -Path "C:\Shares\test\scripts\sync-from-nas.log" -Value "$((Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) : BULK SYNC COMPLETE - one-time catchup finished"
# Clean up this trigger script
Remove-Item -Path "C:\Shares\test\scripts\BulkSync-OneTime.ps1" -Force -ErrorAction SilentlyContinue
"@
Set-Content -Path "AD2:\Shares\test\scripts\BulkSync-OneTime.ps1" -Value $bulkScript
Write-Output "[OK] Bulk sync script written to AD2:\Shares\test\scripts\BulkSync-OneTime.ps1"
# Now create a scheduled task on AD2 to run it immediately
# We can do this by writing a schtasks command file
$taskScript = @"
schtasks /create /tn "BulkSync-OneTime" /tr "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Shares\test\scripts\BulkSync-OneTime.ps1" /sc once /st 00:00 /ru INTRANET\sysadmin /rp "Paper123\!@#" /f
schtasks /run /tn "BulkSync-OneTime"
"@
Set-Content -Path "AD2:\Shares\test\scripts\run-bulk.cmd" -Value $taskScript
Write-Output "[OK] Task creation script written"
Write-Output ""
Write-Output "NOTE: The scheduled task needs to be triggered on AD2."
Write-Output "Attempting to use WMI to create process on AD2..."
# Try WMI process creation (may work even if WinRM doesnt)
try {
$proc = Invoke-WmiMethod -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Name Create -ArgumentList "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Shares\test\scripts\BulkSync-OneTime.ps1"
if ($proc.ReturnValue -eq 0) {
Write-Output "[OK] WMI process started on AD2\! PID: $($proc.ProcessId)"
} else {
Write-Output "[ERROR] WMI process creation failed with return value: $($proc.ReturnValue)"
}
} catch {
Write-Output "[ERROR] WMI failed: $_"
Write-Output "Trying schtasks approach..."
try {
$schResult = schtasks /create /s 192.168.0.6 /u INTRANET\sysadmin /p "Paper123\!@#" /tn "BulkSync-OneTime" /tr "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Shares\test\scripts\BulkSync-OneTime.ps1" /sc once /st 00:00 /ru INTRANET\sysadmin /rp "Paper123\!@#" /f 2>&1
Write-Output "schtasks create: $schResult"
$runResult = schtasks /run /s 192.168.0.6 /u INTRANET\sysadmin /p "Paper123\!@#" /tn "BulkSync-OneTime" 2>&1
Write-Output "schtasks run: $runResult"
} catch {
Write-Output "[ERROR] schtasks also failed: $_"
}
}
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue

29
tmp_bulksync2.ps1 Normal file
View File

@@ -0,0 +1,29 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncPath = "\\192.168.0.6\C$\Shares\test\scripts"
# Map net use
net use "\\192.168.0.6\C$" /user:INTRANET\sysadmin ("Paper123" + [char]33 + "@#") 2>&1 | Out-Null
# Write the bulk sync script using direct UNC path
$bulkContent = @"
Set-Location "C:\Shares\test\scripts"
& powershell -NoProfile -ExecutionPolicy Bypass -File "C:\Shares\test\scripts\Sync-FromNAS.ps1" -MaxAgeMinutes 86400 -Verbose
"@
[System.IO.File]::WriteAllText("\\192.168.0.6\C$\Shares\test\scripts\BulkSync-OneTime.ps1", $bulkContent)
Write-Output "[OK] BulkSync script written via System.IO"
# Verify it exists
$exists = Test-Path "\\192.168.0.6\C$\Shares\test\scripts\BulkSync-OneTime.ps1"
Write-Output "File exists: $exists"
# Now trigger it via WMI
$proc = Invoke-WmiMethod -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Name Create -ArgumentList "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Shares\test\scripts\BulkSync-OneTime.ps1"
if ($proc.ReturnValue -eq 0) {
Write-Output "[OK] Bulk sync started on AD2\! PID: $($proc.ProcessId)"
} else {
Write-Output "[ERROR] Failed to start: return value $($proc.ReturnValue)"
}
net use "\\192.168.0.6\C$" /delete 2>&1 | Out-Null

13
tmp_check2.ps1 Normal file
View File

@@ -0,0 +1,13 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
# Check if still running
$procs = Get-WmiObject -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Filter "Name='powershell.exe'" 2>$null
$syncRunning = $false
foreach ($p in $procs) {
if ($p.CommandLine -match "BulkSync|86400") {
Write-Output "Still running: PID=$($p.ProcessId)"
$syncRunning = $true
}
}
if (-not $syncRunning) { Write-Output "Bulk sync has completed\!" }

18
tmp_check3.ps1 Normal file
View File

@@ -0,0 +1,18 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
# Read the last 40 lines of the sync log
$logContent = [System.IO.File]::ReadAllText("\\192.168.0.6\C$\Shares\test\scripts\sync-from-nas.log")
$lines = $logContent -split "`n"
$lastLines = $lines | Select-Object -Last 40
Write-Output "=== LAST 40 LOG LINES ==="
$lastLines | ForEach-Object { Write-Output $_ }
# Check process status
$procs = Get-WmiObject -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Filter "Name='powershell.exe'" 2>$null
$syncRunning = $false
foreach ($p in $procs) {
if ($p.CommandLine -match "BulkSync|86400") { $syncRunning = $true }
}
Write-Output ""
if ($syncRunning) { Write-Output "STATUS: Still running..." } else { Write-Output "STATUS: COMPLETED" }

18
tmp_check4.ps1 Normal file
View File

@@ -0,0 +1,18 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
# Read the last 50 lines of the sync log
$logContent = [System.IO.File]::ReadAllText("\\192.168.0.6\C$\Shares\test\scripts\sync-from-nas.log")
$lines = $logContent -split "`n"
$lastLines = $lines | Select-Object -Last 50
Write-Output "=== LAST 50 LOG LINES ==="
$lastLines | ForEach-Object { Write-Output $_ }
# Check process status
$procs = Get-WmiObject -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Filter "Name='powershell.exe'" 2>$null
$syncRunning = $false
foreach ($p in $procs) {
if ($p.CommandLine -match "BulkSync|86400") { $syncRunning = $true }
}
Write-Output ""
if ($syncRunning) { Write-Output "[...] STATUS: Still running..." } else { Write-Output "[OK] STATUS: COMPLETED" }

18
tmp_check_proc.ps1 Normal file
View File

@@ -0,0 +1,18 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
# Check if the process is still running
$proc = Get-WmiObject -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Filter "ProcessId=15856" 2>$null
if ($proc) {
Write-Output "Process 15856 still running: $($proc.CommandLine)"
} else {
Write-Output "Process 15856 has completed"
}
# Check for any powershell processes running our script
$procs = Get-WmiObject -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Filter "Name='powershell.exe'" 2>$null
foreach ($p in $procs) {
if ($p.CommandLine -match "Sync-FromNAS|BulkSync") {
Write-Output "Found sync process: PID=$($p.ProcessId) CMD=$($p.CommandLine)"
}
}

21
tmp_connect_ad2.ps1 Normal file
View File

@@ -0,0 +1,21 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop
Write-Output "=== DRIVE MAPPED ==="
$scriptPath = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
if (Test-Path $scriptPath) {
Write-Output "=== SCRIPT CONTENT START ==="
Get-Content $scriptPath -Raw
Write-Output "=== SCRIPT CONTENT END ==="
} else {
Write-Output "Script not found"
}
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
}

37
tmp_fix2.ps1 Normal file
View File

@@ -0,0 +1,37 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
$scriptPath = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
$lines = Get-Content $scriptPath
# Fix line 70 (0-indexed: 69) - the Copy-FromNAS SCP line
$oldLine = $lines[69]
Write-Output "OLD LINE 70: $oldLine"
# The correct line should have backtick-quoted remote path and quoted local path
$lines[69] = ' $result = & $SCP -O -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" "${NAS_USER}@${NAS_IP}:`"${RemotePath}`"" "$LocalPath" 2>&1'
Write-Output "NEW LINE 70: $($lines[69])"
# Write back
Set-Content -Path $scriptPath -Value $lines
Write-Output "=== LINE 70 FIXED ==="
# Verify both SCP lines are now correct
$verify = Get-Content $scriptPath
Write-Output "=== FINAL SCP LINES ==="
for ($i = 0; $i -lt $verify.Count; $i++) {
if ($verify[$i] -match "result.*SCP") {
Write-Output ("{0,3}: {1}" -f ($i+1), $verify[$i].TrimEnd())
}
}
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
Write-Output $_.ScriptStackTrace
}

58
tmp_fix_script.ps1 Normal file
View File

@@ -0,0 +1,58 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
Write-Output "=== DRIVE MAPPED ==="
$scriptPath = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
$content = Get-Content $scriptPath -Raw
# Fix 1: Invoke-NASCommand - add user@host target
$old1 = '$result = & $SSH -i "C:\Users\sysadmin\.ssh\id_ed25519" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new $Command 2>&1'
$new1 = '$result = & $SSH -i "C:\Users\sysadmin\.ssh\id_ed25519" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${NAS_USER}@${NAS_IP}" $Command 2>&1'
$content = $content.Replace($old1, $new1)
# Fix 2: Copy-FromNAS - fix the SCP line (it has the if statement on same line + unquoted paths)
$old2 = '$result = & $SCP -O -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" "${NAS_USER}@${NAS_IP}:$RemotePath" $LocalPath 2>&1 if ($LASTEXITCODE -ne 0) {'
$new2 = @"
`$result = & `$SCP -O -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" "`${NAS_USER}@`${NAS_IP}:`"`${RemotePath}`"" "`$LocalPath" 2>&1
if (`$LASTEXITCODE -ne 0) {
"@
$content = $content.Replace($old2, $new2)
# Fix 3: Fix the error message in Copy-FromNAS (PUSH -> PULL)
$old3 = 'Write-Log " SCP PUSH ERROR (exit $LASTEXITCODE): $errorMsg"'
# Only fix the FIRST occurrence (in Copy-FromNAS). Use a targeted approach.
# Since we already fixed the structure, just do a simple replace of the first occurrence
$idx = $content.IndexOf($old3)
if ($idx -ge 0) {
$new3 = 'Write-Log " SCP PULL ERROR (exit $LASTEXITCODE): $errorMsg"'
$content = $content.Substring(0, $idx) + $new3 + $content.Substring($idx + $old3.Length)
}
# Fix 4: Copy-ToNAS - quote both paths
$old4 = '$result = & $SCP -O -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" $LocalPath "${NAS_USER}@${NAS_IP}:$RemotePath" 2>&1'
$new4 = '$result = & $SCP -O -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="C:\Shares\test\scripts\.ssh\known_hosts" "$LocalPath" "${NAS_USER}@${NAS_IP}:`"${RemotePath}`"" 2>&1'
$content = $content.Replace($old4, $new4)
# Write the fixed script
Set-Content -Path $scriptPath -Value $content -NoNewline
Write-Output "=== SCRIPT UPDATED ==="
# Verify the fixes
$verify = Get-Content $scriptPath -Raw
Write-Output "=== VERIFY: SCP LINES ==="
$verify -split "`n" | Where-Object { $_ -match "result = .* SCP" } | ForEach-Object { Write-Output (" " + $_.Trim()) }
Write-Output "=== VERIFY: SSH LINE ==="
$verify -split "`n" | Where-Object { $_ -match "result = .* SSH" } | ForEach-Object { Write-Output (" " + $_.Trim()) }
Write-Output "=== VERIFY: ERROR MESSAGES ==="
$verify -split "`n" | Where-Object { $_ -match "SCP (PULL|PUSH) ERROR" } | ForEach-Object { Write-Output (" " + $_.Trim()) }
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
Write-Output $_.ScriptStackTrace
}

7
tmp_grep.ps1 Normal file
View File

@@ -0,0 +1,7 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$cmd = "cmd /c powershell -NoProfile -Command \"Get-Content C:\Shares\test\scripts\sync-from-nas.log | Select-String -Pattern 'Starting sync|Max age: 86400|Sync complete|No new DAT|Found.*DAT|PULL=|ambiguous' | Select-Object -Last 30 | ForEach-Object { $_.Line } > C:\Shares\test\scripts\tail-grep.txt 2>&1\""
$r = Invoke-WmiMethod -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Name Create -ArgumentList $cmd
Write-Output "PID: $($r.ProcessId)"
Start-Sleep -Seconds 15
Write-Output ([System.IO.File]::ReadAllText("\\192.168.0.6\C$\Shares\test\scripts\tail-grep.txt"))

14
tmp_grep2.ps1 Normal file
View File

@@ -0,0 +1,14 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$filterScript = 'Get-Content C:\Shares\test\scripts\sync-from-nas.log | Select-String -Pattern "Starting sync|Max age: 86400|Sync complete|No new DAT|Found.*DAT|PULL=|ambiguous|Copied to station|Copied report" | Select-Object -Last 40 | ForEach-Object { $_.Line } | Out-File C:\Shares\test\scripts\tail-grep.txt -Encoding utf8'
[System.IO.File]::WriteAllText("\\192.168.0.6\C$\Shares\test\scripts\filter-log.ps1", $filterScript)
$r = Invoke-WmiMethod -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Name Create -ArgumentList "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Shares\test\scripts\filter-log.ps1"
Write-Output "PID: $($r.ProcessId)"
Start-Sleep -Seconds 20
try {
Write-Output ([System.IO.File]::ReadAllText("\\192.168.0.6\C$\Shares\test\scripts\tail-grep.txt"))
} catch {
Write-Output "File not ready: $_"
}

24
tmp_inspect.ps1 Normal file
View File

@@ -0,0 +1,24 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
Write-Output "=== DRIVE MAPPED ==="
$scriptPath = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
$content = Get-Content $scriptPath -Raw
# Show the original problematic lines
Write-Output "=== ORIGINAL Copy-FromNAS SCP LINE ==="
$content -split "`n" | Where-Object { $_ -match "SCP.*RemotePath.*LocalPath|SCP.*LocalPath.*RemotePath" } | ForEach-Object { Write-Output $_.Trim() }
Write-Output "=== ORIGINAL Invoke-NASCommand LINE ==="
$content -split "`n" | Where-Object { $_ -match "result = .* \" } | ForEach-Object { Write-Output $_.Trim() }
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
}

20
tmp_lines.ps1 Normal file
View File

@@ -0,0 +1,20 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
$scriptPath = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
$lines = Get-Content $scriptPath
# Show lines 70-95 (around Copy-FromNAS SCP area)
Write-Output "=== Lines 60-95 ==="
for ($i = 59; $i -lt 95 -and $i -lt $lines.Count; $i++) {
Write-Output ("{0,3}: {1}" -f ($i+1), $lines[$i])
}
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
}

9
tmp_readlog.ps1 Normal file
View File

@@ -0,0 +1,9 @@
# Read sync log - last 60 lines
try {
$log = [System.IO.File]::ReadAllText("\\192.168.0.6\C$\Shares\test\scripts\sync-from-nas.log")
$logLines = $log -split "`n"
Write-Output "=== SYNC LOG (last 60 lines) ==="
$logLines | Select-Object -Last 60 | ForEach-Object { Write-Output $_ }
} catch {
Write-Output "ERROR reading log: $_"
}

0
tmp_readlog2.ps1 Normal file
View File

18
tmp_readtail.ps1 Normal file
View File

@@ -0,0 +1,18 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
# Write a small script on AD2 to create the tail file
$tailScript = "Get-Content C:\Shares\test\scripts\sync-from-nas.log -Tail 80 | Out-File C:\Shares\test\scripts\sync-tail.txt -Encoding utf8; Get-Content C:\Shares\test\_SYNC_STATUS.txt | Out-File C:\Shares\test\scripts\sync-tail.txt -Append -Encoding utf8"
[System.IO.File]::WriteAllText("\\192.168.0.6\C$\Shares\test\scripts\read-tail.ps1", $tailScript)
# Run it via WMI
$cmd = "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Shares\test\scripts\read-tail.ps1"
$result = Invoke-WmiMethod -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Name Create -ArgumentList $cmd
Write-Output "WMI result: $($result.ReturnValue) PID: $($result.ProcessId)"
# Wait for it to finish
Start-Sleep -Seconds 5
# Read the tail file
$tailContent = [System.IO.File]::ReadAllText("\\192.168.0.6\C$\Shares\test\scripts\sync-tail.txt")
Write-Output $tailContent

10
tmp_summary.ps1 Normal file
View File

@@ -0,0 +1,10 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$script = '$log = Get-Content C:\Shares\test\scripts\sync-from-nas.log; $bulk = $log | Where-Object { $_ -match "2026-02-17 16:(4|5)" }; $out = @(); $out += "=== FIRST 30 LINES ==="; $out += ($bulk | Select-Object -First 30); $out += ""; $out += "=== LAST 20 LINES ==="; $out += ($bulk | Select-Object -Last 20); $out += ""; $out += "=== SUMMARY ==="; $out += "Total lines: $($bulk.Count)"; $out += "Pushed: $(($bulk | Where-Object { $_ -match "Pushed:" }).Count)"; $out += "Errors: $(($bulk | Where-Object { $_ -match "ERROR" }).Count)"; $out += "Ambiguous: $(($bulk | Where-Object { $_ -match "ambiguous" }).Count)"; $out += "No such file: $(($bulk | Where-Object { $_ -match "No such file" }).Count)"; $out += "Copied station: $(($bulk | Where-Object { $_ -match "Copied to station" }).Count)"; $out += "Copied report: $(($bulk | Where-Object { $_ -match "Copied report" }).Count)"; $out | Out-File C:\Shares\test\scripts\sync-summary.txt -Encoding utf8'
[System.IO.File]::WriteAllText("\\192.168.0.6\C$\Shares\test\scripts\make-summary.ps1", $script)
$r = Invoke-WmiMethod -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Name Create -ArgumentList "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Shares\test\scripts\make-summary.ps1"
Write-Output "PID: $($r.ProcessId)"
Start-Sleep -Seconds 10
Write-Output ([System.IO.File]::ReadAllText("\\192.168.0.6\C$\Shares\test\scripts\sync-summary.txt"))

29
tmp_syntax.ps1 Normal file
View File

@@ -0,0 +1,29 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
$scriptPath = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
$content = Get-Content $scriptPath -Raw
# Syntax check via parsing the content string
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$null, [ref]$parseErrors)
if ($parseErrors.Count -eq 0) {
Write-Output "[OK] Script parses OK - no syntax errors"
} else {
foreach ($err in $parseErrors) {
Write-Output "[ERROR] PARSE ERROR: $($err.Message) at line $($err.Extent.StartLineNumber)"
}
}
# Count total lines
$lineCount = ($content -split "`n").Count
Write-Output "Total lines: $lineCount"
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
}

11
tmp_syntax2.ps1 Normal file
View File

@@ -0,0 +1,11 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
$content = Get-Content "AD2:\Shares\test\scripts\Sync-FromNAS.ps1" -Raw
$tokens = $null; $parseErrors = $null
[void][System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$tokens, [ref]$parseErrors)
Write-Output "Parse errors: $($parseErrors.Count)"
if ($parseErrors.Count -gt 0) { $parseErrors | ForEach-Object { Write-Output " $_" } }
else { Write-Output "[OK] No syntax errors detected" }
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue

6
tmp_tail.ps1 Normal file
View File

@@ -0,0 +1,6 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$r = Invoke-WmiMethod -ComputerName 192.168.0.6 -Credential $cred -Class Win32_Process -Name Create -ArgumentList "cmd /c powershell -NoProfile -Command Get-Content C:\Shares\test\scripts\sync-from-nas.log -Tail 40 > C:\Shares\test\scripts\tail40.txt 2>&1"
Write-Output "PID: $($r.ProcessId) Return: $($r.ReturnValue)"
Start-Sleep -Seconds 8
Write-Output ([System.IO.File]::ReadAllText("\\192.168.0.6\C$\Shares\test\scripts\tail40.txt"))

18
tmp_test.ps1 Normal file
View File

@@ -0,0 +1,18 @@
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
# First, do a dry run to see what files are stranded on the NAS
Write-Output "=== DRY RUN: Bulk sync with 86400 minute window (60 days) ==="
$dryRunOutput = & powershell -NoProfile -ExecutionPolicy Bypass -Command {
# We cannot run the script directly on AD2 from here, so we will invoke it remotely
}
# Actually we need to run this ON AD2. Lets check if we can invoke it via the mapped drive.
# The script needs to run locally on AD2 (it calls SCP/SSH). We need WinRM or SSH for that.
# Since WinRM is blocked from this machine, lets try another approach.
# Check: can we SSH to AD2 with the key from this machine?
Write-Output "=== Testing SSH to NAS directly from this machine ==="
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue

33
tmp_verify.ps1 Normal file
View File

@@ -0,0 +1,33 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
$scriptPath = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
$content = Get-Content $scriptPath -Raw
# Show the Copy-FromNAS function
$lines = $content -split "`n"
$inFunc = $false
$funcName = ""
foreach ($line in $lines) {
if ($line -match "^function (Copy-FromNAS|Copy-ToNAS|Invoke-NASCommand)") {
$inFunc = $true
$funcName = $Matches[1]
Write-Output "=== $funcName ==="
}
if ($inFunc) {
Write-Output $line.TrimEnd()
if ($line.Trim() -eq "}" -and $funcName) {
$inFunc = $false
Write-Output ""
}
}
}
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
}

32
tmp_verify2.ps1 Normal file
View File

@@ -0,0 +1,32 @@
$ErrorActionPreference = "Stop"
$pass = ConvertTo-SecureString ("Paper123" + [char]33 + "@#") -AsPlainText -Force
$cred = New-Object PSCredential("INTRANET\sysadmin", $pass)
$uncRoot = "\\192.168.0.6\C$"
try {
New-PSDrive -Name AD2 -PSProvider FileSystem -Root $uncRoot -Credential $cred -ErrorAction Stop | Out-Null
$scriptPath = "AD2:\Shares\test\scripts\Sync-FromNAS.ps1"
$lines = Get-Content $scriptPath
Write-Output "=== FULL VERIFICATION: Lines 48-100 ==="
for ($i = 47; $i -lt 100 -and $i -lt $lines.Count; $i++) {
Write-Output ("{0,3}: {1}" -f ($i+1), $lines[$i])
}
# Also check the script parses without errors
Write-Output ""
Write-Output "=== SYNTAX CHECK ==="
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile("AD2:\Shares\test\scripts\Sync-FromNAS.ps1", [ref]$null, [ref]$parseErrors)
if ($parseErrors.Count -eq 0) {
Write-Output "Script parses OK - no syntax errors"
} else {
foreach ($err in $parseErrors) {
Write-Output "PARSE ERROR: $($err.Message) at line $($err.Extent.StartLineNumber)"
}
}
Remove-PSDrive -Name AD2 -ErrorAction SilentlyContinue
} catch {
Write-Output "ERROR: $_"
}

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