From c929176cfa6dfa5b210333ad971c8941d4540fda Mon Sep 17 00:00:00 2001 From: Kaushik Date: Thu, 16 Jul 2026 17:31:33 +0530 Subject: [PATCH] feat: per-site SHIFT path with video search, fix agent token injection, accept cpu_temp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy_agent.sh: agent v3.3-shift — use dashboard-configured SHIFT path for file fetch/search (SR/SHIFT sites have no TAKELEAP folder), date fast-path; fix installer discarding the injected agent token (sentinel was being rewritten by the server's placeholder replace, agents ended up tokenless) - central_api_prototype.py: replace only the token assignment when serving the installer; add cpu_temp to TelemetryPayload (agents already send it, it was silently dropped); new endpoints set-shift-path, request-search, search-results; heartbeat response now carries shift_path + pending search - commands.json: restore last_reboot/dummy, add update_diag diagnostic - CLAUDE.md: document deployment layout, procedures, and gotchas --- CLAUDE.md | 124 +++++++++++++++++++++++++++++++++++++++ central_api_prototype.py | 81 +++++++++++++++++++++++-- commands.json | 15 ++++- deploy_agent.sh | 112 ++++++++++++++++++++++++++++++++++- 4 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..64c74e7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# SeekRight Pulse RMM — Production Deployment Map + +This VM (`vm3-mint`, LAN `192.168.1.201`, Tailscale `100.79.183.41`) is the +**production server** for the SeekRight Pulse RMM system. Field agents and the +public dashboard both talk to services running here. + +## Architecture + +``` +Internet + │ + ▼ +Synology reverse proxy (takeleapindia.synology.me / 106.51.70.111) + ├── https://rmm.seekright.com → this VM : vite port (currently 4173) + └── https://rmm-backend.seekright.com → this VM : 8000 + │ +Field agents (X-Agent-Token) ───────────────────┤ + ▼ + FastAPI backend (uvicorn :8000, 4 workers) + │ + MongoDB localhost:27017, db `rmm_db` +``` + +## Components + +### Backend — `/opt/rmm-backend` +- FastAPI app `central_api_prototype.py`, run by **systemd `rmm-backend.service`** + (User=root): `venv/bin/uvicorn central_api_prototype:app --host 0.0.0.0 --port 8000 --workers 4` +- Env: systemd `EnvironmentFile=/opt/rmm-backend/.env` injects vars **at service + start**. The `APP_ENV` / `.env.production` / `.env.development` logic in the + code is mostly vestigial here: values from `.env` are already in the process + environment and `load_dotenv` never overrides them. **Editing `.env` requires + a service restart to take effect.** +- Data: MongoDB `rmm_db` (`clients`, `logs`, `config` collections). Alerts go to + Rocket.Chat via `ROCKETCHAT_WEBHOOK_URL`. +- `commands.json` = whitelist of commands agents may run. Read from disk **per + request** (no restart needed). Also editable live from the dashboard config + editor (`/api` load/save endpoints) — meaning production edits land in the + working tree and will conflict with `git pull` (see gotchas). +- Agent auth: agents send `X-Agent-Token` (value `AGENT_TOKEN` in `.env`). Mode + lives in Mongo `config._id=agent_auth`: `grace` (default — tokenless legacy + agents still accepted) or `strict` (reject without token). Flip with + `set_strict.py` only after every field agent has been updated + (`deploy_agent.sh` / the `update_agent` command). +- Dashboard login: `POST /api/login`, credentials `DASHBOARD_USERNAME` / + `DASHBOARD_PASSWORD` from `.env`, JWT signed with `JWT_SECRET_KEY`. + +### Frontend — `/opt/rmm-ui` +- React + Vite, run by **systemd `rmm-frontend.service`** (User=root): + `npm run dev -- --mode production` (a vite **dev server**, not a static build). +- Port is set in `vite.config.js` (`server.port`, currently **4173** — must + match the Synology reverse-proxy upstream); + `allowedHosts: ["rmm.seekright.com"]` must include the public hostname or + vite rejects proxied requests. +- Vite watches files: pulled code changes hot-reload automatically, and a + `vite.config.js` change makes vite restart itself — BUT an in-process + restart keeps whatever port vite already bound (including an auto-bumped + one like 4174 after a port collision). To change ports for real: + `sudo systemctl restart rmm-frontend.service`. +- API base URL comes from `/opt/rmm-ui/.env.production` + (`VITE_API_BASE_URL=https://rmm-backend.seekright.com`). + +### Reverse proxy — Synology (NOT on this VM) +- No nginx/apache runs on this VM. TLS + routing for both public hostnames is + done on the Synology at `takeleapindia.synology.me` (106.51.70.111). +- **If the vite port changes in `vite.config.js`, the Synology reverse-proxy + upstream port must be updated to match** — otherwise the site 502s. + This exact thing happened 2026-07-16 (commit `dc604a4` moved 4173 → 6173). + +### Field agents (remote machines) +- Run `/opt/seekright-agent/client_agent_prototype.py` on each monitored node; + poll `GET /api/get-command` and push `POST /api/telemetry`. +- Installed/updated via `deploy_agent.sh` (served by the backend; in strict + mode downloading it requires a valid agent token). + +## How to deploy + +Backend: +```bash +cd /opt/rmm-backend +git status # working tree should be clean — see gotchas +git pull +sudo systemctl restart rmm-backend.service +systemctl status rmm-backend.service --no-pager -n 20 # check for crash loop +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/docs # expect 200 +``` + +Frontend: +```bash +cd /opt/rmm-ui +git pull # vite hot-reloads; restarts itself on config change +# only if deps changed: npm install && sudo systemctl restart rmm-frontend.service +curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: rmm.seekright.com' http://localhost:4173/ +curl -sk -o /dev/null -w '%{http_code}\n' https://rmm.seekright.com/ # via Synology +``` + +## Gotchas / history + +- **Don't hand-edit tracked files on this server** (`commands.json`, `.env` + are tracked in git). Local edits block `git pull`. Commit changes to the repo + instead. Dashboard edits to `commands.json` also dirty the tree — push them + upstream after changing them. +- 2026-07-16: pull of `dc604a4` changed the vite port 4173 → 6173 and broke + https://rmm.seekright.com (Synology still forwarded to 4173). Resolved by + reverting `vite.config.js` to port 4173 in the working tree. That revert is + a LOCAL change to a tracked file — commit and push it (or the next pull + will conflict and re-break the site by pulling 6173 back in). +- The backend service must be restarted after every backend pull — uvicorn has + no auto-reload in the unit. A long-running process can silently serve + weeks-old code (happened Jul 6 → Jul 16). +- Adding a telemetry field takes THREE places: agent payload (in + `deploy_agent.sh`), `TelemetryPayload` in `central_api_prototype.py`, and the + UI. Pydantic silently DROPS any field missing from `TelemetryPayload` — + agents sent `cpu_temp` for a while with the backend discarding it and no + error anywhere (found 2026-07-16). +- Ports on this VM: 8000 backend, 6173 rmm-ui vite, 5173 auditor-portal vite + (localhost only), 7514 + others auditor portal, 4430–4433 MeshCentral, + Grafana also runs here. Check `ss -tlnp` before assigning a new port. + +## Other services on this VM (not part of RMM deploys) + +- `meshcentral.service` — MeshCentral remote management (node, ports 4430–4433) +- `grafana` — monitoring dashboards +- `/opt/auditor-portal` — separate project (its own vite/node dev servers) diff --git a/central_api_prototype.py b/central_api_prototype.py index b35961e..4486dea 100644 --- a/central_api_prototype.py +++ b/central_api_prototype.py @@ -325,6 +325,7 @@ class TelemetryPayload(BaseModel): memory_free_gb: float disks: List[Dict[str, Any]] gpus: List[Dict[str, Any]] + cpu_temp: Optional[float] = None agent_version: Optional[str] = None speed_upload_mbps: Optional[float] = None speed_download_mbps: Optional[float] = None @@ -335,6 +336,12 @@ class CommandResultPayload(BaseModel): stdout: str stderr: str +class SearchResultsPayload(BaseModel): + query: str + results: List[Dict[str, Any]] + searched_path: Optional[str] = None + error: Optional[str] = None + class LoginPayload(BaseModel): username: str password: str @@ -735,6 +742,57 @@ async def request_file(client_id: str, filename: str, current_user: str = Depend append_to_logs("file_requested", client_id, {"filename": safe_name}) return {"status": "success", "message": f"File '{safe_name}' requested from {client_id}"} +@app.post("/api/set-shift-path") +async def set_shift_path(client_id: str, shift_path: str = "", current_user: str = Depends(get_current_user)): + """ + Store where this node keeps its SHIFT folder (varies per site: + /mnt//SR/SHIFT or /mnt//TAKELEAP/SHIFT). + Delivered to the agent in every heartbeat response. + """ + path = shift_path.strip() + result = clients_collection.update_one({"_id": client_id}, {"$set": {"shift_path": path}}) + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Unknown client_id") + append_to_logs("shift_path_set", client_id, {"shift_path": path}) + return {"status": "success", "shift_path": path} + +@app.post("/api/request-search") +async def request_search(client_id: str, query: str, current_user: str = Depends(get_current_user)): + """ + Queue a filename search in the node's SHIFT folder. The agent picks the + query up on its next heartbeat and posts matches to /api/search-results. + """ + q = query.strip() + if not q: + raise HTTPException(status_code=400, detail="Empty search query") + now_str = datetime.now().isoformat() + result = clients_collection.update_one( + {"_id": client_id}, + {"$set": {"pending_search": q, + "file_search": {"query": q, "status": "searching", "results": [], "requested_at": now_str}}} + ) + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Unknown client_id") + append_to_logs("search_requested", client_id, {"query": q}) + return {"status": "success"} + +@app.post("/api/search-results") +async def receive_search_results(client_id: str, payload: SearchResultsPayload, token_valid: bool = Depends(require_agent_token)): + now_str = datetime.now().isoformat() + clients_collection.update_one( + {"_id": client_id}, + {"$set": {"file_search": { + "query": payload.query, + "status": "error" if payload.error else "done", + "results": payload.results[:200], + "searched_path": payload.searched_path, + "error": payload.error, + "completed_at": now_str, + }}} + ) + append_to_logs("search_results", client_id, {"query": payload.query, "count": len(payload.results), "error": payload.error}) + return {"status": "success"} + @app.get("/api/get-file-request") async def get_file_request(client_id: str, token_valid: bool = Depends(require_agent_token)): if not client_id or not client_id.strip(): @@ -907,7 +965,14 @@ async def get_agent_installer(x_agent_token: str = Header(None), token: Optional # the live agent token so a freshly downloaded installer bakes it into the agent. with open(path, "rb") as f: content = f.read().replace(b"\r\n", b"\n") - content = content.replace(b"__AGENT_TOKEN__", AGENT_TOKEN.encode("utf-8")) + # Replace only the assignment, not the "was I injected?" sentinel comparison + # a few lines below it — a blanket replace turns that check into an + # always-true self-comparison and the installer discards the injected token. + content = content.replace( + b'AGENT_TOKEN="__AGENT_TOKEN__"', + b'AGENT_TOKEN="' + AGENT_TOKEN.encode("utf-8") + b'"', + 1, + ) return Response( content=content, media_type="text/x-shellscript", @@ -949,20 +1014,26 @@ async def receive_heartbeat(client_id: str, payload: TelemetryPayload, ) doc = clients_collection.find_one_and_update( {"_id": client_id}, - {"$set": {"pending_command": "none", "pending_file_request": "none"}}, + {"$set": {"pending_command": "none", "pending_file_request": "none", "pending_search": "none"}}, return_document=False ) cmd = "none" fname = "none" + search = "none" + shift_path = None if doc: cmd = doc.get("pending_command", "none") fname = doc.get("pending_file_request", "none") - + search = doc.get("pending_search", "none") or "none" + shift_path = doc.get("shift_path") or None + if cmd != "none": append_to_logs("command_polled", client_id, {"command": cmd}) - + return { "status": "success", "command": cmd, - "filename": fname + "filename": fname, + "search": search, + "shift_path": shift_path } diff --git a/commands.json b/commands.json index 8b383c3..b4e7ab8 100644 --- a/commands.json +++ b/commands.json @@ -324,6 +324,19 @@ "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap-sub-files", "UPLOAD_FOLDER" + ], + "last_reboot": [ + "last", + "reboot" + ], + "dummy": [ + "last", + "reboot" + ], + "update_diag": [ + "bash", + "-c", + "echo HOST:$(hostname); echo USER:$(id -un); echo MACHINE_ID:$(cat /etc/machine-id 2>/dev/null); echo AGENT_PROCS:; pgrep -af client_agent_prototype 2>/dev/null; echo APT_PROCS:; ps aux | grep -E \"apt-get|apt |dpkg|unattended\" | grep -v grep; echo UPDATE_UNITS:; systemctl list-units \"run-r*\" --no-legend --all 2>&1 | head -5" ] } -} \ No newline at end of file +} diff --git a/deploy_agent.sh b/deploy_agent.sh index 8d1e880..7f759c9 100644 --- a/deploy_agent.sh +++ b/deploy_agent.sh @@ -40,7 +40,9 @@ if [ -z "$CENTRAL_URL" ]; then CENTRAL_URL="http://rmm-backend.seekright.com"; f AGENT_TOKEN="__AGENT_TOKEN__" if [ -n "$ARG_TOKEN" ]; then AGENT_TOKEN="$ARG_TOKEN" -elif [ "$AGENT_TOKEN" = "__AGENT_TOKEN__" ]; then +elif [ "$AGENT_TOKEN" = "__AGENT_""TOKEN__" ]; then + # (placeholder split in two so the server's token injection can't rewrite this + # comparison — it must only replace the assignment above) if [ -f "$EXISTING_PY" ]; then AGENT_TOKEN="$(sed -n 's/^AGENT_TOKEN = "\(.*\)"/\1/p' "$EXISTING_PY" | head -1)" else @@ -99,7 +101,7 @@ def get_cpu_temp(): pass return None -AGENT_VERSION = "3.2-auth" +AGENT_VERSION = "3.3-shift" CLIENT_ID = "TEMPLATE_CLIENT_ID" CENTRAL_BASE = "TEMPLATE_CENTRAL_URL" @@ -123,6 +125,8 @@ CENTRAL_FILE_STATUS_URL = f"{CENTRAL_BASE}/api/file-transfer-status?client_id={C CACHED_COMMANDS = {} command_in_progress = False file_transfer_in_progress = False +SHIFT_PATH = None # per-site SHIFT folder, configured on the dashboard, delivered via heartbeat +search_in_progress = False def run_command_async(command_key, safe_cmd_list, is_shutdown_or_restart): global command_in_progress @@ -367,7 +371,23 @@ def send_heartbeat(): t = threading.Thread(target=handle_file_request_async, args=(fname,)) t.daemon = True t.start() - + + # Server-configured SHIFT folder for this node + global SHIFT_PATH + sp = res.get("shift_path") + if sp: + SHIFT_PATH = sp + + # Handle video search requests + squery = res.get("search", "none") + if squery and squery != "none": + global search_in_progress + if not search_in_progress: + search_in_progress = True + t = threading.Thread(target=run_file_search, args=(squery,)) + t.daemon = True + t.start() + print("[*] Heartbeat successful.") except Exception as e: print(f"[!] Failed heartbeat: {e}") @@ -685,6 +705,75 @@ def list_takeleap_subfolder_files(subfolder_name): def list_takeleap_subfolder(subfolder_name): list_takeleap_subfolder_files(subfolder_name) +def find_shift_roots(): + # The dashboard-configured path wins; without one, fall back to any + # */TAKELEAP/SHIFT discovered by the legacy drive scan. + if SHIFT_PATH and os.path.isdir(SHIFT_PATH): + return [SHIFT_PATH] + roots = [] + if os.name == "nt": + drives = [f"{letter}:\\" for letter in string.ascii_uppercase if os.path.exists(f"{letter}:\\")] + else: + drives = ["/mnt", "/media", "/home"] + if os.path.exists("/TAKELEAP"): + drives.append("/TAKELEAP") + for drive in drives: + for root, dirs, files in os.walk(drive, topdown=True): + if os.name == "nt": + dirs[:] = [d for d in dirs if d.lower() not in {"windows", "program files", "program files (x86)", "appdata", "programdata", "$recycle.bin", "system volume information", "node_modules", ".git"}] + else: + if root == "/": + dirs[:] = [d for d in dirs if d.lower() not in {"proc", "sys", "dev", "var", "lib", "run", "boot", "snap", "node_modules", ".git"}] + elif root == "/mnt": + dirs[:] = [d for d in dirs if not (len(d) == 1 and d.isalpha()) and d.lower() not in {"wslg", "wsl"}] + if os.path.basename(root).upper() == "TAKELEAP": + for sub in dirs: + if sub.upper() == "SHIFT": + roots.append(os.path.join(root, sub)) + dirs[:] = [] # don't descend into an already-claimed subtree + return roots + +def run_file_search(query): + # Case-insensitive filename substring search in the SHIFT folder(s); + # results are posted back for the dashboard to render. + global search_in_progress + MAX_RESULTS = 200 + try: + target = query.lower() + roots = find_shift_roots() + results = [] + for base in roots: + for froot, fdirs, ffiles in os.walk(base): + for f in ffiles: + if target in f.lower(): + fp = os.path.join(froot, f) + entry = {"name": f, "path": fp} + try: + st = os.stat(fp) + entry["size_mb"] = round(st.st_size / (1024 * 1024), 2) + entry["modified"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_mtime)) + except Exception: + pass + results.append(entry) + if len(results) >= MAX_RESULTS: + break + if len(results) >= MAX_RESULTS: + break + if len(results) >= MAX_RESULTS: + break + payload = {"query": query, "results": results, "searched_path": ", ".join(roots)} + if not roots: + payload["error"] = "No SHIFT folder configured or found on this system" + data = json.dumps(payload).encode('utf-8') + req = urllib.request.Request(f"{CENTRAL_BASE}/api/search-results?client_id={CLIENT_ID}", data=data, headers={'Content-Type': 'application/json'}, method='POST') + req.add_header('ngrok-skip-browser-warning', 'true') + urllib.request.urlopen(req, timeout=30) + print(f" -> Search '{query}': posted {len(results)} match(es).") + except Exception as e: + print(f" -> [!] File search failed: {e}") + finally: + search_in_progress = False + # ============================================================ # Remote File Transfer: dashboard requests a file by name, # agent finds it under any TAKELEAP folder and uploads it. @@ -725,6 +814,23 @@ def find_file_in_takeleap(filename): except Exception: pass + # Configured SHIFT path takes priority — SR/SHIFT sites have no TAKELEAP + # folder anywhere, so the legacy scan below can never find their files. + if SHIFT_PATH and os.path.isdir(SHIFT_PATH): + if fast_path_rel: + # SHIFT_PATH ends in .../SHIFT and fast_path_rel starts with SHIFT/, + # so join the date subpath onto SHIFT_PATH's parent. + specific_dir = os.path.join(os.path.dirname(SHIFT_PATH), fast_path_rel) + if os.path.exists(specific_dir): + for froot, fdirs, ffiles in os.walk(specific_dir): + for f in ffiles: + if f.lower() == target: + return os.path.join(froot, f) + for froot, fdirs, ffiles in os.walk(SHIFT_PATH): + for f in ffiles: + if f.lower() == target: + return os.path.join(froot, f) + if os.name == "nt": drives = [f"{letter}:\\" for letter in string.ascii_uppercase if os.path.exists(f"{letter}:\\")] else: