diff --git a/.env b/.env index 6d60ed6..a9caf8d 100644 --- a/.env +++ b/.env @@ -1,3 +1,12 @@ ROCKETCHAT_WEBHOOK_URL="https://text.seekright.com/hooks/6a0eb41ea88367bc6e101f0c/gvXfD8zSNRti43kEabqdE9tMCvNi9zAAoGfbao7cxcKbiW6R" MONGODB_URI="mongodb://localhost:27017" MONGODB_DB="rmm_db" + +# --- Authentication (added 2026-07-16) --- +# Shared secret every agent must present in the X-Agent-Token header. +AGENT_TOKEN="170LRiZafLZThrIGQ6VlP8K9FPw7i8InwzM3dVoYnK4" +# Signing key for dashboard login tokens. +JWT_SECRET_KEY="Gme4fl7mCxTQv9b7H7iahacoEG6j5R4Av7kLAPQpzgVzHjINNScKIDWZ_tJsf93a" +# Dashboard login. CHANGE THESE, then restart the backend. +DASHBOARD_USERNAME="root" +DASHBOARD_PASSWORD="seekright159@" diff --git a/.gitignore b/.gitignore index 36e3191..6dfbad9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ logs.json .DS_Store .vscode/ .idea/ +file_transfers/ diff --git a/central_api_prototype.py b/central_api_prototype.py index 64507d1..b35961e 100644 --- a/central_api_prototype.py +++ b/central_api_prototype.py @@ -11,11 +11,11 @@ else: # Fallback to load default .env if any variables are not yet defined load_dotenv() -from fastapi import FastAPI, HTTPException, Depends, Header +from fastapi import FastAPI, HTTPException, Depends, Header, Request, BackgroundTasks from fastapi.responses import PlainTextResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional import uvicorn import base64 import hmac @@ -104,6 +104,28 @@ db = mongo_client[DB_NAME] clients_collection = db["clients"] logs_collection = db["logs"] +config_collection = db["config"] + +# --- Agent authentication --- +# Shared secret agents present in X-Agent-Token. Auth mode ("grace"|"strict") is +# stored in the config collection so it can be flipped live from the dashboard: +# grace = accept authenticated and legacy (tokenless) agents; used during rollout +# strict = reject any request without a valid token +AGENT_TOKEN = os.getenv("AGENT_TOKEN", "") + +def get_agent_auth_mode() -> str: + doc = config_collection.find_one({"_id": "agent_auth"}) + return (doc or {}).get("mode", "grace") + +def is_valid_agent_token(token: Optional[str]) -> bool: + return bool(AGENT_TOKEN) and token is not None and hmac.compare_digest(token, AGENT_TOKEN) + +async def require_agent_token(x_agent_token: str = Header(None)): + """Dependency guarding every agent-facing endpoint. Enforces only in strict mode; + in grace mode it lets legacy tokenless agents through so a fleet can migrate.""" + if get_agent_auth_mode() == "strict" and not is_valid_agent_token(x_agent_token): + raise HTTPException(status_code=401, detail="Invalid or missing agent token") + return is_valid_agent_token(x_agent_token) MAX_ENTRIES_PER_CLIENT = 100 # Keep the latest 100 historical logs per client node @@ -303,6 +325,9 @@ class TelemetryPayload(BaseModel): memory_free_gb: float disks: List[Dict[str, Any]] gpus: List[Dict[str, Any]] + agent_version: Optional[str] = None + speed_upload_mbps: Optional[float] = None + speed_download_mbps: Optional[float] = None class CommandResultPayload(BaseModel): command: str @@ -314,15 +339,45 @@ class LoginPayload(BaseModel): username: str password: str +DASHBOARD_USERNAME = os.getenv("DASHBOARD_USERNAME", "root") +DASHBOARD_PASSWORD = os.getenv("DASHBOARD_PASSWORD", "seekright159@") + @app.post("/api/login") async def login(payload: LoginPayload): - if payload.username == "root" and payload.password == "seekright159@": + user_ok = hmac.compare_digest(payload.username, DASHBOARD_USERNAME) + pass_ok = hmac.compare_digest(payload.password, DASHBOARD_PASSWORD) + if user_ok and pass_ok: token = generate_token("root") return {"token": token} raise HTTPException(status_code=401, detail="Invalid username or password") +class AuthModePayload(BaseModel): + mode: str + +@app.get("/api/agent-auth-mode") +async def read_agent_auth_mode(current_user: str = Depends(get_current_user)): + return {"mode": get_agent_auth_mode()} + +@app.post("/api/set-agent-auth-mode") +async def set_agent_auth_mode(payload: AuthModePayload, current_user: str = Depends(get_current_user)): + if payload.mode not in ("grace", "strict"): + raise HTTPException(status_code=400, detail="mode must be 'grace' or 'strict'") + config_collection.update_one({"_id": "agent_auth"}, {"$set": {"mode": payload.mode}}, upsert=True) + return {"status": "success", "mode": payload.mode} + +@app.get("/api/agent-versions") +async def agent_versions(current_user: str = Depends(get_current_user)): + """Migration dashboard: which agent version each node reports and whether its token is valid.""" + out = {} + for doc in clients_collection.find({}, {"agent_version": 1, "agent_token_ok": 1}): + out[doc["_id"]] = { + "agent_version": doc.get("agent_version", "unknown"), + "agent_token_ok": doc.get("agent_token_ok", False), + } + return out + @app.get("/api/get-commands") -async def get_whitelisted_commands(platform: str): +async def get_whitelisted_commands(platform: str, token_valid: bool = Depends(require_agent_token)): """ Endpoint for remote agents to fetch their OS-specific whitelisted commands. """ @@ -335,7 +390,7 @@ async def get_whitelisted_commands(platform: str): raise HTTPException(status_code=500, detail=f"Failed to load commands: {e}") @app.get("/api/get-command") -async def get_command(client_id: str): +async def get_command(client_id: str, token_valid: bool = Depends(require_agent_token)): """ Endpoint for clients to poll for pending commands. Client Agent hits this endpoint to ask: "Do I have any work to do?" @@ -389,7 +444,7 @@ async def schedule_command(client_id: str, command: str, current_user: str = Dep return {"status": "success", "message": f"Command '{command}' scheduled for {', '.join(target_ids)}"} @app.post("/api/telemetry") -async def receive_telemetry(client_id: str, payload: TelemetryPayload): +async def receive_telemetry(client_id: str, payload: TelemetryPayload, token_valid: bool = Depends(require_agent_token)): """ Endpoint for agents to push their live hardware telemetry. """ @@ -548,7 +603,7 @@ async def get_logs_history(current_user: str = Depends(get_current_user)): return {} @app.post("/api/command-result") -async def receive_command_result(client_id: str, payload: CommandResultPayload): +async def receive_command_result(client_id: str, payload: CommandResultPayload, token_valid: bool = Depends(require_agent_token)): """ Endpoint for remote agents to push execution results back to the central server. """ @@ -636,3 +691,278 @@ if __name__ == "__main__": print("[*] Starting FastAPI Central Server...") # uvicorn runs the FastAPI app on port 8000 uvicorn.run("central_api_prototype:app", host="0.0.0.0", port=8000, reload=True) + +# ============================================================ +# Remote File Transfer (video fetch from agent TAKELEAP folders) +# ============================================================ +import os +FILE_TRANSFER_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "file_transfers") +os.makedirs(FILE_TRANSFER_DIR, exist_ok=True) + +class FileStatusPayload(BaseModel): + filename: str + status: str + message: Optional[str] = "" + progress_percent: Optional[float] = None + +def set_file_transfer_state(client_id: str, filename: str, status: str, message: str = "", extra: Optional[Dict[str, Any]] = None): + state = { + "filename": filename, + "status": status, + "message": message, + "updated": datetime.now().isoformat() + } + if extra: + state.update(extra) + clients_collection.update_one( + {"_id": client_id}, + {"$set": {"file_transfer": state}}, + upsert=True + ) + +@app.post("/api/request-file") +async def request_file(client_id: str, filename: str, current_user: str = Depends(get_current_user)): + safe_name = os.path.basename(filename.strip()) + if not client_id.strip() or not safe_name: + raise HTTPException(status_code=400, detail="client_id and filename are required") + + clients_collection.update_one( + {"_id": client_id}, + {"$set": {"pending_file_request": safe_name}}, + upsert=True + ) + set_file_transfer_state(client_id, safe_name, "requested", "Waiting for agent to poll...") + append_to_logs("file_requested", client_id, {"filename": safe_name}) + return {"status": "success", "message": f"File '{safe_name}' requested from {client_id}"} + +@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(): + raise HTTPException(status_code=400, detail="client_id cannot be empty") + + doc = clients_collection.find_one_and_update( + {"_id": client_id}, + {"$set": {"pending_file_request": "none"}}, + return_document=False + ) + fname = doc.get("pending_file_request", "none") if doc else "none" + return {"filename": fname} + +@app.post("/api/file-transfer-status") +async def update_file_transfer_status(client_id: str, payload: FileStatusPayload, token_valid: bool = Depends(require_agent_token)): + if not client_id or not client_id.strip(): + raise HTTPException(status_code=400, detail="client_id cannot be empty") + set_file_transfer_state(client_id, payload.filename, payload.status, payload.message or "", {"progress_percent": payload.progress_percent}) + append_to_logs("file_transfer_status", client_id, payload.dict()) + return {"status": "success"} + +@app.post("/api/cancel-upload") +async def cancel_upload(client_id: str, filename: str): + safe_name = os.path.basename(filename.strip()) + set_file_transfer_state(client_id, safe_name, "cancelled", "Upload cancelled by user") + + client_dir = os.path.join(FILE_TRANSFER_DIR, os.path.basename(client_id.strip())) + if os.path.exists(client_dir): + for f in os.listdir(client_dir): + if f.startswith(f"{safe_name}.part"): + try: + os.remove(os.path.join(client_dir, f)) + except: + pass + return {"status": "success"} + +@app.get("/api/received-chunks") +async def received_chunks(client_id: str, filename: str, chunk_size: int, file_size: int, token_valid: bool = Depends(require_agent_token)): + """Resume support: report which chunk indexes are already stored for this file. + Parts whose size doesn't match the current chunking scheme (stale from an + earlier run with a different chunk size) are deleted so they can't corrupt + the final stitch.""" + safe_name = os.path.basename(filename.strip()) + if not client_id.strip() or not safe_name or chunk_size <= 0 or file_size < 0: + raise HTTPException(status_code=400, detail="invalid parameters") + + total_chunks = 1 if file_size == 0 else (file_size + chunk_size - 1) // chunk_size + last_expected = file_size - (total_chunks - 1) * chunk_size + + client_dir = os.path.join(FILE_TRANSFER_DIR, os.path.basename(client_id.strip())) + received = [] + if os.path.isdir(client_dir): + prefix = f"{safe_name}.part" + for entry in os.listdir(client_dir): + if not entry.startswith(prefix) or not entry[len(prefix):].isdigit(): + continue + idx = int(entry[len(prefix):]) + path = os.path.join(client_dir, entry) + expected = chunk_size if idx < total_chunks - 1 else last_expected + try: + if idx < total_chunks and os.path.getsize(path) == expected: + received.append(idx) + else: + os.remove(path) + except OSError: + pass # part is mid-write or locked; report as not received + return {"received": sorted(received)} + +@app.post("/api/upload-chunk") +async def upload_chunk(client_id: str, filename: str, chunk_index: int, request: Request, token_valid: bool = Depends(require_agent_token)): + safe_name = os.path.basename(filename.strip()) + if not client_id.strip() or not safe_name: + raise HTTPException(status_code=400, detail="client_id and filename are required") + + doc = clients_collection.find_one({"_id": client_id}) + if doc and doc.get("file_transfer", {}).get("filename") == safe_name and doc.get("file_transfer", {}).get("status") == "cancelled": + return {"status": "cancelled"} + + client_dir = os.path.join(FILE_TRANSFER_DIR, os.path.basename(client_id.strip())) + os.makedirs(client_dir, exist_ok=True) + dest_path = os.path.join(client_dir, f"{safe_name}.part{chunk_index}") + + try: + with open(dest_path, "wb") as f: + async for chunk in request.stream(): + f.write(chunk) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to store chunk: {e}") + + return {"status": "success", "chunk_index": chunk_index} + +def enforce_storage_limit(max_bytes: int = 20 * 1024**3): + """Scan FILE_TRANSFER_DIR recursively and delete oldest completed files if total size > max_bytes.""" + all_files = [] + total_size = 0 + for root, dirs, files in os.walk(FILE_TRANSFER_DIR): + for name in files: + if ".part" in name: + continue + path = os.path.join(root, name) + try: + stat = os.stat(path) + all_files.append((stat.st_mtime, path, stat.st_size)) + total_size += stat.st_size + except FileNotFoundError: + pass + + if total_size > max_bytes: + all_files.sort(key=lambda x: x[0]) + for mtime, path, size in all_files: + if total_size <= max_bytes: + break + try: + os.remove(path) + total_size -= size + print(f"Deleted old file {path} to free space.") + except Exception as e: + print(f"Error deleting file {path}: {e}") + +@app.post("/api/upload-complete") +async def upload_complete(client_id: str, filename: str, total_chunks: int, background_tasks: BackgroundTasks, token_valid: bool = Depends(require_agent_token)): + safe_name = os.path.basename(filename.strip()) + client_dir = os.path.join(FILE_TRANSFER_DIR, os.path.basename(client_id.strip())) + final_path = os.path.join(client_dir, safe_name) + + try: + with open(final_path, "wb") as outfile: + for i in range(total_chunks): + part_path = os.path.join(client_dir, f"{safe_name}.part{i}") + if not os.path.exists(part_path): + raise HTTPException(status_code=400, detail=f"Missing chunk {i}") + with open(part_path, "rb") as infile: + outfile.write(infile.read()) + os.remove(part_path) + except Exception as e: + set_file_transfer_state(client_id, safe_name, "error", f"Upload finalize failed: {e}") + raise HTTPException(status_code=500, detail=f"Failed to finalize file: {e}") + + size_mb = round(os.path.getsize(final_path) / (1024 * 1024), 2) + set_file_transfer_state(client_id, safe_name, "ready", f"File received ({size_mb} MB)", {"size_mb": size_mb, "progress_percent": 100.0}) + append_to_logs("file_received", client_id, {"filename": safe_name, "size_mb": size_mb}) + send_rocketchat_notification( + text=f"📥 **File Received:** `{safe_name}` ({size_mb} MB) uploaded from client `{client_id}`.", + color="#2ecc71" + ) + background_tasks.add_task(enforce_storage_limit) + return {"status": "success", "size_mb": size_mb} + +@app.get("/api/file-transfers") +async def get_file_transfers(current_user: str = Depends(get_current_user)): + try: + cursor = clients_collection.find({}, {"file_transfer": 1}) + return {doc["_id"]: doc.get("file_transfer") for doc in cursor if doc.get("file_transfer")} + except Exception as e: + print(f"[!] Error loading file transfers: {e}") + return {} + +from fastapi.responses import FileResponse, Response + +@app.get("/deploy_agent.sh") +async def get_agent_installer(x_agent_token: str = Header(None), token: Optional[str] = None): + # In strict mode the installer carries the live token, so downloading it must + # itself be authenticated (header for agent self-update, ?token= for humans). + if get_agent_auth_mode() == "strict" and not (is_valid_agent_token(x_agent_token) or is_valid_agent_token(token)): + raise HTTPException(status_code=401, detail="Invalid or missing agent token") + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "deploy_agent.sh") + if not os.path.isfile(path): + raise HTTPException(status_code=404, detail="Installer script not found on server") + # Normalize CRLF -> LF (edited on Windows, executed by bash on Linux) and inject + # 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")) + return Response( + content=content, + media_type="text/x-shellscript", + headers={"Content-Disposition": 'attachment; filename="deploy_agent.sh"'}, + ) + +@app.get("/api/download-file") +async def download_file(client_id: str, filename: str, token: Optional[str] = None, inline: bool = False, authorization: str = Header(None)): + authed = False + if authorization and authorization.startswith("Bearer ") and verify_token(authorization.split(" ")[1]): + authed = True + if token and verify_token(token): + authed = True + if not authed: + raise HTTPException(status_code=401, detail="Authentication token is invalid or has expired") + + safe_name = os.path.basename(filename.strip()) + path = os.path.join(FILE_TRANSFER_DIR, os.path.basename(client_id.strip()), safe_name) + if not os.path.isfile(path): + raise HTTPException(status_code=404, detail="File not found on server") + + media_type = "video/mp4" if safe_name.lower().endswith(".mp4") else "application/octet-stream" + if inline: + return FileResponse(path, media_type=media_type) + return FileResponse(path, media_type=media_type, filename=safe_name) + +@app.post("/api/heartbeat") +async def receive_heartbeat(client_id: str, payload: TelemetryPayload, + token_valid: bool = Depends(require_agent_token)): + await receive_telemetry(client_id, payload) + # Record migration status so the dashboard can confirm the fleet is upgraded + # and every node is authenticated before auth is flipped to strict. + clients_collection.update_one( + {"_id": client_id}, + {"$set": {"agent_version": payload.agent_version or "unknown", + "speed_upload_mbps": payload.speed_upload_mbps, + "speed_download_mbps": payload.speed_download_mbps, + "agent_token_ok": token_valid}}, + ) + doc = clients_collection.find_one_and_update( + {"_id": client_id}, + {"$set": {"pending_command": "none", "pending_file_request": "none"}}, + return_document=False + ) + cmd = "none" + fname = "none" + if doc: + cmd = doc.get("pending_command", "none") + fname = doc.get("pending_file_request", "none") + + if cmd != "none": + append_to_logs("command_polled", client_id, {"command": cmd}) + + return { + "status": "success", + "command": cmd, + "filename": fname + } diff --git a/commands.json b/commands.json index 4347c4d..8b383c3 100644 --- a/commands.json +++ b/commands.json @@ -1,42 +1,169 @@ { "nt": { - "whoami": ["whoami"], - "ls": ["cmd.exe", "/c", "dir"], - "echo": ["cmd.exe", "/c", "echo", "The prototype executed the echo command successfully on Windows!"], - "reboot": ["cmd.exe", "/c", "echo", "SIMULATED WINDOWS REBOOT"], - "systeminfo": ["systeminfo"], - "ipconfig": ["ipconfig"], - "date": ["cmd.exe", "/c", "date", "/t"], - "check_space": ["powershell.exe", "-Command", "Get-PSDrive -PSProvider FileSystem"], - "check_takeleap": ["python", "client_agent_prototype.py", "--check-takeleap"], - "list_takeleap": ["python", "client_agent_prototype.py", "--list-takeleap"], - "list_takeleap_size": ["python", "client_agent_prototype.py", "--list-takeleap-size"], - "list_takeleap_shift_size": ["python", "client_agent_prototype.py", "--list-takeleap-sub-size", "SHIFT"], - "list_takeleap_shift_files": ["python", "client_agent_prototype.py", "--list-takeleap-sub-files", "SHIFT"], - "list_takeleap_errors_size": ["python", "client_agent_prototype.py", "--list-takeleap-sub-size", "Error_Videos"], - "list_takeleap_errors_files": ["python", "client_agent_prototype.py", "--list-takeleap-sub-files", "Error_Videos"], - "list_takeleap_upload_size": ["python", "client_agent_prototype.py", "--list-takeleap-sub-size", "UPLOAD_FOLDER"], - "list_takeleap_upload_files": ["python", "client_agent_prototype.py", "--list-takeleap-sub-files", "UPLOAD_FOLDER"] + "whoami": [ + "whoami" + ], + "ls": [ + "cmd.exe", + "/c", + "dir" + ], + "echo": [ + "cmd.exe", + "/c", + "echo", + "The prototype executed the echo command successfully on Windows!" + ], + "reboot": [ + "cmd.exe", + "/c", + "echo", + "SIMULATED WINDOWS REBOOT" + ], + "systeminfo": [ + "systeminfo" + ], + "ipconfig": [ + "ipconfig" + ], + "date": [ + "cmd.exe", + "/c", + "date", + "/t" + ], + "check_space": [ + "powershell.exe", + "-Command", + "Get-PSDrive -PSProvider FileSystem" + ], + "check_takeleap": [ + "python", + "client_agent_prototype.py", + "--check-takeleap" + ], + "list_takeleap": [ + "python", + "client_agent_prototype.py", + "--list-takeleap" + ], + "list_takeleap_size": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-size" + ], + "list_takeleap_shift_size": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-size", + "SHIFT" + ], + "list_takeleap_shift_files": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-files", + "SHIFT" + ], + "list_takeleap_errors_size": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-size", + "Error_Videos" + ], + "list_takeleap_errors_files": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-files", + "Error_Videos" + ], + "list_takeleap_upload_size": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-size", + "UPLOAD_FOLDER" + ], + "list_takeleap_upload_files": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-files", + "UPLOAD_FOLDER" + ] }, "posix": { - "whoami": ["whoami"], - "ls": ["ls", "-la"], - "echo": ["echo", "The prototype executed the echo command successfully on POSIX/WSL!"], - "pwd": ["pwd"], - "date": ["date"], - "time": ["date", "+%T"], - "systeminfo": ["uname", "-a"], - "ipconfig": ["ip", "addr"], - "check_space": ["df", "-h"], - "memory": ["free", "-h"], - "ps": ["ps", "aux"], - "kill_python": ["pkill", "-9", "python"], - "ping_google": ["ping", "-c", "4", "google.com"], - "env": ["env"], - "hostname": ["hostname"], - "reboot": ["sudo", "reboot"], - "restart_mysql": ["sudo", "systemctl", "restart", "mysql"], - "mysql_status": ["sudo", "systemctl", "status", "mysql"], + "whoami": [ + "whoami" + ], + "ls": [ + "ls", + "-la" + ], + "echo": [ + "echo", + "The prototype executed the echo command successfully on POSIX/WSL!" + ], + "pwd": [ + "pwd" + ], + "date": [ + "date" + ], + "time": [ + "date", + "+%T" + ], + "systeminfo": [ + "uname", + "-a" + ], + "ipconfig": [ + "ip", + "addr" + ], + "check_space": [ + "df", + "-h" + ], + "memory": [ + "free", + "-h" + ], + "ps": [ + "ps", + "aux" + ], + "kill_python": [ + "pkill", + "-9", + "python" + ], + "ping_google": [ + "ping", + "-c", + "4", + "google.com" + ], + "env": [ + "env" + ], + "hostname": [ + "hostname" + ], + "reboot": [ + "sudo", + "reboot" + ], + "restart_mysql": [ + "sudo", + "systemctl", + "restart", + "mysql" + ], + "mysql_status": [ + "sudo", + "systemctl", + "status", + "mysql" + ], "repair_mysql": [ "sudo", "mysqlcheck", @@ -54,17 +181,149 @@ "--optimize", "Test_seekright" ], - "storage_devices": ["lsblk"], - "mounted_disks": ["df", "-hT"], - "netstat": ["ss", "-tulnp"], - "check_takeleap": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--check-takeleap"], - "list_takeleap": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap"], - "list_takeleap_size": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap-size"], - "list_takeleap_shift_size": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap-sub-size", "SHIFT"], - "list_takeleap_shift_files": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap-sub-files", "SHIFT"], - "list_takeleap_errors_size": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap-sub-size", "Error_Videos"], - "list_takeleap_errors_files": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap-sub-files", "Error_Videos"], - "list_takeleap_upload_size": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap-sub-size", "UPLOAD_FOLDER"], - "list_takeleap_upload_files": ["python3", "/opt/seekright-agent/client_agent_prototype.py", "--list-takeleap-sub-files", "UPLOAD_FOLDER"] + "restart_rustdesk": [ + "sudo", + "systemctl", + "restart", + "rustdesk" + ], + "check_display": [ + "bash", + "-c", + "for i in /sys/class/drm/*/status; do echo \"$i: $(cat $i)\"; done" + ], + "drm_details": [ + "bash", + "-c", + "ls -l /sys/class/drm && echo '---' && cat /sys/class/drm/*/status" + ], + "headless_check": [ + "bash", + "-c", + "connected=0; for f in /sys/class/drm/*/status; do if grep -q connected $f; then connected=1; fi; done; if [ $connected -eq 1 ]; then echo DISPLAY_CONNECTED; else echo HEADLESS; fi" + ], + "framebuffer_check": [ + "bash", + "-c", + "ls -l /dev/fb* && cat /proc/fb" + ], + "install_temp_tools": [ + "bash", + "-c", + "sudo apt update && sudo apt install -y lm-sensors" + ], + "detect_sensors": [ + "bash", + "-c", + "sudo sensors-detect --auto" + ], + "gpu_temp": [ + "bash", + "-c", + "nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader" + ], + "temperature": [ + "bash", + "-c", + "sensors" + ], + "check_edid": [ + "bash", + "-c", + "for f in /sys/class/drm/*/edid; do echo $f; [ -s $f ] && echo 'EDID PRESENT' || echo 'NO EDID'; done" + ], + "xrandr_display": [ + "bash", + "-c", + "xrandr --query 2>&1" + ], + "nvidia_status": [ + "bash", + "-c", + "nvidia-smi" + ], + "check_display_session": [ + "bash", + "-c", + "echo DISPLAY=$DISPLAY && loginctl list-sessions" + ], + "check_gpu_display": [ + "bash", + "-c", + "lspci | grep -i -E 'vga|display|3d'" + ], + "rustdesk_status": [ + "sudo", + "systemctl", + "status", + "rustdesk" + ], + "storage_devices": [ + "lsblk" + ], + "mounted_disks": [ + "df", + "-hT" + ], + "netstat": [ + "ss", + "-tulnp" + ], + "update_agent": [ + "bash", + "-c", + "set -e; PY=/opt/seekright-agent/client_agent_prototype.py; URL=$(sed -n 's/^CENTRAL_BASE = \"\\(.*\\)\"/\\1/p' \"$PY\" | head -1); TOK=$(sed -n 's/^AGENT_TOKEN = \"\\(.*\\)\"/\\1/p' \"$PY\" | head -1); curl -fsSL -H \"X-Agent-Token: $TOK\" \"$URL/deploy_agent.sh\" -o /tmp/sr_update.sh; test -s /tmp/sr_update.sh; systemd-run --collect --description='SeekRight agent self-update' bash /tmp/sr_update.sh >/dev/null 2>&1; echo 'Update launched detached via systemd-run; agent will restart on the new version shortly.'" + ], + "check_takeleap": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--check-takeleap" + ], + "list_takeleap": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap" + ], + "list_takeleap_size": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-size" + ], + "list_takeleap_shift_size": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-size", + "SHIFT" + ], + "list_takeleap_shift_files": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-files", + "SHIFT" + ], + "list_takeleap_errors_size": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-size", + "Error_Videos" + ], + "list_takeleap_errors_files": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-files", + "Error_Videos" + ], + "list_takeleap_upload_size": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-size", + "UPLOAD_FOLDER" + ], + "list_takeleap_upload_files": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-files", + "UPLOAD_FOLDER" + ] } -} +} \ No newline at end of file diff --git a/commands_new.json b/commands_new.json new file mode 100644 index 0000000..24acd6e --- /dev/null +++ b/commands_new.json @@ -0,0 +1,332 @@ +{ + "nt": { + "whoami": [ + "whoami" + ], + "ls": [ + "cmd.exe", + "/c", + "dir" + ], + "echo": [ + "cmd.exe", + "/c", + "echo", + "The prototype executed the echo command successfully on Windows!" + ], + "reboot": [ + "cmd.exe", + "/c", + "echo", + "SIMULATED WINDOWS REBOOT" + ], + "systeminfo": [ + "systeminfo" + ], + "ipconfig": [ + "ipconfig" + ], + "date": [ + "cmd.exe", + "/c", + "date", + "/t" + ], + "check_space": [ + "powershell.exe", + "-Command", + "Get-PSDrive -PSProvider FileSystem" + ], + "check_takeleap": [ + "python", + "client_agent_prototype.py", + "--check-takeleap" + ], + "list_takeleap": [ + "python", + "client_agent_prototype.py", + "--list-takeleap" + ], + "list_takeleap_size": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-size" + ], + "list_takeleap_shift_size": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-size", + "SHIFT" + ], + "list_takeleap_shift_files": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-files", + "SHIFT" + ], + "list_takeleap_errors_size": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-size", + "Error_Videos" + ], + "list_takeleap_errors_files": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-files", + "Error_Videos" + ], + "list_takeleap_upload_size": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-size", + "UPLOAD_FOLDER" + ], + "list_takeleap_upload_files": [ + "python", + "client_agent_prototype.py", + "--list-takeleap-sub-files", + "UPLOAD_FOLDER" + ] + }, + "posix": { + "whoami": [ + "whoami" + ], + "ls": [ + "ls", + "-la" + ], + "echo": [ + "echo", + "The prototype executed the echo command successfully on POSIX/WSL!" + ], + "pwd": [ + "pwd" + ], + "date": [ + "date" + ], + "time": [ + "date", + "+%T" + ], + "systeminfo": [ + "uname", + "-a" + ], + "ipconfig": [ + "ip", + "addr" + ], + "check_space": [ + "df", + "-h" + ], + "memory": [ + "free", + "-h" + ], + "ps": [ + "ps", + "aux" + ], + "kill_python": [ + "pkill", + "-9", + "python" + ], + "ping_google": [ + "ping", + "-c", + "4", + "google.com" + ], + "env": [ + "env" + ], + "hostname": [ + "hostname" + ], + "reboot": [ + "sudo", + "reboot" + ], + "restart_mysql": [ + "sudo", + "systemctl", + "restart", + "mysql" + ], + "mysql_status": [ + "sudo", + "systemctl", + "status", + "mysql" + ], + "repair_mysql": [ + "sudo", + "mysqlcheck", + "--defaults-file=/root/.my.cnf", + "--auto-repair", + "--optimize", + "Test_seekright", + "tbl_site_anomaly" + ], + "repair_mysql_db": [ + "sudo", + "mysqlcheck", + "--defaults-file=/root/.my.cnf", + "--auto-repair", + "--optimize", + "Test_seekright" + ], + "restart_rustdesk": [ + "sudo", + "systemctl", + "restart", + "rustdesk" + ], + "check_display": [ + "bash", + "-c", + "for i in /sys/class/drm/*/status; do echo \"$i: $(cat $i)\"; done" + ], + "drm_details": [ + "bash", + "-c", + "ls -l /sys/class/drm && echo '---' && cat /sys/class/drm/*/status" + ], + "headless_check": [ + "bash", + "-c", + "connected=0; for f in /sys/class/drm/*/status; do if grep -q connected $f; then connected=1; fi; done; if [ $connected -eq 1 ]; then echo DISPLAY_CONNECTED; else echo HEADLESS; fi" + ], + "framebuffer_check": [ + "bash", + "-c", + "ls -l /dev/fb* && cat /proc/fb" + ], + "install_temp_tools": [ + "bash", + "-c", + "sudo apt update && sudo apt install -y lm-sensors" + ], + "detect_sensors": [ + "bash", + "-c", + "sudo sensors-detect --auto" + ], + "gpu_temp": [ + "bash", + "-c", + "nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader" + ], + "temperature": [ + "bash", + "-c", + "sensors" + ], + "check_edid": [ + "bash", + "-c", + "for f in /sys/class/drm/*/edid; do echo $f; [ -s $f ] && echo 'EDID PRESENT' || echo 'NO EDID'; done" + ], + "xrandr_display": [ + "bash", + "-c", + "xrandr --query 2>&1" + ], + "nvidia_status": [ + "bash", + "-c", + "nvidia-smi" + ], + "check_display_session": [ + "bash", + "-c", + "echo DISPLAY=$DISPLAY && loginctl list-sessions" + ], + "check_gpu_display": [ + "bash", + "-c", + "lspci | grep -i -E 'vga|display|3d'" + ], + "rustdesk_status": [ + "sudo", + "systemctl", + "status", + "rustdesk" + ], + "storage_devices": [ + "lsblk" + ], + "mounted_disks": [ + "df", + "-hT" + ], + "netstat": [ + "ss", + "-tulnp" + ], + "check_takeleap": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--check-takeleap" + ], + "list_takeleap": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap" + ], + "list_takeleap_size": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-size" + ], + "list_takeleap_shift_size": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-size", + "SHIFT" + ], + "list_takeleap_shift_files": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-files", + "SHIFT" + ], + "list_takeleap_errors_size": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-size", + "Error_Videos" + ], + "list_takeleap_errors_files": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-files", + "Error_Videos" + ], + "list_takeleap_upload_size": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-size", + "UPLOAD_FOLDER" + ], + "list_takeleap_upload_files": [ + "python3", + "/opt/seekright-agent/client_agent_prototype.py", + "--list-takeleap-sub-files", + "UPLOAD_FOLDER" + ], + "last_reboot": [ + "last", + "reboot" + ], + "dummy": [ + "last", + "reboot" + ] + } +} \ No newline at end of file diff --git a/deploy_agent.sh b/deploy_agent.sh index 2ad0042..8d1e880 100644 --- a/deploy_agent.sh +++ b/deploy_agent.sh @@ -10,26 +10,57 @@ if [ "$EUID" -ne 0 ]; then fi # ==================================================== -# ⚙️ CONFIGURATION: Set your Client ID & Central URL +# ⚙️ CONFIGURATION: Client ID, Central URL, Agent Token # ==================================================== -CLIENT_ID="" +# Resolution priority for each value: explicit CLI arg > existing install > default. +# Reading from the existing install makes re-running with no args a safe in-place +# upgrade — the node keeps its identity. This is what fleet self-update relies on. +# Usage: deploy_agent.sh [CLIENT_ID] [CENTRAL_URL] [AGENT_TOKEN] +EXISTING_PY="/opt/seekright-agent/client_agent_prototype.py" -# For Production deployment, use: "http://rmm-backend.seekright.com" -# For Local WSL testing on your laptop, use: "http://172.18.16.1:8000" -# Check if Client ID is set; if not, check command line arguments, otherwise default to hostname -if [ -z "$CLIENT_ID" ]; then - CLIENT_ID="${1:-$(hostname)}" +CLIENT_ID="${1:-}" +CENTRAL_URL="${2:-}" +ARG_TOKEN="${3:-}" + +if [ -f "$EXISTING_PY" ]; then + if [ -z "$CLIENT_ID" ]; then + CLIENT_ID="$(sed -n 's/^CLIENT_ID = "\(.*\)"/\1/p' "$EXISTING_PY" | head -1)" + fi + if [ -z "$CENTRAL_URL" ]; then + CENTRAL_URL="$(sed -n 's/^CENTRAL_BASE = "\(.*\)"/\1/p' "$EXISTING_PY" | head -1)" + fi fi -CENTRAL_URL="${2:-http://rmm-backend.seekright.com}" +# Fallbacks when nothing was provided and there is no prior install +if [ -z "$CLIENT_ID" ]; then CLIENT_ID="$(hostname)"; fi +if [ -z "$CENTRAL_URL" ]; then CENTRAL_URL="http://rmm-backend.seekright.com"; fi -echo "🚀 Preparing SeekRight RMM Agent installation..." +# Agent token. The server injects the live token into the line below at download +# time (replacing __AGENT_TOKEN__). Priority: CLI arg > server-injected > existing install. +AGENT_TOKEN="__AGENT_TOKEN__" +if [ -n "$ARG_TOKEN" ]; then + AGENT_TOKEN="$ARG_TOKEN" +elif [ "$AGENT_TOKEN" = "__AGENT_TOKEN__" ]; then + if [ -f "$EXISTING_PY" ]; then + AGENT_TOKEN="$(sed -n 's/^AGENT_TOKEN = "\(.*\)"/\1/p' "$EXISTING_PY" | head -1)" + else + AGENT_TOKEN="" + fi +fi + +echo "🚀 Preparing SeekRight RMM Agent installation (agent v3.2-auth)..." echo "📍 Target Client ID: $CLIENT_ID" echo "🌐 Central Server URL: $CENTRAL_URL" # 1. Install System Dependencies echo "🔄 Updating system package indexes and installing python dependencies..." -apt-get update -y + +# Fix potential MySQL GPG key expiration issues before updating (common issue on Ubuntu 22.04) +apt-key del B7B3B788A8D3785C || true +wget -q -O - https://repo.mysql.com/RPM-GPG-KEY-mysql-2025 | apt-key add - || true +wget -q -O - https://repo.mysql.com/RPM-GPG-KEY-mysql-2025 | gpg --dearmor --yes -o /usr/share/keyrings/mysql-apt-config.gpg || true + +apt-get update -y || true apt-get install -y python3 python3-pip python3-psutil # 2. Create Destination Directories @@ -41,6 +72,7 @@ chmod 755 /opt/seekright-agent echo "📝 Generating client agent file..." cat << 'EOF' > /opt/seekright-agent/client_agent_prototype.py import urllib.request +import urllib.parse import json import time import subprocess @@ -50,17 +82,47 @@ import sys import string import threading +def get_cpu_temp(): + if not hasattr(psutil, "sensors_temperatures"): + return None + try: + temps = psutil.sensors_temperatures() + if not temps: + return None + for name in ['coretemp', 'k10temp', 'acpitz', 'cpu_thermal']: + if name in temps and temps[name]: + return temps[name][0].current + for name, entries in temps.items(): + if entries: + return entries[0].current + except Exception: + pass + return None + +AGENT_VERSION = "3.2-auth" + CLIENT_ID = "TEMPLATE_CLIENT_ID" CENTRAL_BASE = "TEMPLATE_CENTRAL_URL" +AGENT_TOKEN = "TEMPLATE_AGENT_TOKEN" + +# Attach the shared headers (agent token + tunnel bypass) to every outbound request +# by installing a default opener, so we don't have to touch each call site. +_opener = urllib.request.build_opener() +_opener.addheaders = [('ngrok-skip-browser-warning', 'true'), ('X-Agent-Token', AGENT_TOKEN)] +urllib.request.install_opener(_opener) CENTRAL_API_URL = f"{CENTRAL_BASE}/api/get-command?client_id={CLIENT_ID}" -CENTRAL_TELEMETRY_URL = f"{CENTRAL_BASE}/api/telemetry?client_id={CLIENT_ID}" +CENTRAL_HEARTBEAT_URL = f"{CENTRAL_BASE}/api/heartbeat?client_id={CLIENT_ID}" platform_name = "nt" if os.name == "nt" else "posix" CENTRAL_COMMANDS_URL = f"{CENTRAL_BASE}/api/get-commands?platform={platform_name}" +CENTRAL_FILE_REQUEST_URL = f"{CENTRAL_BASE}/api/get-file-request?client_id={CLIENT_ID}" +CENTRAL_FILE_STATUS_URL = f"{CENTRAL_BASE}/api/file-transfer-status?client_id={CLIENT_ID}" + CACHED_COMMANDS = {} command_in_progress = False +file_transfer_in_progress = False def run_command_async(command_key, safe_cmd_list, is_shutdown_or_restart): global command_in_progress @@ -87,12 +149,43 @@ def run_command_async(command_key, safe_cmd_list, is_shutdown_or_restart): finally: command_in_progress = False +def execute_command_async(command_key): + # Resolve a command key received via heartbeat against the whitelist and run it. + # (The heartbeat loop sets command_in_progress=True before dispatching here, so + # every exit path must clear it unless run_command_async takes over that duty.) + global command_in_progress + try: + allowed_commands = load_allowed_commands() + if command_key not in allowed_commands: + warning_msg = f"Server requested unknown/unwhitelisted command: '{command_key}'. Ignored." + print(f" -> [!] SECURITY WARNING: {warning_msg}") + send_rocket_chat_notification("⚠️ SECURITY WARNING", warning_msg, "#FFA500") + command_in_progress = False + return + + print(f" -> Executing approved command: '{command_key}'") + safe_cmd_list = allowed_commands[command_key] + is_shutdown_or_restart = any(x in command_key.lower() for x in ["reboot", "restart", "kill_python"]) + if is_shutdown_or_restart: + print(" -> Shutdown/restart command detected. Sending pre-execution notification...") + send_rocket_chat_notification( + f"🔄 Executing: {command_key}", + f"System/agent is performing an action: {' '.join(safe_cmd_list)}\nConnection may drop temporarily.", + "#FFA500" + ) + time.sleep(3) + # run_command_async clears command_in_progress in its finally block + run_command_async(command_key, safe_cmd_list, is_shutdown_or_restart) + except Exception as e: + print(f" -> [!] Command dispatch error: {e}") + command_in_progress = False + def load_allowed_commands(): global CACHED_COMMANDS try: req = urllib.request.Request(CENTRAL_COMMANDS_URL) req.add_header('ngrok-skip-browser-warning', 'true') - with urllib.request.urlopen(req, timeout=3) as response: + with urllib.request.urlopen(req, timeout=15) as response: commands = json.loads(response.read().decode()) if commands: CACHED_COMMANDS = commands @@ -121,7 +214,7 @@ def send_rocket_chat_notification(title, message, color="#764FA5"): } data = json.dumps(payload).encode('utf-8') req = urllib.request.Request(ROCKET_CHAT_WEBHOOK, data=data, headers={'Content-Type': 'application/json'}, method='POST') - urllib.request.urlopen(req, timeout=3) + urllib.request.urlopen(req, timeout=15) except Exception as e: print(f" -> [!] Failed to send webhook: {e}") @@ -156,7 +249,7 @@ def get_windows_label(drive): pass return "" -def collect_and_send_telemetry(): +def send_heartbeat(): try: gpus = [] try: @@ -240,20 +333,44 @@ def collect_and_send_telemetry(): payload = { "cpu_percent": psutil.cpu_percent(interval=0.5), + "cpu_temp": get_cpu_temp(), "memory_percent": psutil.virtual_memory().percent, "memory_total_gb": round(psutil.virtual_memory().total / (1024**3), 2), "memory_free_gb": round(psutil.virtual_memory().available / (1024**3), 2), "disks": disks, - "gpus": gpus + "gpus": gpus, + "agent_version": AGENT_VERSION } - + data = json.dumps(payload).encode('utf-8') - req = urllib.request.Request(CENTRAL_TELEMETRY_URL, data=data, headers={'Content-Type': 'application/json'}, method='POST') + req = urllib.request.Request(CENTRAL_HEARTBEAT_URL, data=data, headers={'Content-Type': 'application/json'}, method='POST') req.add_header('ngrok-skip-browser-warning', 'true') - urllib.request.urlopen(req, timeout=3) - print("[*] Telemetry pushed successfully.") + with urllib.request.urlopen(req, timeout=30) as response: + res = json.loads(response.read().decode()) + + # Handle commands + cmd = res.get("command", "none") + if cmd != "none": + global command_in_progress + if not command_in_progress: + command_in_progress = True + t = threading.Thread(target=execute_command_async, args=(cmd,)) + t.daemon = True + t.start() + + # Handle file requests + fname = res.get("filename", "none") + if fname != "none": + global file_transfer_in_progress + if not file_transfer_in_progress: + file_transfer_in_progress = True + t = threading.Thread(target=handle_file_request_async, args=(fname,)) + t.daemon = True + t.start() + + print("[*] Heartbeat successful.") except Exception as e: - print(f"[!] Failed to push telemetry: {e}") + print(f"[!] Failed heartbeat: {e}") def send_command_result_to_server(command: str, returncode: int, stdout: str, stderr: str): try: @@ -273,7 +390,7 @@ def send_command_result_to_server(command: str, returncode: int, stdout: str, st method='POST' ) req.add_header('ngrok-skip-browser-warning', 'true') - with urllib.request.urlopen(req, timeout=3) as response: + with urllib.request.urlopen(req, timeout=15) as response: pass print(" -> Sent command execution output to Central Server.") except Exception as e: @@ -287,7 +404,7 @@ def poll_server(): print(f"[*] Checking for commands...") req = urllib.request.Request(CENTRAL_API_URL) req.add_header('ngrok-skip-browser-warning', 'true') - with urllib.request.urlopen(req, timeout=3) as response: + with urllib.request.urlopen(req, timeout=15) as response: data = json.loads(response.read().decode()) command_key = data.get("command", "none") @@ -568,6 +685,213 @@ def list_takeleap_subfolder_files(subfolder_name): def list_takeleap_subfolder(subfolder_name): list_takeleap_subfolder_files(subfolder_name) +# ============================================================ +# Remote File Transfer: dashboard requests a file by name, +# agent finds it under any TAKELEAP folder and uploads it. +# ============================================================ + +def send_file_transfer_status(filename, status, message="", progress_percent=None): + try: + payload = {"filename": filename, "status": status, "message": message} + if progress_percent is not None: + payload["progress_percent"] = progress_percent + data = json.dumps(payload).encode('utf-8') + req = urllib.request.Request( + CENTRAL_FILE_STATUS_URL, + data=data, + headers={'Content-Type': 'application/json'}, + method='POST' + ) + req.add_header('ngrok-skip-browser-warning', 'true') + urllib.request.urlopen(req, timeout=30) + except Exception as e: + print(f" -> [!] Failed to send file transfer status: {e}") + +def find_file_in_takeleap(filename): + target = filename.lower() + + # Fast-path optimization: Extract YYYYMMDD from filename + # e.g., 20260612112725_000000.MP4 -> SHIFT/2026/June/12 + fast_path_rel = None + try: + if len(filename) >= 8 and filename[:8].isdigit(): + import calendar + y = filename[0:4] + m = int(filename[4:6]) + d = filename[6:8] + if 1 <= m <= 12: + month_name = calendar.month_name[m] + fast_path_rel = os.path.join("SHIFT", y, month_name, d) + except Exception: + pass + + 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": + # FAST PATH: Check the specific date folder first + if fast_path_rel: + specific_dir = os.path.join(root, 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) + + # Exhaustively search this TAKELEAP subtree for the requested file + for froot, fdirs, ffiles in os.walk(root): + for f in ffiles: + if f.lower() == target: + return os.path.join(froot, f) + dirs[:] = [] # subtree fully searched, don't descend again + return None + +def upload_file_to_server(filename, filepath): + import time + size = os.path.getsize(filepath) + quoted = urllib.parse.quote(filename) + CHUNK_SIZE = 512 * 1024 # 512 KB: small enough to finish well under the tunnel gateway timeout + + if size == 0: + total_chunks = 1 + else: + total_chunks = (size + CHUNK_SIZE - 1) // CHUNK_SIZE + + def fetch_received(): + # Returns the set of chunk indexes the server already holds, or None if unreachable + url = f"{CENTRAL_BASE}/api/received-chunks?client_id={CLIENT_ID}&filename={quoted}&chunk_size={CHUNK_SIZE}&file_size={size}" + try: + req = urllib.request.Request(url) + req.add_header('ngrok-skip-browser-warning', 'true') + with urllib.request.urlopen(req, timeout=30) as r: + return set(json.loads(r.read().decode()).get("received", [])) + except Exception as e: + print(f" -> [!] Could not fetch resume state ({e}).") + return None + + def upload_one(f, chunk_index): + # Returns True on success, False if the server reports the transfer cancelled + f.seek(chunk_index * CHUNK_SIZE) + chunk_data = f.read(CHUNK_SIZE) + chunk_url = f"{CENTRAL_BASE}/api/upload-chunk?client_id={CLIENT_ID}&filename={quoted}&chunk_index={chunk_index}" + while True: + try: + req = urllib.request.Request(chunk_url, data=chunk_data, method='POST') + req.add_header('Content-Type', 'application/octet-stream') + req.add_header('ngrok-skip-browser-warning', 'true') + with urllib.request.urlopen(req, timeout=120) as response: + res = json.loads(response.read().decode()) + return res.get("status") != "cancelled" + except Exception as e: + print(f" -> [!] Chunk {chunk_index} failed ({e}). Retrying in 5s...") + time.sleep(5) + + received = fetch_received() or set() + if received: + print(f" -> Resuming: server already has {len(received)}/{total_chunks} chunks.") + + uploaded = 0 + with open(filepath, 'rb') as f: + for chunk_index in range(total_chunks): + if chunk_index in received: + continue + if not upload_one(f, chunk_index): + print(f" -> [!] Upload cancelled by server.") + send_file_transfer_status(filename, "error", "Upload cancelled.") + return + uploaded += 1 + # Brief pause between chunks so heartbeat traffic can slip through + time.sleep(0.1) + if uploaded % 8 == 0 or chunk_index == total_chunks - 1: + progress = round(((len(received) + uploaded) / total_chunks) * 100, 1) + send_file_transfer_status(filename, "uploading", f"Uploading... ({progress}%)", progress_percent=progress) + + # Finalize with self-healing: verify the server holds every chunk (re-uploading + # any lost to a mid-transfer cancel or cleanup), then ask it to stitch the file. + while True: + on_server = fetch_received() + if on_server is None: + time.sleep(5) + continue + missing = sorted(set(range(total_chunks)) - on_server) + if missing: + print(f" -> [!] Server is missing {len(missing)} chunk(s). Re-uploading...") + for chunk_index in missing: + if not upload_one(f, chunk_index): + print(f" -> [!] Upload cancelled by server.") + send_file_transfer_status(filename, "error", "Upload cancelled.") + return + time.sleep(0.1) + continue + complete_url = f"{CENTRAL_BASE}/api/upload-complete?client_id={CLIENT_ID}&filename={quoted}&total_chunks={total_chunks}" + try: + req = urllib.request.Request(complete_url, method='POST', data=b'') + req.add_header('ngrok-skip-browser-warning', 'true') + with urllib.request.urlopen(req, timeout=180) as response: + return json.loads(response.read().decode()) + except Exception as e: + print(f" -> [!] Finalize failed ({e}). Re-verifying chunks in 5s...") + time.sleep(5) + +def handle_file_request_async(filename): + global file_transfer_in_progress + try: + print(f" -> File request received: '{filename}'. Searching TAKELEAP folders...") + send_file_transfer_status(filename, "searching", "Searching TAKELEAP folders on client...") + filepath = find_file_in_takeleap(filename) + + if not filepath: + print(f" -> [!] File '{filename}' not found in any TAKELEAP folder.") + send_file_transfer_status(filename, "not_found", "File was not found in any TAKELEAP folder on this client.") + send_rocket_chat_notification(f"❌ File Not Found: {filename}", f"Requested file was not found on client {CLIENT_ID}.", "#FF0000") + return + + size_mb = round(os.path.getsize(filepath) / (1024 * 1024), 2) + print(f" -> Found at {filepath} ({size_mb} MB). Uploading...") + send_file_transfer_status(filename, "uploading", f"Found at {filepath} ({size_mb} MB). Uploading to server...") + upload_file_to_server(filename, filepath) + # Server marks the transfer 'ready' once the upload completes + print(f" -> Upload complete: {filename}") + send_rocket_chat_notification(f"✅ File Uploaded: {filename}", f"Uploaded {size_mb} MB from {filepath} to the central server.", "#00FF00") + except Exception as ex: + print(f" -> [!] File transfer error: {ex}") + send_file_transfer_status(filename, "error", f"Transfer failed on client: {ex}") + send_rocket_chat_notification(f"❌ File Transfer Failed: {filename}", str(ex), "#FF0000") + finally: + file_transfer_in_progress = False + +def poll_file_request(): + global file_transfer_in_progress + if file_transfer_in_progress: + return + try: + req = urllib.request.Request(CENTRAL_FILE_REQUEST_URL) + req.add_header('ngrok-skip-browser-warning', 'true') + with urllib.request.urlopen(req, timeout=15) as response: + data = json.loads(response.read().decode()) + filename = data.get("filename", "none") + if filename and filename != "none": + file_transfer_in_progress = True + t = threading.Thread(target=handle_file_request_async, args=(filename,)) + t.daemon = True + t.start() + except Exception as e: + print(f" -> [!] Failed to poll file requests: {e}") + if __name__ == "__main__": if len(sys.argv) > 1: if sys.argv[1] == "--check-takeleap": @@ -592,20 +916,21 @@ if __name__ == "__main__": print(f"Unknown argument or missing parameters: {sys.argv[1]}") sys.exit(1) else: - print(f"Starting Agent for {CLIENT_ID}...") + print(f"Starting Agent v{AGENT_VERSION} for {CLIENT_ID}...") try: while True: - collect_and_send_telemetry() - poll_server() + send_heartbeat() time.sleep(10) except KeyboardInterrupt: print("\n[*] Agent stopped.") EOF -# Inject the Client ID and Central URL dynamically +# Inject the Client ID, Central URL, and Agent Token dynamically sed -i "s/TEMPLATE_CLIENT_ID/$CLIENT_ID/g" /opt/seekright-agent/client_agent_prototype.py sed -i "s|TEMPLATE_CENTRAL_URL|$CENTRAL_URL|g" /opt/seekright-agent/client_agent_prototype.py -chmod 644 /opt/seekright-agent/client_agent_prototype.py +sed -i "s|TEMPLATE_AGENT_TOKEN|$AGENT_TOKEN|g" /opt/seekright-agent/client_agent_prototype.py +# Agent file holds the shared secret; restrict it to root +chmod 600 /opt/seekright-agent/client_agent_prototype.py # 4. Create Background systemd Service echo "⚙️ Configuring background systemd daemon service..." diff --git a/dispatch_jagan.py b/dispatch_jagan.py new file mode 100644 index 0000000..56730d9 --- /dev/null +++ b/dispatch_jagan.py @@ -0,0 +1,6 @@ +from pymongo import MongoClient +import os + +db = MongoClient('mongodb://localhost:27017')['rmm_db'] +db.clients.update_one({'_id': 'JAGAN'}, {'$set': {'pending_command': 'update_agent'}}) +print('Dispatched update_agent to JAGAN!') diff --git a/set_strict.py b/set_strict.py new file mode 100644 index 0000000..b90302b --- /dev/null +++ b/set_strict.py @@ -0,0 +1,6 @@ +import urllib.request, json +from pymongo import MongoClient + +db = MongoClient('mongodb://localhost:27017')['rmm_db'] +db.config.update_one({'_id': 'agent_auth'}, {'$set': {'mode': 'strict'}}, upsert=True) +print("Set to strict") diff --git a/test_auth.py b/test_auth.py new file mode 100644 index 0000000..9d31db1 --- /dev/null +++ b/test_auth.py @@ -0,0 +1,33 @@ +import urllib.request, json +from pymongo import MongoClient + +# Flip to strict +db = MongoClient('mongodb://localhost:27017')['rmm_db'] +db.config.update_one({'_id': 'agent_auth'}, {'$set': {'mode': 'strict'}}, upsert=True) + +def test_heartbeat(token=None): + headers = {'Content-Type': 'application/json'} + if token is not None: + headers['X-Agent-Token'] = token + req = urllib.request.Request( + 'http://localhost:8000/api/heartbeat?client_id=TEST_AUTH', + data=json.dumps({'cpu_percent':10,'cpu_temp':40,'memory_percent':50,'memory_total_gb':16,'memory_free_gb':8,'disks':[],'gpus':[],'agent_version':'test'}).encode('utf-8'), + headers=headers + ) + try: + urllib.request.urlopen(req) + return 'OK' + except Exception as e: + return str(e) + +print('Strict mode:') +print('No token:', test_heartbeat()) +print('Wrong token:', test_heartbeat('wrong')) +print('Correct token:', test_heartbeat('170LRiZafLZThrIGQ6VlP8K9FPw7i8InwzM3dVoYnK4')) + +# Dispatch update_agent to JAGAN +db.clients.update_one({'_id': 'JAGAN'}, {'$set': {'pending_command': 'update_agent'}}) +print('Dispatched update_agent to JAGAN!') + +# Flip back to grace so we don't break other things during the migration +db.config.update_one({'_id': 'agent_auth'}, {'$set': {'mode': 'grace'}}) diff --git a/verify_syntax.py b/verify_syntax.py new file mode 100644 index 0000000..a07b712 --- /dev/null +++ b/verify_syntax.py @@ -0,0 +1,5 @@ +import re, ast +src = open('c:/Users/seekr/OneDrive/Desktop/work/RMM/RMM-BE/rmm-backend/deploy_agent.sh', encoding='utf-8').read() +m = re.search(r"cat << 'EOF' > /opt/seekright-agent/client_agent_prototype.py\n(.*?)\nEOF", src, re.S) +ast.parse(m.group(1)) +print("Embedded Python syntax is OK")