Compare commits
2 Commits
6d3582d5dc
...
92f3dd696f
| Author | SHA1 | Date | |
|---|---|---|---|
| 92f3dd696f | |||
| 8b6f0bcc96 |
@@ -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"
|
||||
|
||||
0
projects/dataforth-dos/Sync-FromNAS.ps1.original
Normal file
0
projects/dataforth-dos/Sync-FromNAS.ps1.original
Normal file
606
projects/msp-tools/guru-rmm/GURURMM_DOCUMENTATION.md
Normal file
606
projects/msp-tools/guru-rmm/GURURMM_DOCUMENTATION.md
Normal 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.*
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
67
projects/msp-tools/guru-rmm/deploy_agent_chunks.py
Normal file
67
projects/msp-tools/guru-rmm/deploy_agent_chunks.py
Normal 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.")
|
||||
88
projects/msp-tools/guru-rmm/deploy_via_textchunks.py
Normal file
88
projects/msp-tools/guru-rmm/deploy_via_textchunks.py
Normal 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!")
|
||||
35
projects/msp-tools/guru-rmm/download_agent.py
Normal file
35
projects/msp-tools/guru-rmm/download_agent.py
Normal 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])
|
||||
46
projects/msp-tools/guru-rmm/scp_agent.py
Normal file
46
projects/msp-tools/guru-rmm/scp_agent.py
Normal 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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
projects/msp-tools/guru-rmm/swap_agent.py
Normal file
80
projects/msp-tools/guru-rmm/swap_agent.py
Normal 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
218
session-logs/2026-02-24-session.md
Normal file
218
session-logs/2026-02-24-session.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Session Log: 2026-02-24
|
||||
|
||||
## Session Summary
|
||||
|
||||
Two major topics covered this session:
|
||||
|
||||
### 1. Yealink YMCS Setup & Phone Scanner Tool
|
||||
Set up Yealink Management Cloud Service (YMCS) for managing phones across ACG clients. Built a PowerShell scanner tool to discover Yealink phones on client networks and extract serial numbers for RPS/YMCS registration.
|
||||
|
||||
### 2. Peaceful Spirit (Country Club) - UCG Ultra Speed Issues
|
||||
Diagnosed severe speed degradation on a Cox 300/30 circuit behind a Unifi Cloud Gateway Ultra. Root cause identified as ECM hardware offload engine crash-looping combined with Suricata IDS/IPS on High consuming excessive CPU.
|
||||
|
||||
---
|
||||
|
||||
## Topic 1: Yealink YMCS Setup
|
||||
|
||||
### What Was Accomplished
|
||||
- Reviewed YMCS dashboard structure: Arizona Computer Guru LLC org with sites VWP and GuruHQ
|
||||
- Confirmed YMCS pass-through/relay provisioning works - YMCS redirects phones to PacketDials for SIP config
|
||||
- Two phones already online in YMCS:
|
||||
- **ACG Test Phone**: MAC `805ec097dacf`, SIP-T46S, firmware 66.86.0.15, IP 172.16.1.58
|
||||
- **Winter**: MAC `805e0c08fefa`, SIP-T46S, firmware 66.86.0.15, IP 172.16.1.29
|
||||
- YMCS Site Configuration (GuruHQ) already has relay config to PacketDials:
|
||||
```
|
||||
auto_provision.pnp_enable=1
|
||||
auto_provision.power_on=1
|
||||
auto_provision.repeat.enable=1
|
||||
auto_provision.repeat.minutes=30
|
||||
auto_provision.server.password=********
|
||||
auto_provision.server.url=ftp://p.packetdials.net
|
||||
auto_provision.server.username=lrshwh
|
||||
firmware.url=ftp://p.packetdials.net
|
||||
static.zero_touch.enable=1
|
||||
```
|
||||
|
||||
### Migration Plan (wlcomm to OIT VoIP)
|
||||
- YMCS acts as relay/pass-through to provider's provisioning server
|
||||
- When ready: change `auto_provision.server.url` in YMCS site config from PacketDials to OIT
|
||||
- Push config, phones re-provision from OIT on next check-in (every 30 min) or reboot
|
||||
- Each client in PacketDials/Whitelabel has shared device password, username always `admin`
|
||||
|
||||
### Winter Phone SIP Details (for reference)
|
||||
- SIP Server: `computerguru.voip.packetdials.net`
|
||||
- Username: `5f54f3c8b216`
|
||||
- Password: `3eb7d67260efe017`
|
||||
- Transport: DNS NAPTR
|
||||
- Expires: 360
|
||||
- Assigned to: Winter Williams
|
||||
- E911: (520) 304-8300 - 7437 E 22...
|
||||
- Line Keys: Device (Winter), Park 1-4 (*31-*34), BLF Mike (7003), BLF Rob (7007), Speed Dial Mike-Cell (1-520-289-1912), Howard-Cell (1-520-585-1310), Rob-Cell (1-520-303-6791)
|
||||
|
||||
### Yealink Phone Scanner Tool
|
||||
Built `tools/Scan-YealinkPhones.ps1` - PowerShell script to scan subnets for Yealink phones.
|
||||
|
||||
**What works:**
|
||||
- Ping sweep using .NET SendPingAsync (parallel batches)
|
||||
- ARP table + Get-NetNeighbor parsing to find Yealink MACs
|
||||
- Yealink OUI prefixes: `80:5E:C0`, `80:5E:0C`, `80:5A:35`, `00:15:65`, `28:6D:97`, `24:4B:FE`
|
||||
- SSL certificate bypass for self-signed certs
|
||||
- Unsafe header parsing for Yealink's non-standard HTTP responses
|
||||
- CSV output with append capability
|
||||
|
||||
**What doesn't work (yet):**
|
||||
- Serial number extraction from web UI - Yealink T46S firmware 66.86.0.15 uses RSA+AES encrypted login
|
||||
- Login flow: AES-128-CBC encrypts password (with random prefix + JSESSIONID), RSA encrypts AES key/IV
|
||||
- Implemented the crypto in PowerShell but got error -3 (authentication format mismatch)
|
||||
- The JS crypto uses CryptoJS AES with ZeroPadding + custom RSA (pkcs1pad2)
|
||||
- Issue likely related to session/nonce handling
|
||||
|
||||
**Alternative approaches tried:**
|
||||
- SSDP/UPnP discovery: No response from Yealink phones
|
||||
- SNMP (community: public): No response
|
||||
- Digest auth on cgiServer.exx: 401 (auth not accepted)
|
||||
- Various API endpoints: All return login page or 403
|
||||
|
||||
**Backup tool created:** `tools/yealink-serial-scanner.html` - Browser-based scanner that uses the phone's own JavaScript crypto. Not yet tested.
|
||||
|
||||
**Recommended approach:** Yealink IP Discovery Tool (official tool, not publicly available - request from Yealink distributor or check YMCS Resources section)
|
||||
|
||||
### Files Created/Modified
|
||||
- `tools/Scan-YealinkPhones.ps1` - Main scanner script
|
||||
- `tools/test-yealink.ps1` - Debug/test script (can be deleted)
|
||||
- `tools/yealink-serial-scanner.html` - Browser-based scanner (backup approach)
|
||||
|
||||
### Credentials
|
||||
- GuruHQ Yealink phone web UI: admin / b4e765c3
|
||||
- PacketDials provisioning: username `lrshwh` (password masked in YMCS)
|
||||
- YMCS RPS example serial: `3146019091637071` (ACG Test Phone)
|
||||
|
||||
---
|
||||
|
||||
## Topic 2: Peaceful Spirit Country Club - UCG Ultra Speed Issues
|
||||
|
||||
### Problem
|
||||
Cox 300/30 Mbps circuit delivering 1 Mbps download with hardware acceleration ON + auto MSS clamping. Was working at full speed a few days prior.
|
||||
|
||||
### Equipment
|
||||
- **Gateway:** Unifi Cloud Gateway Ultra (UCG-PST-CC)
|
||||
- **Firmware:** UniFi OS 5.0.12, Network 10.1.85 (Official channel, auto-update ON)
|
||||
- **Kernel:** 5.4.213-ui-ipq5322 (aarch64)
|
||||
- **WAN:** eth4, 2500 Mbps full duplex to Cox modem
|
||||
- **VPN:** WireGuard site-to-site (wgsts1000, MTU 1420) + tun1 (Teleport)
|
||||
- **Cox IP:** 98.190.129.150 (wsip-98-190-129-150.ph.ph.cox.net)
|
||||
- **LAN:** 192.168.0.0/24
|
||||
- **Modem:** New, replaced day before session
|
||||
|
||||
### Test Results
|
||||
| Configuration | Download | Upload |
|
||||
|--------------|----------|--------|
|
||||
| HW accel ON + Auto MSS | ~1 Mbps | 29 Mbps |
|
||||
| HW accel ON + MSS 1300 | 28 Mbps | 29 Mbps |
|
||||
| HW accel OFF + Auto MSS | 28 Mbps | 22 Mbps |
|
||||
| HW accel ON + MSS 1452 | <1 Mbps | - |
|
||||
| HW accel ON + MSS disabled | <2 Mbps | - |
|
||||
| Later (no changes) | 150 Mbps | - |
|
||||
| Later (no changes) | 271 Mbps | - |
|
||||
|
||||
### Root Cause Analysis (via SSH)
|
||||
1. **Suricata IDS/IPS running on HIGH** - consuming 20.3% RAM (614MB), forcing all traffic through CPU
|
||||
2. **ECM hardware offload NOT loaded** - `lsmod | grep ecm` returned empty; ECM is disabled when IDS/IPS is active
|
||||
3. **ECM was crash-looping** in dmesg - repeated `ECM exit / ECM init` cycles
|
||||
4. **MSS clamping rules only apply to tun1 (VPN)**, NOT to WAN (eth4) - UI MSS setting had no effect on WAN traffic
|
||||
5. **QUIC reassembly failures** in dmesg: `[quic_sm_reassemble_func#1025]: failed to allocate reassemble cont.`
|
||||
6. **WAN link flapped** - eth4 went down/up during the session period
|
||||
|
||||
### Key Finding
|
||||
MSS clamping in the Unifi UI was a red herring - iptables showed MSS rules only on `tun1`, not `eth4`. The real issue was Suricata on High preventing hardware offload, combined with ECM instability.
|
||||
|
||||
### Resolution
|
||||
Speed recovered to 271 Mbps without making changes - likely ECM crash loop resolved itself. Monitoring recommended.
|
||||
|
||||
### Recommendations
|
||||
- Consider switching IDS/IPS from High to Medium/Low for better throughput
|
||||
- Monitor for ECM crash recurrence
|
||||
- If speeds drop again, reboot UCG Ultra to reset ECM state
|
||||
- Keep SSH key in place for future diagnostics
|
||||
|
||||
### SSH Access
|
||||
- **Host:** 192.168.0.10 (via VPN) or 98.190.129.150 (WAN)
|
||||
- **User:** root (also requires password via GUI-added key)
|
||||
- **Key:** `~/.ssh/ucg_peaceful_spirit` (ed25519)
|
||||
- **Public key:** `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKBw+BK25MXpm91XBtDsSp7K0nTcKwFDLFZDx7tAO/N8 claude@claudetools`
|
||||
- **Note:** Key was added via Unifi GUI; SSH still prompts for password in addition to key
|
||||
|
||||
### Infrastructure
|
||||
- UCG Ultra hostname: UCG-PST-CC
|
||||
- WAN interface: eth4 (NOT eth0)
|
||||
- LAN interfaces: eth0-eth3 on switch0, br0
|
||||
- VPN: wgsts1000 (WireGuard site-to-site), tun1 (Teleport)
|
||||
|
||||
---
|
||||
|
||||
## MSS Clamping Reference (Cox Cable)
|
||||
- Cox uses standard DOCSIS, MTU 1500, no PPPoE
|
||||
- Standard MSS: 1460 (1500 - 20 IP - 20 TCP)
|
||||
- With IPsec VPN: ~1390-1400
|
||||
- With WireGuard: 1420
|
||||
- UCG Ultra max MSS input: 1452
|
||||
|
||||
---
|
||||
|
||||
## Pending/Incomplete Tasks
|
||||
|
||||
### Yealink YMCS
|
||||
- [ ] Get Yealink IP Discovery Tool from distributor (for serial number extraction)
|
||||
- [ ] Test browser-based scanner (`tools/yealink-serial-scanner.html`) as fallback
|
||||
- [ ] Onboard remaining phones across all client sites into YMCS
|
||||
- [ ] Build OIT VoIP config templates in YMCS when ready for migration
|
||||
- [ ] Clean up test files (`tools/test-yealink.ps1`)
|
||||
|
||||
### Peaceful Spirit
|
||||
- [ ] Monitor UCG Ultra speed stability over coming days
|
||||
- [ ] If speeds drop again, consider IDS/IPS High -> Medium/Low
|
||||
- [ ] Investigate why GUI-added SSH key still requires password
|
||||
- [ ] Consider disabling auto-update on UCG to prevent firmware regressions
|
||||
|
||||
---
|
||||
|
||||
## Update: 2026-02-25 Follow-up
|
||||
|
||||
### Peaceful Spirit - Continued Degradation
|
||||
|
||||
After initial recovery to 278 Mbps (HW accel ON, auto MSS), speeds dropped back to 1 Mbps within minutes. ECM confirmed crash-looping again via SSH dmesg — cycling every ~6 minutes (init -> run -> exit -> repeat).
|
||||
|
||||
### IDS/IPS Disabled
|
||||
- Switched IDS/IPS from High to disabled entirely
|
||||
- Speed still unstable: initial 200+ Mbps then **decays to ~70 Mbps under sustained load**
|
||||
- This speed decay pattern (burst then drop) indicates external plant issue, not gateway
|
||||
|
||||
### Conclusion: Cox Plant Issue
|
||||
- ECM crash-looping is a SYMPTOM, not the cause
|
||||
- Gateway offload engine crashing because it's receiving corrupted/incomplete frames from modem
|
||||
- Speed decay under sustained load consistent with:
|
||||
- Upstream noise/ingress causing CMTS power level adjustments
|
||||
- Overheating or failing amplifier in plant
|
||||
- Partial bonding failure (marginal channels dropping under load)
|
||||
- T3 timeouts accumulating as modem loses sync on noisy channels
|
||||
- **Cox tech dispatched** — needs line tech with meter at the tap
|
||||
|
||||
### Summary Provided to Cox Tech
|
||||
- 300/30 circuit delivering 70-200 Mbps (intermittent drops to <1 Mbps)
|
||||
- 50% packet loss at all packet sizes
|
||||
- New modem (replaced day prior), same issue
|
||||
- Speed starts 200+ then decays to 70 under sustained load
|
||||
- Download severely impacted, upload less affected = downstream RF/signal issue
|
||||
- Need tech to check: downstream SNR, power levels, uncorrectable codewords, T3/T4 timeouts, physical plant, RF ingress
|
||||
|
||||
---
|
||||
|
||||
## Files Reference
|
||||
- `tools/Scan-YealinkPhones.ps1` - Yealink phone subnet scanner
|
||||
- `tools/test-yealink.ps1` - Debug script (temporary)
|
||||
- `tools/yealink-serial-scanner.html` - Browser-based serial scanner
|
||||
- `~/.ssh/ucg_peaceful_spirit` - SSH key for Peaceful Spirit UCG Ultra
|
||||
- `C:\temp\phones.csv` - Scanner output (test data)
|
||||
- `C:\temp\yealink_common.js` - Yealink phone JS (for crypto analysis)
|
||||
- `C:\temp\yealink_login.js` - Yealink login JS
|
||||
- `C:\temp\yealink_loginform.txt` - Login form response dump
|
||||
20
tmp_backup_ad2.ps1
Normal file
20
tmp_backup_ad2.ps1
Normal 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
56
tmp_bulksync.ps1
Normal 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
29
tmp_bulksync2.ps1
Normal 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
13
tmp_check2.ps1
Normal 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
18
tmp_check3.ps1
Normal 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
18
tmp_check4.ps1
Normal 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
18
tmp_check_proc.ps1
Normal 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
21
tmp_connect_ad2.ps1
Normal 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
37
tmp_fix2.ps1
Normal 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
58
tmp_fix_script.ps1
Normal 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
7
tmp_grep.ps1
Normal 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
14
tmp_grep2.ps1
Normal 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
24
tmp_inspect.ps1
Normal 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
20
tmp_lines.ps1
Normal 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
9
tmp_readlog.ps1
Normal 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
0
tmp_readlog2.ps1
Normal file
18
tmp_readtail.ps1
Normal file
18
tmp_readtail.ps1
Normal 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
10
tmp_summary.ps1
Normal 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
29
tmp_syntax.ps1
Normal 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
11
tmp_syntax2.ps1
Normal 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
6
tmp_tail.ps1
Normal 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
18
tmp_test.ps1
Normal 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
33
tmp_verify.ps1
Normal 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
32
tmp_verify2.ps1
Normal 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: $_"
|
||||
}
|
||||
898
tools/Scan-YealinkPhones.ps1
Normal file
898
tools/Scan-YealinkPhones.ps1
Normal file
@@ -0,0 +1,898 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Scans a subnet for Yealink phones and extracts inventory data from their web UI.
|
||||
|
||||
.DESCRIPTION
|
||||
Performs a fast parallel ping sweep of a given CIDR subnet, filters for Yealink
|
||||
MAC address prefixes via the ARP table, authenticates to each phone's web UI
|
||||
using digest auth, and extracts MAC, serial number, model, and firmware version.
|
||||
Results are written to CSV and displayed in the console.
|
||||
|
||||
.PARAMETER Subnet
|
||||
Subnet to scan in CIDR notation (e.g., 192.168.1.0/24).
|
||||
|
||||
.PARAMETER Username
|
||||
Web UI username for the Yealink phones. Default: admin
|
||||
|
||||
.PARAMETER Password
|
||||
Web UI password for the Yealink phones.
|
||||
|
||||
.PARAMETER SiteName
|
||||
Client/site name included in each CSV row for multi-site inventory tracking.
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Path for the output CSV file. Default: yealink_inventory.csv
|
||||
|
||||
.PARAMETER Timeout
|
||||
Timeout in milliseconds for network operations. Default: 1000
|
||||
|
||||
.EXAMPLE
|
||||
.\Scan-YealinkPhones.ps1 -Subnet "192.168.1.0/24" -Password "mypass" -SiteName "ClientHQ"
|
||||
|
||||
.EXAMPLE
|
||||
.\Scan-YealinkPhones.ps1 -Subnet "10.0.5.0/24" -Username "admin" -Password "p@ss" -SiteName "BranchOffice" -OutputFile "C:\inventory\phones.csv" -Timeout 2000
|
||||
|
||||
.NOTES
|
||||
Compatible with PowerShell 5.1 (Windows built-in). No external module dependencies.
|
||||
Uses runspaces for parallel ping sweep. Supports Yealink digest authentication.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, HelpMessage = "Subnet in CIDR notation, e.g. 192.168.1.0/24")]
|
||||
[ValidatePattern('^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$')]
|
||||
[string]$Subnet,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Username = "admin",
|
||||
|
||||
[Parameter(Mandatory = $true, HelpMessage = "Web UI password for the phones")]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory = $true, HelpMessage = "Client/site name for the CSV output")]
|
||||
[string]$SiteName,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$OutputFile = "yealink_inventory.csv",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidateRange(100, 30000)]
|
||||
[int]$Timeout = 1000
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Yealink OUI prefixes (colon-separated, uppercase for comparison)
|
||||
# ---------------------------------------------------------------------------
|
||||
$YealinkPrefixes = @("80:5E:C0", "80:5E:0C", "80:5A:35", "00:15:65", "28:6D:97", "24:4B:FE")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CIDR Calculation
|
||||
# ---------------------------------------------------------------------------
|
||||
function Get-SubnetAddresses {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns all usable host IP addresses for a given CIDR subnet.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$CidrSubnet
|
||||
)
|
||||
|
||||
$parts = $CidrSubnet -split '/'
|
||||
$networkAddress = [System.Net.IPAddress]::Parse($parts[0])
|
||||
$prefixLength = [int]$parts[1]
|
||||
|
||||
if ($prefixLength -lt 8 -or $prefixLength -gt 30) {
|
||||
throw "Prefix length must be between /8 and /30. Got /$prefixLength."
|
||||
}
|
||||
|
||||
$networkBytes = $networkAddress.GetAddressBytes()
|
||||
# Convert to UInt32 (big-endian)
|
||||
$networkUInt = ([uint32]$networkBytes[0] -shl 24) -bor `
|
||||
([uint32]$networkBytes[1] -shl 16) -bor `
|
||||
([uint32]$networkBytes[2] -shl 8) -bor `
|
||||
([uint32]$networkBytes[3])
|
||||
|
||||
$hostBits = 32 - $prefixLength
|
||||
$totalAddresses = [math]::Pow(2, $hostBits)
|
||||
$subnetMask = ([uint32]::MaxValue) -shl $hostBits -band [uint32]::MaxValue
|
||||
$networkStart = $networkUInt -band $subnetMask
|
||||
|
||||
$addresses = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
# Skip network address (first) and broadcast address (last)
|
||||
for ($i = 1; $i -lt ($totalAddresses - 1); $i++) {
|
||||
$ipUInt = $networkStart + $i
|
||||
$octet1 = ($ipUInt -shr 24) -band 0xFF
|
||||
$octet2 = ($ipUInt -shr 16) -band 0xFF
|
||||
$octet3 = ($ipUInt -shr 8) -band 0xFF
|
||||
$octet4 = $ipUInt -band 0xFF
|
||||
$addresses.Add("$octet1.$octet2.$octet3.$octet4")
|
||||
}
|
||||
|
||||
return $addresses
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parallel Ping Sweep using Runspaces
|
||||
# ---------------------------------------------------------------------------
|
||||
function Invoke-PingSweep {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pings all IPs in parallel using runspaces. Returns list of responding IPs.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Collections.Generic.List[string]]$IPAddresses,
|
||||
|
||||
[int]$TimeoutMs = 1000,
|
||||
|
||||
[int]$ThrottleLimit = 64
|
||||
)
|
||||
|
||||
$runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $ThrottleLimit)
|
||||
$runspacePool.Open()
|
||||
|
||||
$scriptBlock = {
|
||||
param([string]$IP, [int]$Timeout)
|
||||
$pinger = New-Object System.Net.NetworkInformation.Ping
|
||||
try {
|
||||
$result = $pinger.Send($IP, $Timeout)
|
||||
if ($result.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) {
|
||||
return $IP
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Host unreachable or other error — skip silently
|
||||
}
|
||||
finally {
|
||||
$pinger.Dispose()
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$jobs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
foreach ($ip in $IPAddresses) {
|
||||
$ps = [System.Management.Automation.PowerShell]::Create()
|
||||
$ps.RunspacePool = $runspacePool
|
||||
[void]$ps.AddScript($scriptBlock)
|
||||
[void]$ps.AddArgument($ip)
|
||||
[void]$ps.AddArgument($TimeoutMs)
|
||||
|
||||
$handle = $ps.BeginInvoke()
|
||||
$jobs.Add([PSCustomObject]@{
|
||||
PowerShell = $ps
|
||||
Handle = $handle
|
||||
})
|
||||
}
|
||||
|
||||
$liveHosts = [System.Collections.Generic.List[string]]::new()
|
||||
$completed = 0
|
||||
$total = $jobs.Count
|
||||
|
||||
foreach ($job in $jobs) {
|
||||
try {
|
||||
$result = $job.PowerShell.EndInvoke($job.Handle)
|
||||
if ($result -and $result[0]) {
|
||||
$liveHosts.Add($result[0])
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Silently skip failed pings
|
||||
}
|
||||
finally {
|
||||
$job.PowerShell.Dispose()
|
||||
}
|
||||
$completed++
|
||||
if ($completed % 50 -eq 0 -or $completed -eq $total) {
|
||||
$pct = [math]::Round(($completed / $total) * 100)
|
||||
Write-Host "`r Ping progress: $completed/$total ($pct%)" -NoNewline
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
$runspacePool.Close()
|
||||
$runspacePool.Dispose()
|
||||
|
||||
return $liveHosts
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARP Table MAC Lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
function Get-ArpMac {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Retrieves MAC address for an IP from the local ARP table.
|
||||
Returns MAC in colon-separated uppercase format, or $null if not found.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$IPAddress
|
||||
)
|
||||
|
||||
try {
|
||||
$arpOutput = & arp -a $IPAddress 2>$null
|
||||
if (-not $arpOutput) { return $null }
|
||||
|
||||
foreach ($line in $arpOutput) {
|
||||
$line = $line.Trim()
|
||||
# Windows ARP format: "192.168.1.1 80-5e-c0-aa-bb-cc dynamic"
|
||||
if ($line -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+([\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2})') {
|
||||
$mac = $Matches[1].ToUpper() -replace '-', ':'
|
||||
return $mac
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# ARP lookup failed — not critical
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Yealink MAC Prefix Check
|
||||
# ---------------------------------------------------------------------------
|
||||
function Test-YealinkMac {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns $true if the MAC address belongs to a known Yealink OUI prefix.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Mac
|
||||
)
|
||||
|
||||
$macUpper = $Mac.ToUpper() -replace '-', ':'
|
||||
$prefix = ($macUpper -split ':')[0..2] -join ':'
|
||||
|
||||
return ($YealinkPrefixes -contains $prefix)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Digest Authentication Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
function Set-UnsafeHeaderParsing {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enables useUnsafeHeaderParsing to tolerate non-standard HTTP headers
|
||||
from embedded devices like Yealink phones.
|
||||
#>
|
||||
$netAssembly = [System.Reflection.Assembly]::GetAssembly([System.Net.Configuration.SettingsSection])
|
||||
if ($netAssembly) {
|
||||
$bindingFlags = [System.Reflection.BindingFlags]::Static -bor
|
||||
[System.Reflection.BindingFlags]::GetProperty -bor
|
||||
[System.Reflection.BindingFlags]::NonPublic
|
||||
$settingsType = $netAssembly.GetType("System.Net.Configuration.SettingsSectionInternal")
|
||||
if ($settingsType) {
|
||||
$instance = $settingsType.InvokeMember("Section", $bindingFlags, $null, $null, @())
|
||||
if ($instance) {
|
||||
$useUnsafeField = $settingsType.GetField("useUnsafeHeaderParsing",
|
||||
[System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance)
|
||||
if ($useUnsafeField) {
|
||||
$useUnsafeField.SetValue($instance, $true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-DigestAuthRequest {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Performs an HTTP GET with digest authentication.
|
||||
Compatible with PowerShell 5.1.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Uri,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$User,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Pass,
|
||||
|
||||
[int]$TimeoutMs = 5000
|
||||
)
|
||||
|
||||
# Enable lenient header parsing for Yealink's non-standard HTTP responses
|
||||
Set-UnsafeHeaderParsing
|
||||
|
||||
# Ignore SSL certificate errors (phones use self-signed certs)
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||
# Ensure TLS 1.2 support
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls -bor [System.Net.SecurityProtocolType]::Ssl3
|
||||
|
||||
$credential = New-Object System.Net.NetworkCredential($User, $Pass)
|
||||
$credCache = New-Object System.Net.CredentialCache
|
||||
$credCache.Add([Uri]$Uri, "Digest", $credential)
|
||||
# Also add Basic in case some models use it
|
||||
$credCache.Add([Uri]$Uri, "Basic", $credential)
|
||||
|
||||
$request = [System.Net.HttpWebRequest]::Create($Uri)
|
||||
$request.Credentials = $credCache
|
||||
$request.PreAuthenticate = $false
|
||||
$request.Timeout = $TimeoutMs
|
||||
$request.ReadWriteTimeout = $TimeoutMs
|
||||
$request.Method = "GET"
|
||||
$request.UserAgent = "Mozilla/5.0 (YealinkScanner)"
|
||||
|
||||
try {
|
||||
$response = $request.GetResponse()
|
||||
$stream = $response.GetResponseStream()
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
$body = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
$stream.Close()
|
||||
$response.Close()
|
||||
return $body
|
||||
}
|
||||
catch [System.Net.WebException] {
|
||||
$ex = $_.Exception
|
||||
if ($ex.Response) {
|
||||
$statusCode = [int]$ex.Response.StatusCode
|
||||
if ($statusCode -eq 401) {
|
||||
throw "Authentication failed (HTTP 401)"
|
||||
}
|
||||
throw "HTTP error $statusCode"
|
||||
}
|
||||
throw "Connection failed: $($ex.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Yealink Data Extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
function Get-YealinkPhoneData {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Queries a Yealink phone's web UI and extracts inventory data.
|
||||
Tries the structured data endpoint first, then falls back to the HTML status page.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$IPAddress,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$User,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Pass,
|
||||
|
||||
[int]$TimeoutMs = 5000
|
||||
)
|
||||
|
||||
$phoneData = [PSCustomObject]@{
|
||||
MAC = ""
|
||||
Serial = ""
|
||||
Model = ""
|
||||
FirmwareVersion = ""
|
||||
IP = $IPAddress
|
||||
Success = $false
|
||||
Error = ""
|
||||
}
|
||||
|
||||
# Enable lenient header parsing and SSL bypass
|
||||
Set-UnsafeHeaderParsing
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls -bor [System.Net.SecurityProtocolType]::Ssl3
|
||||
|
||||
$body = $null
|
||||
$protocols = @("https", "http")
|
||||
|
||||
# Disable Expect: 100-continue globally (Yealink returns 417 otherwise)
|
||||
[System.Net.ServicePointManager]::Expect100Continue = $false
|
||||
|
||||
foreach ($proto in $protocols) {
|
||||
$baseUrl = "${proto}://$IPAddress"
|
||||
|
||||
# --- Method 1: Cookie-based login (T4x/T5x with newer firmware) ---
|
||||
try {
|
||||
$cookieContainer = New-Object System.Net.CookieContainer
|
||||
|
||||
# Step 1: POST login
|
||||
$loginUrl = "$baseUrl/servlet?m=mod_listener&p=login&q=login&Ession=0"
|
||||
$loginRequest = [System.Net.HttpWebRequest]::Create($loginUrl)
|
||||
$loginRequest.Method = "POST"
|
||||
$loginRequest.ContentType = "application/x-www-form-urlencoded"
|
||||
$loginRequest.ServicePoint.Expect100Continue = $false
|
||||
$loginRequest.CookieContainer = $cookieContainer
|
||||
$loginRequest.Timeout = $TimeoutMs
|
||||
$loginRequest.ReadWriteTimeout = $TimeoutMs
|
||||
$loginRequest.UserAgent = "Mozilla/5.0 (YealinkScanner)"
|
||||
$loginRequest.AllowAutoRedirect = $true
|
||||
|
||||
$loginBody = "username=$User&pwd=$Pass"
|
||||
$loginBytes = [System.Text.Encoding]::UTF8.GetBytes($loginBody)
|
||||
$loginRequest.ContentLength = $loginBytes.Length
|
||||
$reqStream = $loginRequest.GetRequestStream()
|
||||
$reqStream.Write($loginBytes, 0, $loginBytes.Length)
|
||||
$reqStream.Close()
|
||||
|
||||
$loginResponse = $loginRequest.GetResponse()
|
||||
$loginReader = New-Object System.IO.StreamReader($loginResponse.GetResponseStream())
|
||||
$loginResult = $loginReader.ReadToEnd()
|
||||
$loginReader.Close()
|
||||
$loginResponse.Close()
|
||||
|
||||
# Step 2: Fetch status page with session cookie
|
||||
$statusUrl = "$baseUrl/servlet?m=mod_data&p=status-status&q=load"
|
||||
$statusRequest = [System.Net.HttpWebRequest]::Create($statusUrl)
|
||||
$statusRequest.Method = "GET"
|
||||
$statusRequest.CookieContainer = $cookieContainer
|
||||
$statusRequest.Timeout = $TimeoutMs
|
||||
$statusRequest.ReadWriteTimeout = $TimeoutMs
|
||||
$statusRequest.UserAgent = "Mozilla/5.0 (YealinkScanner)"
|
||||
|
||||
$statusResponse = $statusRequest.GetResponse()
|
||||
$statusReader = New-Object System.IO.StreamReader($statusResponse.GetResponseStream())
|
||||
$body = $statusReader.ReadToEnd()
|
||||
$statusReader.Close()
|
||||
$statusResponse.Close()
|
||||
|
||||
# Check if we got actual data (not a login page)
|
||||
if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
|
||||
break
|
||||
}
|
||||
|
||||
# Try alternate status endpoint
|
||||
$statusUrl2 = "$baseUrl/servlet?p=status-status"
|
||||
$statusRequest2 = [System.Net.HttpWebRequest]::Create($statusUrl2)
|
||||
$statusRequest2.Method = "GET"
|
||||
$statusRequest2.CookieContainer = $cookieContainer
|
||||
$statusRequest2.Timeout = $TimeoutMs
|
||||
$statusRequest2.ReadWriteTimeout = $TimeoutMs
|
||||
$statusRequest2.UserAgent = "Mozilla/5.0 (YealinkScanner)"
|
||||
|
||||
$statusResponse2 = $statusRequest2.GetResponse()
|
||||
$statusReader2 = New-Object System.IO.StreamReader($statusResponse2.GetResponseStream())
|
||||
$body = $statusReader2.ReadToEnd()
|
||||
$statusReader2.Close()
|
||||
$statusResponse2.Close()
|
||||
|
||||
if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
|
||||
break
|
||||
}
|
||||
$body = $null
|
||||
}
|
||||
catch {
|
||||
$phoneData.Error = $_.Exception.Message
|
||||
$body = $null
|
||||
continue
|
||||
}
|
||||
|
||||
# --- Method 2: Digest auth fallback (older firmware) ---
|
||||
$endpoints = @(
|
||||
"$baseUrl/servlet?m=mod_data&p=status-status&q=load",
|
||||
"$baseUrl/servlet?p=status-status"
|
||||
)
|
||||
foreach ($endpoint in $endpoints) {
|
||||
try {
|
||||
$body = Invoke-DigestAuthRequest -Uri $endpoint -User $User -Pass $Pass -TimeoutMs $TimeoutMs
|
||||
if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
|
||||
break
|
||||
}
|
||||
$body = $null
|
||||
}
|
||||
catch {
|
||||
$phoneData.Error = $_.Exception.Message
|
||||
$body = $null
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ($body) { break }
|
||||
}
|
||||
|
||||
if (-not $body) {
|
||||
if (-not $phoneData.Error) {
|
||||
$phoneData.Error = "No valid response from any status endpoint"
|
||||
}
|
||||
return $phoneData
|
||||
}
|
||||
|
||||
# DEBUG: dump raw response to temp file for inspection
|
||||
$debugFile = Join-Path $env:TEMP "yealink_debug_$($IPAddress -replace '\.','_').txt"
|
||||
$body | Out-File -FilePath $debugFile -Encoding UTF8 -Force
|
||||
Write-Host " DEBUG: Raw response saved to $debugFile" -ForegroundColor DarkGray
|
||||
|
||||
# --- Parse structured JSON response (mod_data endpoint) ---
|
||||
try {
|
||||
# Attempt JSON parse first (mod_data endpoint returns JSON on many models)
|
||||
$jsonData = $body | ConvertFrom-Json -ErrorAction Stop
|
||||
|
||||
# Different models use different JSON field names.
|
||||
# Common patterns observed across T2x/T4x/T5x series:
|
||||
$macFields = @("MacAddress", "MAC", "mac", "Mac_Address", "MACAddress")
|
||||
$serialFields = @("SerialNumber", "Serial", "serial", "Machine_ID", "MachineID", "SN")
|
||||
$modelFields = @("ModelName", "Model", "model", "ProductName", "Product", "DeviceModel")
|
||||
$fwFields = @("FirmwareVersion", "Firmware", "firmware", "FWVersion", "fw_version", "SoftwareVersion")
|
||||
|
||||
foreach ($field in $macFields) {
|
||||
$val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
|
||||
if ($val -and $val.Value) { $phoneData.MAC = ($val.Value -replace '-', ':').ToUpper(); break }
|
||||
}
|
||||
foreach ($field in $serialFields) {
|
||||
$val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
|
||||
if ($val -and $val.Value) { $phoneData.Serial = [string]$val.Value; break }
|
||||
}
|
||||
foreach ($field in $modelFields) {
|
||||
$val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
|
||||
if ($val -and $val.Value) { $phoneData.Model = [string]$val.Value; break }
|
||||
}
|
||||
foreach ($field in $fwFields) {
|
||||
$val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
|
||||
if ($val -and $val.Value) { $phoneData.FirmwareVersion = [string]$val.Value; break }
|
||||
}
|
||||
|
||||
# Some models nest data under a "body" or "data" property
|
||||
$nestedContainers = @("body", "data", "Body", "Data", "status")
|
||||
foreach ($container in $nestedContainers) {
|
||||
$nested = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $container } | Select-Object -First 1
|
||||
if ($nested -and $nested.Value -and $nested.Value -is [PSCustomObject]) {
|
||||
$obj = $nested.Value
|
||||
if (-not $phoneData.MAC) {
|
||||
foreach ($field in $macFields) {
|
||||
$val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
|
||||
if ($val -and $val.Value) { $phoneData.MAC = ($val.Value -replace '-', ':').ToUpper(); break }
|
||||
}
|
||||
}
|
||||
if (-not $phoneData.Serial) {
|
||||
foreach ($field in $serialFields) {
|
||||
$val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
|
||||
if ($val -and $val.Value) { $phoneData.Serial = [string]$val.Value; break }
|
||||
}
|
||||
}
|
||||
if (-not $phoneData.Model) {
|
||||
foreach ($field in $modelFields) {
|
||||
$val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
|
||||
if ($val -and $val.Value) { $phoneData.Model = [string]$val.Value; break }
|
||||
}
|
||||
}
|
||||
if (-not $phoneData.FirmwareVersion) {
|
||||
foreach ($field in $fwFields) {
|
||||
$val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
|
||||
if ($val -and $val.Value) { $phoneData.FirmwareVersion = [string]$val.Value; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Not JSON — fall through to HTML/text parsing
|
||||
}
|
||||
|
||||
# --- Fallback: Parse HTML/text response ---
|
||||
if (-not $phoneData.MAC -or -not $phoneData.Serial -or -not $phoneData.Model -or -not $phoneData.FirmwareVersion) {
|
||||
# MAC Address patterns in HTML
|
||||
if (-not $phoneData.MAC) {
|
||||
if ($body -match '(?i)(?:mac\s*(?:address)?|MAC)\s*[:=]\s*([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})') {
|
||||
$phoneData.MAC = ($Matches[1] -replace '-', ':').ToUpper()
|
||||
}
|
||||
}
|
||||
|
||||
# Serial / Machine ID
|
||||
if (-not $phoneData.Serial) {
|
||||
if ($body -match '(?i)(?:serial\s*(?:number)?|machine\s*id|SN)\s*[:=]\s*([A-Za-z0-9]+)') {
|
||||
$phoneData.Serial = $Matches[1]
|
||||
}
|
||||
}
|
||||
|
||||
# Model
|
||||
if (-not $phoneData.Model) {
|
||||
# Look for Yealink model patterns like T46S, T54W, SIP-T48G, VP59, CP920, etc.
|
||||
if ($body -match '(?i)(?:model|product\s*name|device\s*model)\s*[:=]\s*((?:SIP-)?[A-Za-z]{1,4}[\-]?[0-9]{2,4}[A-Za-z]?)') {
|
||||
$phoneData.Model = $Matches[1]
|
||||
}
|
||||
elseif ($body -match '(?i)(Yealink\s+(?:SIP-)?[A-Za-z]{1,4}[\-]?[0-9]{2,4}[A-Za-z]?)') {
|
||||
$phoneData.Model = $Matches[1]
|
||||
}
|
||||
}
|
||||
|
||||
# Firmware Version
|
||||
if (-not $phoneData.FirmwareVersion) {
|
||||
if ($body -match '(?i)(?:firmware|software)\s*(?:version)?\s*[:=]\s*([0-9]+\.[0-9]+\.[0-9]+[.\-][0-9A-Za-z.]+)') {
|
||||
$phoneData.FirmwareVersion = $Matches[1]
|
||||
}
|
||||
elseif ($body -match '(?i)(?:firmware|software)\s*(?:version)?\s*[:=]\s*(\S+)') {
|
||||
$phoneData.FirmwareVersion = $Matches[1]
|
||||
}
|
||||
}
|
||||
|
||||
# Try parsing HTML table rows (common Yealink status page format):
|
||||
# <tr><td>Label</td><td>Value</td></tr>
|
||||
$tablePattern = '<tr[^>]*>\s*<td[^>]*>\s*(?<label>[^<]+)\s*</td>\s*<td[^>]*>\s*(?<value>[^<]+)\s*</td>\s*</tr>'
|
||||
$tableMatches = [regex]::Matches($body, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
|
||||
foreach ($m in $tableMatches) {
|
||||
$label = $m.Groups['label'].Value.Trim()
|
||||
$value = $m.Groups['value'].Value.Trim()
|
||||
|
||||
if (-not $phoneData.MAC -and $label -match '(?i)mac') {
|
||||
$phoneData.MAC = ($value -replace '-', ':').ToUpper()
|
||||
}
|
||||
if (-not $phoneData.Serial -and $label -match '(?i)(serial|machine)') {
|
||||
$phoneData.Serial = $value
|
||||
}
|
||||
if (-not $phoneData.Model -and $label -match '(?i)(model|product)') {
|
||||
$phoneData.Model = $value
|
||||
}
|
||||
if (-not $phoneData.FirmwareVersion -and $label -match '(?i)(firmware|software)') {
|
||||
$phoneData.FirmwareVersion = $value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# If we got at least one meaningful field, consider it a success
|
||||
if ($phoneData.MAC -or $phoneData.Serial -or $phoneData.Model -or $phoneData.FirmwareVersion) {
|
||||
$phoneData.Success = $true
|
||||
$phoneData.Error = ""
|
||||
}
|
||||
elseif (-not $phoneData.Error) {
|
||||
$phoneData.Error = "Could not parse any fields from status page response"
|
||||
}
|
||||
|
||||
return $phoneData
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV Output
|
||||
# ---------------------------------------------------------------------------
|
||||
function Export-PhoneInventory {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Appends phone inventory data to a CSV file. Creates the file with headers if it does not exist.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[array]$PhoneRecords,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Site
|
||||
)
|
||||
|
||||
$csvRows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
foreach ($phone in $PhoneRecords) {
|
||||
$csvRows.Add([PSCustomObject]@{
|
||||
MAC = $phone.MAC
|
||||
Serial = $phone.Serial
|
||||
Model = $phone.Model
|
||||
FirmwareVersion = $phone.FirmwareVersion
|
||||
IP = $phone.IP
|
||||
SiteName = $Site
|
||||
})
|
||||
}
|
||||
|
||||
$fileExists = Test-Path -Path $FilePath -PathType Leaf
|
||||
if ($fileExists) {
|
||||
# Append without header
|
||||
$csvRows | Export-Csv -Path $FilePath -NoTypeInformation -Append -Force
|
||||
}
|
||||
else {
|
||||
$csvRows | Export-Csv -Path $FilePath -NoTypeInformation -Force
|
||||
}
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN EXECUTION
|
||||
# ===========================================================================
|
||||
|
||||
$scriptStartTime = Get-Date
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " Yealink Phone Scanner" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " Subnet: $Subnet"
|
||||
Write-Host " Site: $SiteName"
|
||||
Write-Host " Output: $OutputFile"
|
||||
Write-Host " Timeout: ${Timeout}ms"
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# --- Step 1: Calculate subnet addresses ---
|
||||
Write-Host "[INFO] Calculating IP addresses for $Subnet..." -ForegroundColor Yellow
|
||||
try {
|
||||
$ipList = Get-SubnetAddresses -CidrSubnet $Subnet
|
||||
}
|
||||
catch {
|
||||
Write-Host "[ERROR] Invalid subnet: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[OK] $($ipList.Count) host addresses in range." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# --- Step 2: Ping sweep to populate ARP table ---
|
||||
Write-Host "[INFO] Pinging $($ipList.Count) addresses to populate ARP table..." -ForegroundColor Yellow
|
||||
|
||||
# Use ping in batches — send async pings using .NET Ping.SendPingAsync for speed
|
||||
$pingTasks = [System.Collections.Generic.List[object]]::new()
|
||||
$batchSize = 100
|
||||
for ($i = 0; $i -lt $ipList.Count; $i += $batchSize) {
|
||||
$batch = $ipList[$i..[math]::Min($i + $batchSize - 1, $ipList.Count - 1)]
|
||||
foreach ($ip in $batch) {
|
||||
$pinger = New-Object System.Net.NetworkInformation.Ping
|
||||
try {
|
||||
$task = $pinger.SendPingAsync($ip, $Timeout)
|
||||
$pingTasks.Add(@{ Task = $task; Pinger = $pinger })
|
||||
}
|
||||
catch {
|
||||
$pinger.Dispose()
|
||||
}
|
||||
}
|
||||
# Wait for this batch to finish
|
||||
foreach ($pt in $pingTasks) {
|
||||
try { [void]$pt.Task.Wait(($Timeout + 500)) } catch {}
|
||||
$pt.Pinger.Dispose()
|
||||
}
|
||||
$pingTasks.Clear()
|
||||
$done = [math]::Min($i + $batchSize, $ipList.Count)
|
||||
$pct = [math]::Round(($done / $ipList.Count) * 100)
|
||||
Write-Host "`r Ping progress: $done/$($ipList.Count) ($pct%)" -NoNewline
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "[OK] Ping sweep complete." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# --- Step 3: Parse ARP table for Yealink MACs within our subnet ---
|
||||
Write-Host "[INFO] Scanning ARP table for Yealink MAC prefixes..." -ForegroundColor Yellow
|
||||
|
||||
$yealinkDevices = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# Build a HashSet of IPs in our target subnet for fast lookup
|
||||
$subnetIPs = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach ($ip in $ipList) { [void]$subnetIPs.Add($ip) }
|
||||
|
||||
# Parse the full ARP table
|
||||
$arpLines = & arp -a 2>$null
|
||||
foreach ($line in $arpLines) {
|
||||
$line = $line.Trim()
|
||||
if ($line -match '^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+([\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2})') {
|
||||
$ip = $Matches[1]
|
||||
$mac = $Matches[2].ToUpper() -replace '-', ':'
|
||||
if ($subnetIPs.Contains($ip) -and (Test-YealinkMac -Mac $mac)) {
|
||||
$yealinkDevices.Add([PSCustomObject]@{
|
||||
IP = $ip
|
||||
MAC = $mac
|
||||
})
|
||||
Write-Host " Found: $ip -> $mac" -ForegroundColor Cyan
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Also try Get-NetNeighbor as a fallback (more reliable on Windows 10/11)
|
||||
try {
|
||||
$neighbors = Get-NetNeighbor -State Reachable,Stale,Delay,Probe -ErrorAction SilentlyContinue
|
||||
foreach ($n in $neighbors) {
|
||||
$ip = $n.IPAddress
|
||||
$mac = ($n.LinkLayerAddress -replace '-', ':').ToUpper()
|
||||
if ($subnetIPs.Contains($ip) -and (Test-YealinkMac -Mac $mac)) {
|
||||
# Avoid duplicates
|
||||
$already = $yealinkDevices | Where-Object { $_.IP -eq $ip }
|
||||
if (-not $already) {
|
||||
$yealinkDevices.Add([PSCustomObject]@{
|
||||
IP = $ip
|
||||
MAC = $mac
|
||||
})
|
||||
Write-Host " Found: $ip -> $mac (via NetNeighbor)" -ForegroundColor Cyan
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " [INFO] Get-NetNeighbor not available, using ARP table only." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
Write-Host "[OK] Found $($yealinkDevices.Count) Yealink devices." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
if ($yealinkDevices.Count -eq 0) {
|
||||
Write-Host "[WARNING] No Yealink devices detected on this subnet. Exiting." -ForegroundColor DarkYellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Display detected devices
|
||||
Write-Host " Detected Yealink devices:" -ForegroundColor Cyan
|
||||
foreach ($dev in $yealinkDevices) {
|
||||
Write-Host " $($dev.IP) ($($dev.MAC))"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# --- Step 4: Query each Yealink phone's web UI ---
|
||||
Write-Host "[INFO] Querying Yealink phone web UIs for inventory data..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
$successfulScrapes = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$failedScrapes = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
$deviceIndex = 0
|
||||
foreach ($device in $yealinkDevices) {
|
||||
$deviceIndex++
|
||||
Write-Host " [$deviceIndex/$($yealinkDevices.Count)] Querying $($device.IP) ($($device.MAC))..." -NoNewline
|
||||
|
||||
$phoneData = Get-YealinkPhoneData -IPAddress $device.IP -User $Username -Pass $Password -TimeoutMs ($Timeout * 5)
|
||||
|
||||
# If we didn't get MAC from the web UI, use the ARP MAC
|
||||
if (-not $phoneData.MAC) {
|
||||
$phoneData.MAC = $device.MAC
|
||||
}
|
||||
|
||||
if ($phoneData.Success) {
|
||||
$successfulScrapes.Add($phoneData)
|
||||
Write-Host " [OK] $($phoneData.Model) / $($phoneData.Serial)" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
$failedScrapes.Add([PSCustomObject]@{
|
||||
IP = $device.IP
|
||||
MAC = $device.MAC
|
||||
Error = $phoneData.Error
|
||||
})
|
||||
Write-Host " [FAILED] $($phoneData.Error)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# --- Step 5: Output results ---
|
||||
if ($successfulScrapes.Count -gt 0) {
|
||||
# Write to CSV
|
||||
Write-Host "[INFO] Writing $($successfulScrapes.Count) records to $OutputFile..." -ForegroundColor Yellow
|
||||
try {
|
||||
Export-PhoneInventory -PhoneRecords $successfulScrapes -FilePath $OutputFile -Site $SiteName
|
||||
Write-Host "[OK] CSV updated: $OutputFile" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Host "[ERROR] Failed to write CSV: $_" -ForegroundColor Red
|
||||
}
|
||||
|
||||
# Display results table
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " Scan Results" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
$successfulScrapes | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
IP = $_.IP
|
||||
MAC = $_.MAC
|
||||
Model = $_.Model
|
||||
Serial = $_.Serial
|
||||
FirmwareVersion = $_.FirmwareVersion
|
||||
}
|
||||
} | Format-Table -AutoSize | Out-String | Write-Host
|
||||
}
|
||||
|
||||
# Report failures
|
||||
if ($failedScrapes.Count -gt 0) {
|
||||
Write-Host " Failed devices:" -ForegroundColor Red
|
||||
foreach ($fail in $failedScrapes) {
|
||||
Write-Host " $($fail.IP) ($($fail.MAC)): $($fail.Error)" -ForegroundColor Red
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# --- Summary ---
|
||||
$elapsed = (Get-Date) - $scriptStartTime
|
||||
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " Summary" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " Site: $SiteName"
|
||||
Write-Host " Subnet: $Subnet"
|
||||
Write-Host " IPs scanned: $($ipList.Count)"
|
||||
Write-Host " Yealink detected: $($yealinkDevices.Count)"
|
||||
Write-Host " Successfully scraped: $($successfulScrapes.Count)" -ForegroundColor Green
|
||||
Write-Host " Failed: $($failedScrapes.Count)" -ForegroundColor $(if ($failedScrapes.Count -gt 0) { "Red" } else { "Green" })
|
||||
Write-Host " Elapsed time: $([math]::Round($elapsed.TotalSeconds, 1))s"
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
183
tools/test-yealink.ps1
Normal file
183
tools/test-yealink.ps1
Normal file
@@ -0,0 +1,183 @@
|
||||
# Test SSDP/UPnP discovery for Yealink phones
|
||||
param(
|
||||
[string]$IP = "172.16.1.29",
|
||||
[int]$DiscoveryTimeout = 5
|
||||
)
|
||||
|
||||
# --- Test 1: SSDP M-SEARCH broadcast ---
|
||||
Write-Host "=== Test 1: SSDP M-SEARCH (broadcast) ===" -ForegroundColor Yellow
|
||||
Write-Host " Sending M-SEARCH to 239.255.255.250:1900..." -ForegroundColor DarkGray
|
||||
|
||||
$ssdpMessage = @"
|
||||
M-SEARCH * HTTP/1.1
|
||||
HOST: 239.255.255.250:1900
|
||||
MAN: "ssdp:discover"
|
||||
MX: 3
|
||||
ST: ssdp:all
|
||||
|
||||
"@
|
||||
|
||||
$ssdpBytes = [System.Text.Encoding]::ASCII.GetBytes($ssdpMessage.Replace("`n", "`r`n"))
|
||||
$udpClient = New-Object System.Net.Sockets.UdpClient
|
||||
$udpClient.Client.ReceiveTimeout = ($DiscoveryTimeout * 1000)
|
||||
$udpClient.Client.SetSocketOption([System.Net.Sockets.SocketOptionLevel]::Socket,
|
||||
[System.Net.Sockets.SocketOptionName]::ReuseAddress, $true)
|
||||
|
||||
$multicastEp = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Parse("239.255.255.250"), 1900)
|
||||
$udpClient.Send($ssdpBytes, $ssdpBytes.Length, $multicastEp) | Out-Null
|
||||
|
||||
$responses = @()
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
while ($sw.Elapsed.TotalSeconds -lt $DiscoveryTimeout) {
|
||||
try {
|
||||
$remoteEp = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)
|
||||
$data = $udpClient.Receive([ref]$remoteEp)
|
||||
$response = [System.Text.Encoding]::ASCII.GetString($data)
|
||||
$sourceIp = $remoteEp.Address.ToString()
|
||||
|
||||
if ($response -match 'yealink|Yealink|YEALINK|SIP-T') {
|
||||
Write-Host " [YEALINK] Response from $sourceIp" -ForegroundColor Green
|
||||
Write-Host $response -ForegroundColor Cyan
|
||||
$responses += @{ IP = $sourceIp; Response = $response }
|
||||
}
|
||||
elseif ($sourceIp -eq $IP) {
|
||||
Write-Host " Response from TARGET $sourceIp" -ForegroundColor Green
|
||||
Write-Host $response -ForegroundColor Cyan
|
||||
$responses += @{ IP = $sourceIp; Response = $response }
|
||||
}
|
||||
}
|
||||
catch [System.Net.Sockets.SocketException] {
|
||||
break # Timeout
|
||||
}
|
||||
}
|
||||
$udpClient.Close()
|
||||
Write-Host " Received $($responses.Count) Yealink/target responses" -ForegroundColor DarkGray
|
||||
|
||||
# --- Test 2: Direct SSDP to the phone's IP ---
|
||||
Write-Host ""
|
||||
Write-Host "=== Test 2: Direct SSDP M-SEARCH to $IP ===" -ForegroundColor Yellow
|
||||
|
||||
$udpClient2 = New-Object System.Net.Sockets.UdpClient
|
||||
$udpClient2.Client.ReceiveTimeout = 3000
|
||||
|
||||
$directEp = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Parse($IP), 1900)
|
||||
$udpClient2.Send($ssdpBytes, $ssdpBytes.Length, $directEp) | Out-Null
|
||||
|
||||
try {
|
||||
$remoteEp2 = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)
|
||||
$data2 = $udpClient2.Receive([ref]$remoteEp2)
|
||||
$response2 = [System.Text.Encoding]::ASCII.GetString($data2)
|
||||
Write-Host " Response from $($remoteEp2.Address):" -ForegroundColor Green
|
||||
Write-Host $response2 -ForegroundColor Cyan
|
||||
}
|
||||
catch {
|
||||
Write-Host " No response (timeout)" -ForegroundColor DarkYellow
|
||||
}
|
||||
$udpClient2.Close()
|
||||
|
||||
# --- Test 3: Try fetching UPnP device description if we got a LOCATION header ---
|
||||
Write-Host ""
|
||||
Write-Host "=== Test 3: UPnP device description URLs ===" -ForegroundColor Yellow
|
||||
|
||||
# Try common UPnP description URLs
|
||||
$descUrls = @(
|
||||
"http://${IP}:1900/description.xml",
|
||||
"http://${IP}/description.xml",
|
||||
"http://${IP}:49152/description.xml",
|
||||
"http://${IP}:5060/description.xml",
|
||||
"http://${IP}/DeviceDescription.xml",
|
||||
"http://${IP}/upnp/description.xml"
|
||||
)
|
||||
|
||||
# Also extract LOCATION from any SSDP responses
|
||||
foreach ($r in $responses) {
|
||||
if ($r.Response -match 'LOCATION:\s*(http[^\s\r\n]+)') {
|
||||
$loc = $Matches[1].Trim()
|
||||
if ($descUrls -notcontains $loc) { $descUrls = @($loc) + $descUrls }
|
||||
}
|
||||
}
|
||||
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||
[System.Net.ServicePointManager]::Expect100Continue = $false
|
||||
|
||||
# Enable unsafe header parsing
|
||||
$netAssembly = [System.Reflection.Assembly]::GetAssembly([System.Net.Configuration.SettingsSection])
|
||||
if ($netAssembly) {
|
||||
$settingsType = $netAssembly.GetType("System.Net.Configuration.SettingsSectionInternal")
|
||||
if ($settingsType) {
|
||||
$bf = [System.Reflection.BindingFlags]::Static -bor [System.Reflection.BindingFlags]::GetProperty -bor [System.Reflection.BindingFlags]::NonPublic
|
||||
$instance = $settingsType.InvokeMember("Section", $bf, $null, $null, @())
|
||||
if ($instance) {
|
||||
$field = $settingsType.GetField("useUnsafeHeaderParsing", [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance)
|
||||
if ($field) { $field.SetValue($instance, $true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($url in $descUrls) {
|
||||
Write-Host " Trying: $url" -ForegroundColor DarkGray -NoNewline
|
||||
try {
|
||||
$req = [System.Net.HttpWebRequest]::Create($url)
|
||||
$req.Timeout = 3000
|
||||
$req.UserAgent = "UPnP/1.0"
|
||||
$resp = $req.GetResponse()
|
||||
$reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
|
||||
$body = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
$resp.Close()
|
||||
Write-Host " -> OK ($($body.Length) chars)" -ForegroundColor Green
|
||||
Write-Host $body -ForegroundColor Cyan
|
||||
|
||||
# Save if it looks like XML device description
|
||||
if ($body -match 'serialNumber|modelName|manufacturer') {
|
||||
Write-Host ""
|
||||
Write-Host " [FOUND] Device description with useful fields!" -ForegroundColor Green
|
||||
$body | Out-File "C:\temp\yealink_upnp.xml" -Encoding UTF8
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " -> FAIL" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# --- Test 4: Try SNMP if available ---
|
||||
Write-Host ""
|
||||
Write-Host "=== Test 4: SNMP probe (community: public) ===" -ForegroundColor Yellow
|
||||
Write-Host " Trying SNMPv2c GET on common OIDs..." -ForegroundColor DarkGray
|
||||
|
||||
# Build a simple SNMPv2c GET request for sysDescr (1.3.6.1.2.1.1.1.0)
|
||||
# This is a minimal hand-crafted SNMP packet
|
||||
$snmpGet = [byte[]]@(
|
||||
0x30, 0x29, # SEQUENCE, length 41
|
||||
0x02, 0x01, 0x01, # INTEGER: version = 1 (SNMPv2c)
|
||||
0x04, 0x06, # OCTET STRING: community
|
||||
0x70, 0x75, 0x62, 0x6C, 0x69, 0x63, # "public"
|
||||
0xA0, 0x1C, # GET-REQUEST, length 28
|
||||
0x02, 0x04, 0x01, 0x02, 0x03, 0x04, # request-id
|
||||
0x02, 0x01, 0x00, # error-status: 0
|
||||
0x02, 0x01, 0x00, # error-index: 0
|
||||
0x30, 0x0E, # varbind list
|
||||
0x30, 0x0C, # varbind
|
||||
0x06, 0x08, # OID
|
||||
0x2B, 0x06, 0x01, 0x02, 0x01, 0x01, 0x01, 0x00, # 1.3.6.1.2.1.1.1.0 (sysDescr)
|
||||
0x05, 0x00 # NULL
|
||||
)
|
||||
|
||||
try {
|
||||
$snmpClient = New-Object System.Net.Sockets.UdpClient
|
||||
$snmpClient.Client.ReceiveTimeout = 3000
|
||||
$snmpEp = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Parse($IP), 161)
|
||||
$snmpClient.Send($snmpGet, $snmpGet.Length, $snmpEp) | Out-Null
|
||||
|
||||
$snmpRemote = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)
|
||||
$snmpData = $snmpClient.Receive([ref]$snmpRemote)
|
||||
$snmpResponse = [System.Text.Encoding]::ASCII.GetString($snmpData)
|
||||
Write-Host " SNMP response received ($($snmpData.Length) bytes)" -ForegroundColor Green
|
||||
# Try to extract readable text from the response
|
||||
$readable = ($snmpData | ForEach-Object { if ($_ -ge 32 -and $_ -le 126) { [char]$_ } else { "." } }) -join ""
|
||||
Write-Host " Readable: $readable" -ForegroundColor Cyan
|
||||
$snmpClient.Close()
|
||||
}
|
||||
catch {
|
||||
Write-Host " No SNMP response (timeout or blocked)" -ForegroundColor DarkYellow
|
||||
}
|
||||
338
tools/yealink-serial-scanner.html
Normal file
338
tools/yealink-serial-scanner.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user