Absorbs the standalone rclone fleet monitor into the RMM, per plan: - deploy_agent.sh: embedded agent v3.6-sync gains a fail-soft sync-monitor module — tails the rclone-synology-sync container's log (docker inspect discovery), parses rounds/pairs/skips/transfers, inventories upload dirs, POSTs to /api/sync-ingest every 10s. Machines without the container report no_container and stay silent. Heartbeat loop untouched. - central_api_prototype.py: POST /api/sync-ingest (agent-token auth) into new sync_* collections; native /api/sync-monitor/* read API (overview, alerts, stats, timeseries, per-machine events, delete) with JWT auth; NAS verification loop (rclone lsjson, Mongo-shared listings across workers) + per-volume NAS health via rclone about (NAS_VOLUMES). - .env: NAS creds + sync-monitor tuning keys. Rolled out fleet-wide 2026-08-18 (25 clients on 3.6-sync). Standalone collector still runs in parallel pending retirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1863 lines
81 KiB
Python
1863 lines
81 KiB
Python
import os
|
|
from dotenv import load_dotenv
|
|
|
|
# Dynamically select configuration file based on APP_ENV
|
|
app_env = os.getenv("APP_ENV", "development")
|
|
if app_env == "production":
|
|
load_dotenv(".env.production")
|
|
else:
|
|
load_dotenv(".env.development")
|
|
|
|
# Fallback to load default .env if any variables are not yet defined
|
|
load_dotenv()
|
|
|
|
from fastapi import FastAPI, HTTPException, Depends, Header, Request, BackgroundTasks
|
|
from fastapi.responses import PlainTextResponse
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from pydantic import BaseModel
|
|
from typing import Dict, Any, List, Optional
|
|
import uvicorn
|
|
import base64
|
|
import hmac
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
app = FastAPI(title="RMM Central API")
|
|
# Dashboard polling was ~90% of this server's 428 GB/month egress; JSON
|
|
# compresses ~15x. Agents' urllib doesn't send Accept-Encoding, so agent
|
|
# traffic (incl. speedtest blobs) is unaffected.
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
|
|
|
# Enable CORS for frontend dashboard queries (React dev server runs on a separate port)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# JWT-like HMAC Secure Token Utilities
|
|
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "seekright_rmm_secret_key_2026_default")
|
|
|
|
# Per-heartbeat telemetry dumps flooded journald+syslog and helped fill the disk
|
|
# (2026-07-23: / hit 100% and mongod fatally aborted). Now opt-in via env.
|
|
VERBOSE_TELEMETRY = os.getenv("VERBOSE_TELEMETRY", "false").lower() in ("1", "true", "yes")
|
|
|
|
def generate_token(username: str) -> str:
|
|
# Expire in 1 day (24 hours)
|
|
expiry = (datetime.utcnow() + timedelta(days=1)).isoformat()
|
|
payload = {
|
|
"username": username,
|
|
"expires": expiry
|
|
}
|
|
payload_json = json.dumps(payload)
|
|
payload_b64 = base64.urlsafe_b64encode(payload_json.encode('utf-8')).decode('utf-8').rstrip('=')
|
|
|
|
sig = hmac.new(JWT_SECRET_KEY.encode('utf-8'), payload_b64.encode('utf-8'), hashlib.sha256).hexdigest()
|
|
return f"{payload_b64}.{sig}"
|
|
|
|
def verify_token(token: str) -> bool:
|
|
try:
|
|
parts = token.split(".")
|
|
if len(parts) != 2:
|
|
return False
|
|
payload_b64, sig = parts
|
|
|
|
# Verify signature
|
|
expected_sig = hmac.new(JWT_SECRET_KEY.encode('utf-8'), payload_b64.encode('utf-8'), hashlib.sha256).hexdigest()
|
|
if not hmac.compare_digest(sig, expected_sig):
|
|
return False
|
|
|
|
# Decode and check expiry
|
|
padding = 4 - (len(payload_b64) % 4)
|
|
if padding < 4:
|
|
payload_b64 += "=" * padding
|
|
payload_json = base64.urlsafe_b64decode(payload_b64.encode('utf-8')).decode('utf-8')
|
|
payload = json.loads(payload_json)
|
|
|
|
expires_dt = datetime.fromisoformat(payload["expires"])
|
|
if datetime.utcnow() > expires_dt:
|
|
return False # Expired
|
|
|
|
return payload["username"] == "root"
|
|
except Exception:
|
|
return False
|
|
|
|
# FastAPI dependency to secure UI endpoints
|
|
async def get_current_user(authorization: str = Header(None)):
|
|
if not authorization or not authorization.startswith("Bearer "):
|
|
raise HTTPException(status_code=401, detail="Missing or invalid authentication credentials")
|
|
token = authorization.split(" ")[1]
|
|
if not verify_token(token):
|
|
raise HTTPException(status_code=401, detail="Authentication token is invalid or has expired")
|
|
return "root"
|
|
|
|
import os
|
|
import json
|
|
from datetime import datetime
|
|
|
|
# In-memory storage for client hardware telemetry
|
|
client_telemetry = {}
|
|
|
|
from pymongo import MongoClient
|
|
|
|
# Database Connection: Configured via environment variable with local fallback
|
|
MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
|
|
DB_NAME = os.getenv("MONGODB_DB", "rmm_db")
|
|
|
|
mongo_client = MongoClient(MONGODB_URI)
|
|
db = mongo_client[DB_NAME]
|
|
|
|
clients_collection = db["clients"]
|
|
logs_collection = db["logs"]
|
|
config_collection = db["config"]
|
|
|
|
# Rolling vitals history: one slim sample per client per minute, expired
|
|
# automatically by MongoDB after 7 days (~1.1 MB/week/system, ~35 MB fleet).
|
|
history_collection = db["telemetry_history"]
|
|
HISTORY_RETENTION_DAYS = 7
|
|
try:
|
|
history_collection.create_index("ts", expireAfterSeconds=HISTORY_RETENTION_DAYS * 24 * 3600)
|
|
history_collection.create_index([("client_id", 1), ("ts", 1)])
|
|
except Exception as e:
|
|
print(f"[!] Could not create telemetry_history indexes: {e}")
|
|
|
|
# --- 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")
|
|
|
|
# During a token rotation both the new and the outgoing token must validate,
|
|
# otherwise agents can't authenticate the very download that gives them the new
|
|
# one. Set AGENT_TOKEN_PREVIOUS in .env while rotating, then clear it once the
|
|
# whole fleet reports the new token.
|
|
AGENT_TOKEN_PREVIOUS = os.getenv("AGENT_TOKEN_PREVIOUS", "")
|
|
|
|
def is_valid_agent_token(token: Optional[str]) -> bool:
|
|
if token is None:
|
|
return False
|
|
for accepted in (AGENT_TOKEN, AGENT_TOKEN_PREVIOUS):
|
|
if accepted and hmac.compare_digest(token, accepted):
|
|
return True
|
|
return False
|
|
|
|
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
|
|
|
|
ROCKETCHAT_WEBHOOK_URL = os.getenv("ROCKETCHAT_WEBHOOK_URL", "")
|
|
|
|
# Alert states are now stored directly in the MongoDB client documents
|
|
# to support multi-worker environments safely.
|
|
|
|
def send_rocketchat_notification(text: str, color: str = "#808080"):
|
|
"""
|
|
Dispatches a formatted notification payload to the Rocket.Chat incoming webhook.
|
|
"""
|
|
if not ROCKETCHAT_WEBHOOK_URL:
|
|
try:
|
|
print(f"[Rocket.Chat Simulation] {text}")
|
|
except UnicodeEncodeError:
|
|
# Fallback for Windows consoles that do not support printing unicode emojis
|
|
sanitized_text = text.encode('ascii', errors='backslashreplace').decode('ascii')
|
|
print(f"[Rocket.Chat Simulation] {sanitized_text}")
|
|
return
|
|
|
|
payload = {
|
|
"text": text,
|
|
"attachments": [
|
|
{
|
|
"color": color,
|
|
"ts": datetime.now().isoformat()
|
|
}
|
|
]
|
|
}
|
|
|
|
try:
|
|
import urllib.request
|
|
import json
|
|
req = urllib.request.Request(
|
|
ROCKETCHAT_WEBHOOK_URL,
|
|
data=json.dumps(payload).encode('utf-8'),
|
|
headers={'Content-Type': 'application/json'}
|
|
)
|
|
with urllib.request.urlopen(req, timeout=3.0) as response:
|
|
pass
|
|
except Exception as e:
|
|
print(f"[!] Error sending Rocket.Chat notification: {e}")
|
|
|
|
def append_to_logs(log_type: str, client_id: str, detail: Any):
|
|
"""
|
|
Appends a new event log grouped by client_id in MongoDB logs collection.
|
|
Ensures fair-share log limits per system so noisy clients never overwrite others.
|
|
"""
|
|
try:
|
|
entry = {
|
|
"client_id": client_id,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"type": log_type,
|
|
"detail": detail
|
|
}
|
|
logs_collection.insert_one(entry)
|
|
|
|
# Enforce fair-share logging limits per system (FIFO circular buffer)
|
|
count = logs_collection.count_documents({"client_id": client_id})
|
|
if count > MAX_ENTRIES_PER_CLIENT:
|
|
oldest_docs = logs_collection.find(
|
|
{"client_id": client_id},
|
|
{"_id": 1}
|
|
).sort("timestamp", 1).limit(count - MAX_ENTRIES_PER_CLIENT)
|
|
|
|
ids_to_delete = [doc["_id"] for doc in oldest_docs]
|
|
if ids_to_delete:
|
|
logs_collection.delete_many({"_id": {"$in": ids_to_delete}})
|
|
except Exception as e:
|
|
print(f"[!] Error writing log to MongoDB: {e}")
|
|
|
|
def record_history_sample(client_id: str, payload):
|
|
"""
|
|
Store one slim vitals sample per client per minute. The _id is the
|
|
client+minute bucket so concurrent uvicorn workers can't duplicate a
|
|
sample, and $setOnInsert keeps the first write of each minute.
|
|
"""
|
|
try:
|
|
bucket = datetime.now().replace(second=0, microsecond=0)
|
|
gpu_temp = None
|
|
for g in (payload.gpus or []):
|
|
raw = str(g.get("temp", "")).strip().rstrip("C").strip()
|
|
try:
|
|
gpu_temp = float(raw)
|
|
break
|
|
except ValueError:
|
|
continue
|
|
history_collection.update_one(
|
|
{"_id": f"{client_id}|{bucket.isoformat()}"},
|
|
{"$setOnInsert": {
|
|
"client_id": client_id,
|
|
"ts": bucket,
|
|
"cpu": payload.cpu_percent,
|
|
"cpu_temp": payload.cpu_temp,
|
|
"ram": payload.memory_percent,
|
|
"gpu_temp": gpu_temp,
|
|
}},
|
|
upsert=True,
|
|
)
|
|
except Exception as e:
|
|
print(f"[!] History sample failed for {client_id}: {e}")
|
|
|
|
def mark_client_active(client_id: str):
|
|
try:
|
|
now_str = datetime.now().isoformat()
|
|
# Atomically check if transitioned from offline to online (active is not True)
|
|
old_doc = clients_collection.find_one_and_update(
|
|
{"_id": client_id, "active": {"$ne": True}},
|
|
{"$set": {"active": True, "last_seen": now_str, "last_down_alert_time": None}},
|
|
return_document=False
|
|
)
|
|
if old_doc:
|
|
# Client transitioned from inactive/None to active!
|
|
# Send UP recovery alert only if they had a down alert time logged
|
|
if old_doc.get("last_down_alert_time"):
|
|
send_rocketchat_notification(
|
|
text=f"✅ **System UP:** Client `{client_id}` has recovered and is back online.",
|
|
color="#2ecc71"
|
|
)
|
|
else:
|
|
# Client is already active, just update their last_seen timestamp
|
|
clients_collection.update_one(
|
|
{"_id": client_id},
|
|
{"$set": {"last_seen": now_str}}
|
|
)
|
|
except Exception as e:
|
|
print(f"[!] Error marking client active: {e}")
|
|
|
|
def load_clients() -> Dict[str, Any]:
|
|
try:
|
|
# Clean up any ghost clients with empty or whitespace-only keys
|
|
clients_collection.delete_many({"_id": {"$in": ["", None]}})
|
|
clients_collection.delete_many({"_id": {"$regex": "^\\s*$"}})
|
|
|
|
# Load all documents
|
|
cursor = clients_collection.find()
|
|
data = {}
|
|
for doc in cursor:
|
|
cid = doc["_id"]
|
|
|
|
# Compute active status dynamically for reads, but do NOT write back to database or alert
|
|
last_seen_str = doc.get("last_seen")
|
|
is_active = doc.get("active", False)
|
|
if last_seen_str:
|
|
try:
|
|
last_seen_dt = datetime.fromisoformat(last_seen_str)
|
|
if (datetime.now() - last_seen_dt).total_seconds() >= 30:
|
|
is_active = False
|
|
except Exception:
|
|
pass
|
|
|
|
info = {k: v for k, v in doc.items() if k != "_id"}
|
|
info["active"] = is_active
|
|
data[cid] = info
|
|
|
|
return data
|
|
except Exception as e:
|
|
print(f"[!] Error loading clients from MongoDB: {e}")
|
|
return {}
|
|
|
|
import asyncio
|
|
import shutil
|
|
|
|
CPU_TEMP_ALERT_C = float(os.getenv("CPU_TEMP_ALERT_C", "85"))
|
|
CPU_TEMP_CLEAR_C = float(os.getenv("CPU_TEMP_CLEAR_C", "75"))
|
|
SERVER_DISK_ALERT_PERCENT = 85.0
|
|
_server_disk_state = {"alerted_at": None}
|
|
|
|
def check_server_disk():
|
|
# The RMM watches every site's disks but died on its own full disk
|
|
# (2026-07-23: / hit 100%, mongod aborted). Watch our own disk too.
|
|
try:
|
|
usage = shutil.disk_usage("/")
|
|
pct = round(usage.used / usage.total * 100, 1)
|
|
free_gb = round(usage.free / (1024 ** 3), 1)
|
|
now = datetime.now()
|
|
if pct >= SERVER_DISK_ALERT_PERCENT:
|
|
last = _server_disk_state["alerted_at"]
|
|
if last is None or (now - last).total_seconds() >= 3600:
|
|
_server_disk_state["alerted_at"] = now
|
|
send_rocketchat_notification(
|
|
text=f"🚨 **RMM SERVER DISK:** {pct}% used, only {free_gb} GB free on the central server. At 100% MongoDB dies — clean up now.",
|
|
color="#e74c3c",
|
|
)
|
|
elif _server_disk_state["alerted_at"] is not None:
|
|
_server_disk_state["alerted_at"] = None
|
|
send_rocketchat_notification(
|
|
text=f"✅ **RMM SERVER DISK recovered:** {pct}% used, {free_gb} GB free.",
|
|
color="#2ecc71",
|
|
)
|
|
except Exception as e:
|
|
print(f"[!] Server disk self-check failed: {e}")
|
|
|
|
async def monitor_heartbeats_loop():
|
|
while True:
|
|
check_server_disk()
|
|
try:
|
|
now = datetime.now()
|
|
timeout_time = (now - timedelta(seconds=30)).isoformat()
|
|
|
|
# Find clients that are active in DB but haven't checked in for 30s
|
|
cursor = clients_collection.find({
|
|
"active": True,
|
|
"last_seen": {"$lt": timeout_time}
|
|
})
|
|
for doc in cursor:
|
|
cid = doc["_id"]
|
|
now_str = now.isoformat()
|
|
|
|
# Atomically update to active = False
|
|
# Ensures only one request/worker triggers the state transition and sends the alert
|
|
old_doc = clients_collection.find_one_and_update(
|
|
{"_id": cid, "active": True, "last_seen": doc["last_seen"]},
|
|
{"$set": {"active": False, "last_down_alert_time": now_str}},
|
|
return_document=False
|
|
)
|
|
if old_doc:
|
|
send_rocketchat_notification(
|
|
text=f"🚨 **System DOWN:** Client `{cid}` has missed heartbeats for over 30 seconds.",
|
|
color="#e74c3c"
|
|
)
|
|
|
|
# Hourly reminders for STILL DOWN clients
|
|
reminder_time = (now - timedelta(hours=1)).isoformat()
|
|
cursor_still_down = clients_collection.find({
|
|
"active": False,
|
|
"last_down_alert_time": {"$lt": reminder_time}
|
|
})
|
|
for doc in cursor_still_down:
|
|
cid = doc["_id"]
|
|
old_last_down = doc["last_down_alert_time"]
|
|
now_str = now.isoformat()
|
|
|
|
# Atomically update reminder timestamp
|
|
old_doc = clients_collection.find_one_and_update(
|
|
{"_id": cid, "active": False, "last_down_alert_time": old_last_down},
|
|
{"$set": {"last_down_alert_time": now_str}},
|
|
return_document=False
|
|
)
|
|
if old_doc:
|
|
send_rocketchat_notification(
|
|
text=f"🚨 **System STILL DOWN:** Client `{cid}` remains offline (reminder sent every hour).",
|
|
color="#e74c3c"
|
|
)
|
|
except Exception as e:
|
|
print(f"[!] Error in heartbeat monitoring loop: {e}")
|
|
|
|
await asyncio.sleep(10)
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
asyncio.create_task(monitor_heartbeats_loop())
|
|
print("[*] Heartbeat monitoring background task initialized.")
|
|
|
|
class TelemetryPayload(BaseModel):
|
|
cpu_percent: float
|
|
memory_percent: float
|
|
memory_total_gb: float
|
|
memory_free_gb: float
|
|
disks: List[Dict[str, Any]]
|
|
gpus: List[Dict[str, Any]]
|
|
cpu_temp: Optional[float] = None
|
|
local_ips: Optional[List[str]] = None
|
|
agent_version: Optional[str] = None
|
|
speed_upload_mbps: Optional[float] = None
|
|
speed_download_mbps: Optional[float] = None
|
|
|
|
class CommandResultPayload(BaseModel):
|
|
command: str
|
|
returncode: int
|
|
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
|
|
|
|
DASHBOARD_USERNAME = os.getenv("DASHBOARD_USERNAME", "root")
|
|
DASHBOARD_PASSWORD = os.getenv("DASHBOARD_PASSWORD", "seekright159@")
|
|
|
|
@app.post("/api/login")
|
|
async def login(payload: LoginPayload):
|
|
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, token_valid: bool = Depends(require_agent_token)):
|
|
"""
|
|
Endpoint for remote agents to fetch their OS-specific whitelisted commands.
|
|
"""
|
|
commands_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "commands.json")
|
|
try:
|
|
with open(commands_file, "r") as f:
|
|
data = json.load(f)
|
|
return data.get(platform, {})
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to load commands: {e}")
|
|
|
|
@app.get("/api/get-command")
|
|
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?"
|
|
"""
|
|
if not client_id or not client_id.strip():
|
|
raise HTTPException(status_code=400, detail="client_id cannot be empty")
|
|
|
|
now_str = datetime.now().isoformat()
|
|
# Dynamic fleet auto-registration
|
|
clients_collection.update_one(
|
|
{"_id": client_id},
|
|
{"$setOnInsert": {"pending_command": "none", "active": True, "last_seen": now_str}},
|
|
upsert=True
|
|
)
|
|
|
|
# Get and reset command atomically
|
|
updated_doc = clients_collection.find_one_and_update(
|
|
{"_id": client_id},
|
|
{"$set": {"pending_command": "none"}},
|
|
return_document=False # Returns the state before update
|
|
)
|
|
|
|
cmd = "none"
|
|
if updated_doc:
|
|
cmd = updated_doc.get("pending_command", "none")
|
|
|
|
if cmd != "none":
|
|
append_to_logs("command_polled", client_id, {"command": cmd})
|
|
|
|
mark_client_active(client_id)
|
|
return {"command": cmd}
|
|
|
|
@app.post("/api/schedule-command")
|
|
async def schedule_command(client_id: str, command: str, current_user: str = Depends(get_current_user)):
|
|
"""
|
|
Endpoint for your Dashboard/UI to schedule a new command.
|
|
Supports a comma-separated list of client IDs for batch fleet updates.
|
|
"""
|
|
target_ids = [cid.strip() for cid in client_id.split(",") if cid.strip()]
|
|
if not target_ids:
|
|
raise HTTPException(status_code=400, detail="No target client IDs specified")
|
|
|
|
for cid in target_ids:
|
|
clients_collection.update_one(
|
|
{"_id": cid},
|
|
{"$set": {"pending_command": command}},
|
|
upsert=True
|
|
)
|
|
append_to_logs("command_scheduled", cid, {"command": command})
|
|
|
|
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, token_valid: bool = Depends(require_agent_token)):
|
|
"""
|
|
Endpoint for agents to push their live hardware telemetry.
|
|
"""
|
|
if not client_id or not client_id.strip():
|
|
raise HTTPException(status_code=400, detail="client_id cannot be empty")
|
|
|
|
now_str = datetime.now().isoformat()
|
|
# Auto-register client if we see it through telemetry first
|
|
clients_collection.update_one(
|
|
{"_id": client_id},
|
|
{"$setOnInsert": {"pending_command": "none", "active": True, "last_seen": now_str}},
|
|
upsert=True
|
|
)
|
|
|
|
mark_client_active(client_id)
|
|
record_history_sample(client_id, payload)
|
|
|
|
# Get current alert states from database to prevent duplicate alerts
|
|
client_doc = clients_collection.find_one({"_id": client_id}) or {}
|
|
storage_alerts = client_doc.get("active_storage_alerts", [])
|
|
gpu_alert_sent = client_doc.get("gpu_alert_sent", False)
|
|
|
|
storage_modified = False
|
|
gpu_modified = False
|
|
|
|
# Storage warning checker
|
|
for disk in payload.disks:
|
|
mount = disk.get("mount", "/")
|
|
percent = disk.get("percent", 0.0)
|
|
|
|
if percent >= 80.0:
|
|
if mount not in storage_alerts:
|
|
storage_alerts.append(mount)
|
|
storage_modified = True
|
|
send_rocketchat_notification(
|
|
text=f"⚠️ **Storage Warning:** Client `{client_id}` disk `{mount}` is at **{percent}%** capacity.",
|
|
color="#f39c12"
|
|
)
|
|
else:
|
|
if mount in storage_alerts:
|
|
storage_alerts.remove(mount)
|
|
storage_modified = True
|
|
send_rocketchat_notification(
|
|
text=f"✅ **Storage Recovered:** Client `{client_id}` disk `{mount}` has cleared warning state and is at **{percent}%**.",
|
|
color="#2ecc71"
|
|
)
|
|
|
|
# GPU Failure Monitor
|
|
if len(payload.gpus) == 0:
|
|
if not gpu_alert_sent:
|
|
gpu_alert_sent = True
|
|
gpu_modified = True
|
|
send_rocketchat_notification(
|
|
text=f"🚨 **GPU Failure:** Client `{client_id}` is not reporting any GPU data! (nvidia-smi has failed or is missing)",
|
|
color="#e74c3c"
|
|
)
|
|
else:
|
|
if gpu_alert_sent:
|
|
gpu_alert_sent = False
|
|
gpu_modified = True
|
|
send_rocketchat_notification(
|
|
text=f"✅ **GPU Recovered:** Client `{client_id}` is reporting GPU data successfully again.",
|
|
color="#2ecc71"
|
|
)
|
|
|
|
# CPU thermal alerting. Hysteresis (alert at 85C, clear at 75C) stops a node
|
|
# hovering on the threshold from spamming Rocket.Chat every 10 seconds.
|
|
temp_alert_sent = client_doc.get("cpu_temp_alert_sent", False)
|
|
temp_modified = False
|
|
cpu_temp = payload.cpu_temp
|
|
if cpu_temp is not None:
|
|
if cpu_temp >= CPU_TEMP_ALERT_C and not temp_alert_sent:
|
|
temp_alert_sent = True
|
|
temp_modified = True
|
|
send_rocketchat_notification(
|
|
text=f"🔥 **CPU OVERHEATING:** Client `{client_id}` CPU at **{cpu_temp}°C** (threshold {CPU_TEMP_ALERT_C}°C). Check cooling — thermal shutdown risk.",
|
|
color="#e74c3c",
|
|
)
|
|
elif cpu_temp <= CPU_TEMP_CLEAR_C and temp_alert_sent:
|
|
temp_alert_sent = False
|
|
temp_modified = True
|
|
send_rocketchat_notification(
|
|
text=f"✅ **CPU temperature normal:** Client `{client_id}` back down to {cpu_temp}°C.",
|
|
color="#2ecc71",
|
|
)
|
|
|
|
update_fields = {
|
|
"telemetry": payload.dict()
|
|
}
|
|
if storage_modified:
|
|
update_fields["active_storage_alerts"] = storage_alerts
|
|
if gpu_modified:
|
|
update_fields["gpu_alert_sent"] = gpu_alert_sent
|
|
if temp_modified:
|
|
update_fields["cpu_temp_alert_sent"] = temp_alert_sent
|
|
|
|
clients_collection.update_one(
|
|
{"_id": client_id},
|
|
{"$set": update_fields}
|
|
)
|
|
|
|
# Log telemetry history event
|
|
append_to_logs("telemetry", client_id, payload.dict())
|
|
|
|
data = payload.dict()
|
|
client_telemetry[client_id] = data
|
|
|
|
# Log received stats (opt-in: this was the main journald/syslog flood source)
|
|
if VERBOSE_TELEMETRY:
|
|
print(f"\n[TELEMETRY RECEIVED] from {client_id}:")
|
|
print(f" CPU: {data['cpu_percent']}%")
|
|
print(f" RAM: {data['memory_percent']}% ({data['memory_free_gb']} GB Free / {data['memory_total_gb']} GB Total)")
|
|
|
|
for disk in data['disks']:
|
|
print(f" Drive {disk['mount']}: {disk['percent']}% Used ({disk['free_gb']} GB Free / {disk['total_gb']} GB Total)")
|
|
|
|
for gpu in data['gpus']:
|
|
power_str = f"Power: {gpu.get('power_draw', 'N/A')} / {gpu.get('power_limit', 'N/A')}"
|
|
fan_str = f"Fan: {gpu.get('fan_speed', 'N/A')}"
|
|
print(f" GPU [{gpu['name']}]: Core: {gpu['utilization']}, Temp: {gpu['temp']}, VRAM: {gpu['memory_used']}/{gpu['memory_total']}, {power_str}, {fan_str}")
|
|
|
|
return {"status": "success"}
|
|
|
|
data = payload.dict()
|
|
client_telemetry[client_id] = data
|
|
|
|
# Temporarily print the data to the console for the user to see!
|
|
print(f"\n[TELEMETRY RECEIVED] from {client_id}:")
|
|
print(f" CPU: {data['cpu_percent']}%")
|
|
print(f" RAM: {data['memory_percent']}% ({data['memory_free_gb']} GB Free / {data['memory_total_gb']} GB Total)")
|
|
|
|
for disk in data['disks']:
|
|
print(f" Drive {disk['mount']}: {disk['percent']}% Used ({disk['free_gb']} GB Free / {disk['total_gb']} GB Total)")
|
|
|
|
for gpu in data['gpus']:
|
|
power_str = f"Power: {gpu.get('power_draw', 'N/A')} / {gpu.get('power_limit', 'N/A')}"
|
|
fan_str = f"Fan: {gpu.get('fan_speed', 'N/A')}"
|
|
print(f" GPU [{gpu['name']}]: Core: {gpu['utilization']}, Temp: {gpu['temp']}, VRAM: {gpu['memory_used']}/{gpu['memory_total']}, {power_str}, {fan_str}")
|
|
|
|
return {"status": "success"}
|
|
|
|
@app.get("/api/telemetry")
|
|
async def get_all_telemetry(current_user: str = Depends(get_current_user)):
|
|
"""
|
|
Endpoint to view the live dashboard data of all clients.
|
|
"""
|
|
clients = load_clients()
|
|
return {cid: info.get("telemetry", {}) for cid, info in clients.items() if info.get("telemetry")}
|
|
|
|
@app.get("/api/clients")
|
|
async def get_clients_api(current_user: str = Depends(get_current_user)):
|
|
"""
|
|
Endpoint for the React UI to fetch the live active client registry with computed statuses.
|
|
"""
|
|
return load_clients()
|
|
|
|
@app.get("/api/logs")
|
|
async def get_logs_history(current_user: str = Depends(get_current_user)):
|
|
"""
|
|
Endpoint to retrieve logs history grouped by client_id.
|
|
"""
|
|
try:
|
|
# Newest 25 per client with slim details. The full-fat version shipped
|
|
# every stored telemetry payload (2 MB+ JSON) and the dashboard re-polls
|
|
# this every 3s — it was hanging the browser.
|
|
grouped_logs = {}
|
|
cursor = logs_collection.find().sort("timestamp", -1)
|
|
for doc in cursor:
|
|
cid = doc.get("client_id")
|
|
if not cid:
|
|
continue
|
|
bucket = grouped_logs.setdefault(cid, [])
|
|
if len(bucket) >= 25:
|
|
continue
|
|
dtype = doc.get("type")
|
|
detail = doc.get("detail")
|
|
if dtype == "telemetry" and isinstance(detail, dict):
|
|
detail = {"cpu_percent": detail.get("cpu_percent"),
|
|
"memory_percent": detail.get("memory_percent")}
|
|
elif isinstance(detail, dict):
|
|
detail = {k: (v[:400] if isinstance(v, str) else v) for k, v in detail.items()}
|
|
bucket.append({
|
|
"timestamp": doc.get("timestamp"),
|
|
"type": dtype,
|
|
"detail": detail
|
|
})
|
|
for cid in grouped_logs:
|
|
grouped_logs[cid].reverse()
|
|
return grouped_logs
|
|
except Exception as e:
|
|
print(f"[!] Error loading logs from MongoDB: {e}")
|
|
return {}
|
|
|
|
@app.post("/api/command-result")
|
|
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.
|
|
"""
|
|
if not client_id or not client_id.strip():
|
|
raise HTTPException(status_code=400, detail="client_id cannot be empty")
|
|
|
|
append_to_logs("command_result", client_id, payload.dict())
|
|
return {"status": "success"}
|
|
|
|
@app.get("/api/get-raw-commands")
|
|
async def get_raw_commands(current_user: str = Depends(get_current_user)):
|
|
"""
|
|
Endpoint for the UI to load the complete commands.json whitelist file.
|
|
"""
|
|
commands_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "commands.json")
|
|
try:
|
|
with open(commands_file, "r") as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to read commands: {e}")
|
|
|
|
@app.post("/api/save-commands")
|
|
async def save_commands(commands: Dict[str, Any], current_user: str = Depends(get_current_user)):
|
|
"""
|
|
Endpoint for the UI to save changes back to commands.json.
|
|
"""
|
|
commands_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "commands.json")
|
|
try:
|
|
with open(commands_file, "w") as f:
|
|
json.dump(commands, f, indent=2)
|
|
return {"status": "success", "message": "commands.json saved successfully"}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to save commands: {e}")
|
|
|
|
@app.get("/metrics", response_class=PlainTextResponse)
|
|
async def get_prometheus_metrics():
|
|
"""
|
|
Exposes the latest client telemetry in standard Prometheus text format.
|
|
"""
|
|
clients = load_clients()
|
|
metrics_lines = []
|
|
|
|
for client_id, data in clients.items():
|
|
# Active status (1.0 for active/online, 0.0 for inactive/offline)
|
|
active_val = 1.0 if data.get("active", False) else 0.0
|
|
metrics_lines.append(f'rmm_client_active{{client_id="{client_id}"}} {active_val}')
|
|
|
|
# Telemetry stats
|
|
telemetry = data.get("telemetry")
|
|
if telemetry:
|
|
cpu = telemetry.get("cpu_percent", 0.0)
|
|
ram = telemetry.get("memory_percent", 0.0)
|
|
ram_free = telemetry.get("memory_free_gb", 0.0)
|
|
ram_total = telemetry.get("memory_total_gb", 0.0)
|
|
|
|
metrics_lines.append(f'rmm_cpu_utilization{{client_id="{client_id}"}} {cpu}')
|
|
metrics_lines.append(f'rmm_memory_utilization{{client_id="{client_id}"}} {ram}')
|
|
metrics_lines.append(f'rmm_memory_free_bytes{{client_id="{client_id}"}} {ram_free * (1024**3)}')
|
|
metrics_lines.append(f'rmm_memory_total_bytes{{client_id="{client_id}"}} {ram_total * (1024**3)}')
|
|
|
|
# Disk mounts
|
|
for disk in telemetry.get("disks", []):
|
|
mount = disk.get("mount", "/")
|
|
disk_percent = disk.get("percent", 0.0)
|
|
metrics_lines.append(f'rmm_disk_utilization{{client_id="{client_id}",mount="{mount}"}} {disk_percent}')
|
|
|
|
# GPU utilization and temps
|
|
for gpu in telemetry.get("gpus", []):
|
|
gpu_name = gpu.get("name", "GPU")
|
|
|
|
# Utilization percent parsing
|
|
gpu_util_str = str(gpu.get("utilization", "0"))
|
|
gpu_util = float(gpu_util_str.replace("%", "").strip())
|
|
|
|
# Temperature parsing
|
|
gpu_temp_str = str(gpu.get("temp", "0"))
|
|
gpu_temp = float(gpu_temp_str.replace("C", "").strip())
|
|
|
|
metrics_lines.append(f'rmm_gpu_utilization{{client_id="{client_id}",gpu_name="{gpu_name}"}} {gpu_util}')
|
|
metrics_lines.append(f'rmm_gpu_temperature{{client_id="{client_id}",gpu_name="{gpu_name}"}} {gpu_temp}')
|
|
|
|
return "\n".join(metrics_lines) + "\n"
|
|
|
|
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.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/<disk-uuid>/SR/SHIFT or /mnt/<disk-uuid>/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.get("/api/history")
|
|
async def get_history(client_id: str, hours: int = 6, current_user: str = Depends(get_current_user)):
|
|
"""Vitals history for one node — powers the dashboard sparklines."""
|
|
hours = min(max(hours, 1), HISTORY_RETENTION_DAYS * 24)
|
|
since = datetime.now() - timedelta(hours=hours)
|
|
try:
|
|
cursor = history_collection.find(
|
|
{"client_id": client_id, "ts": {"$gte": since}},
|
|
{"_id": 0, "client_id": 0},
|
|
).sort("ts", 1)
|
|
return [{
|
|
"ts": d["ts"].isoformat(),
|
|
"cpu": d.get("cpu"),
|
|
"cpu_temp": d.get("cpu_temp"),
|
|
"ram": d.get("ram"),
|
|
"gpu_temp": d.get("gpu_temp"),
|
|
} for d in cursor]
|
|
except Exception as e:
|
|
print(f"[!] Error loading history for {client_id}: {e}")
|
|
return []
|
|
|
|
@app.get("/api/agent-token")
|
|
async def get_agent_token(current_user: str = Depends(get_current_user)):
|
|
"""Current agent token, for building the install command in the dashboard.
|
|
Logged-in operators only — the installer download itself is now authenticated."""
|
|
return {"token": AGENT_TOKEN}
|
|
|
|
@app.get("/api/history-bulk")
|
|
async def get_history_bulk(points: int = 60, current_user: str = Depends(get_current_user)):
|
|
"""
|
|
Recent vitals for every node in one request — the dashboard sparklines poll
|
|
this instead of one request per card.
|
|
"""
|
|
points = min(max(points, 5), 240)
|
|
since = datetime.now() - timedelta(minutes=points)
|
|
out = {}
|
|
try:
|
|
cursor = history_collection.find(
|
|
{"ts": {"$gte": since}},
|
|
{"_id": 0, "ts": 1, "client_id": 1, "cpu": 1, "cpu_temp": 1, "ram": 1},
|
|
).sort("ts", 1)
|
|
for d in cursor:
|
|
out.setdefault(d["client_id"], []).append({
|
|
"ts": d["ts"].isoformat(),
|
|
"cpu": d.get("cpu"),
|
|
"cpu_temp": d.get("cpu_temp"),
|
|
"ram": d.get("ram"),
|
|
})
|
|
except Exception as e:
|
|
print(f"[!] Error loading bulk history: {e}")
|
|
return out
|
|
|
|
@app.post("/api/clear-search")
|
|
async def clear_search(client_id: str, current_user: str = Depends(get_current_user)):
|
|
"""Drop a node's stored search result so the dashboard stops showing it."""
|
|
clients_collection.update_one({"_id": client_id}, {"$unset": {"file_search": ""}})
|
|
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():
|
|
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}
|
|
|
|
@app.get("/api/server-files")
|
|
async def list_server_files(current_user: str = Depends(get_current_user)):
|
|
"""
|
|
All videos currently stored on the server (per client), so the dashboard
|
|
can show and manage what is occupying the 20 GB transfer store.
|
|
"""
|
|
data = {}
|
|
try:
|
|
for cid in sorted(os.listdir(FILE_TRANSFER_DIR)):
|
|
client_dir = os.path.join(FILE_TRANSFER_DIR, cid)
|
|
if not os.path.isdir(client_dir):
|
|
continue
|
|
files = []
|
|
for name in sorted(os.listdir(client_dir)):
|
|
if ".part" in name:
|
|
continue
|
|
path = os.path.join(client_dir, name)
|
|
if os.path.isfile(path):
|
|
st = os.stat(path)
|
|
files.append({
|
|
"name": name,
|
|
"size_mb": round(st.st_size / (1024 * 1024), 2),
|
|
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
|
|
})
|
|
if files:
|
|
data[cid] = files
|
|
except Exception as e:
|
|
print(f"[!] Error listing server files: {e}")
|
|
return data
|
|
|
|
@app.post("/api/delete-server-file")
|
|
async def delete_server_file(client_id: str, filename: str, current_user: str = Depends(get_current_user)):
|
|
safe_client = os.path.basename(client_id.strip())
|
|
safe_name = os.path.basename(filename.strip())
|
|
if not safe_client or not safe_name:
|
|
raise HTTPException(status_code=400, detail="client_id and filename are required")
|
|
path = os.path.join(FILE_TRANSFER_DIR, safe_client, safe_name)
|
|
if not os.path.isfile(path):
|
|
raise HTTPException(status_code=404, detail="File not found on server")
|
|
os.remove(path)
|
|
# Drop the stale 'ready' transfer card if it points at the deleted file
|
|
doc = clients_collection.find_one({"_id": client_id}, {"file_transfer": 1})
|
|
if doc and (doc.get("file_transfer") or {}).get("filename") == safe_name:
|
|
clients_collection.update_one({"_id": client_id}, {"$unset": {"file_transfer": ""}})
|
|
append_to_logs("server_file_deleted", client_id, {"filename": safe_name})
|
|
return {"status": "success", "message": f"Deleted '{safe_name}' from server"}
|
|
|
|
@app.get("/api/speedtest-blob")
|
|
async def speedtest_blob(size_mb: int = 8, token_valid: bool = Depends(require_agent_token)):
|
|
# Agents time this download to estimate their downlink to the server.
|
|
size_mb = max(1, min(int(size_mb), 32))
|
|
def gen():
|
|
chunk = b"\0" * (1024 * 1024)
|
|
for _ in range(size_mb):
|
|
yield chunk
|
|
return StreamingResponse(gen(), media_type="application/octet-stream")
|
|
|
|
@app.post("/api/speedtest-sink")
|
|
async def speedtest_sink(request: Request, token_valid: bool = Depends(require_agent_token)):
|
|
# Agents time this upload; the body is read and discarded.
|
|
total = 0
|
|
async for chunk in request.stream():
|
|
total += len(chunk)
|
|
return {"status": "success", "received_bytes": total}
|
|
|
|
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, StreamingResponse
|
|
|
|
@app.get("/deploy_agent.sh")
|
|
async def get_agent_installer(x_agent_token: str = Header(None), token: Optional[str] = None,
|
|
authorization: str = Header(None)):
|
|
# The served installer carries the LIVE agent token, so this download is
|
|
# authenticated in EVERY mode — gating it on strict mode published the
|
|
# fleet's token to the internet. Accepted proof: an agent token (header for
|
|
# self-update, ?token= for a human running the install by hand) or a
|
|
# logged-in dashboard session.
|
|
dashboard_ok = bool(authorization and authorization.startswith("Bearer ")
|
|
and verify_token(authorization.split(" ")[1]))
|
|
if not (is_valid_agent_token(x_agent_token) or is_valid_agent_token(token) or dashboard_ok):
|
|
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")
|
|
# 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",
|
|
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, request: Request,
|
|
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.
|
|
# During rotation both tokens validate, so also record whether the agent
|
|
# holds the CURRENT one — that is the signal for "safe to drop the previous
|
|
# token and flip strict".
|
|
presented = request.headers.get("x-agent-token")
|
|
token_is_current = bool(AGENT_TOKEN and presented
|
|
and hmac.compare_digest(presented, AGENT_TOKEN))
|
|
set_fields = {"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,
|
|
"agent_token_current": token_is_current}
|
|
# The Synology reverse proxy forwards the site's real public IP — record it
|
|
# so every node's WAN address is visible without any agent-side lookup.
|
|
xff = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip()
|
|
if xff:
|
|
set_fields["public_ip"] = xff
|
|
clients_collection.update_one({"_id": client_id}, {"$set": set_fields})
|
|
doc = clients_collection.find_one_and_update(
|
|
{"_id": client_id},
|
|
{"$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,
|
|
"search": search,
|
|
"shift_path": shift_path
|
|
}
|
|
|
|
|
|
# (Phase-1 sync-monitor proxy removed 2026-08-17 — replaced by the native
|
|
# /api/sync-monitor/* implementation below, same URL contract.)
|
|
|
|
|
|
# =============================================================================
|
|
# Sync-monitor NATIVE ingest — Phase 1 of absorbing the rclone collector
|
|
# (2026-08-17). The v3.6-sync RMM agent POSTs parsed sync-log events here.
|
|
# ADDITIVE: own sync_* collections in rmm_db, own endpoint, existing agent
|
|
# token auth. Runs in PARALLEL with the standalone collector until Phase 4.
|
|
# Logic ported from R-clone monitor/collector/main.py incl. its fixes:
|
|
# skip-aware pairs, restart-safe round summaries, transfer-byte accounting.
|
|
# =============================================================================
|
|
import re
|
|
from datetime import datetime as _sdt, timedelta as _std, timezone as _stz
|
|
|
|
sync_machines = db["sync_machines"]
|
|
sync_pairs = db["sync_pairs"]
|
|
sync_events = db["sync_events"]
|
|
sync_events.create_index([("machine_id", 1), ("received_at", -1)])
|
|
sync_events.create_index([("machine_id", 1), ("type", 1), ("direction", 1),
|
|
("received_at", -1)])
|
|
sync_pairs.create_index([("machine_id", 1), ("pair", 1)], unique=True)
|
|
try:
|
|
sync_events.create_index([("received_at", 1)], name="sync_events_ttl",
|
|
expireAfterSeconds=30 * 86400)
|
|
except Exception:
|
|
pass # window changed — harmless, old TTL keeps working
|
|
|
|
_SYNC_KEEP = {"round_start", "round_end", "pair_ok", "pair_fail",
|
|
"file_synced", "error"}
|
|
_SYNC_LOG_TS = "%Y/%m/%d %H:%M:%S"
|
|
_SYNC_SIZES = {"": 1, "K": 1e3, "Ki": 1024, "M": 1e6, "Mi": 1024 ** 2,
|
|
"G": 1e9, "Gi": 1024 ** 3, "T": 1e12, "Ti": 1024 ** 4}
|
|
|
|
|
|
def _sync_size_bytes(s):
|
|
m = re.match(r"([\d.]+)\s*([KMGT]i?)?B$", (s or "").strip())
|
|
return float(m.group(1)) * _SYNC_SIZES[m.group(2) or ""] if m else None
|
|
|
|
|
|
def _sync_summarise_round(mid, end_ev, inventory, ts):
|
|
rnd = end_ev.get("round")
|
|
if db is None or rnd is None:
|
|
return
|
|
# dedup on round AND log_ts: container restarts reset the round counter
|
|
if sync_events.find_one({"machine_id": mid, "type": "round_summary",
|
|
"round": rnd, "log_ts": end_ev.get("log_ts")}):
|
|
return
|
|
start_ev = sync_events.find_one(
|
|
{"machine_id": mid, "type": "round_start", "round": rnd},
|
|
sort=[("received_at", -1)])
|
|
since = start_ev["received_at"] if start_ev else ts - _std(minutes=30)
|
|
synced = list(sync_events.find(
|
|
{"machine_id": mid, "type": "file_synced", "round": rnd,
|
|
"received_at": {"$gte": since}}, {"file": 1, "direction": 1}))
|
|
up = [e for e in synced if e.get("direction") == "up"]
|
|
sizes = {f["name"]: f.get("size", 0)
|
|
for files in (inventory or {}).values() for f in files}
|
|
bytes_up = sum(sizes.get(e.get("file"), 0) for e in up)
|
|
duration = None
|
|
try:
|
|
t0 = _sdt.strptime(start_ev["log_ts"].strip(), _SYNC_LOG_TS)
|
|
t1 = _sdt.strptime(end_ev["log_ts"].strip(), _SYNC_LOG_TS)
|
|
duration = (t1 - t0).total_seconds()
|
|
except Exception:
|
|
pass
|
|
summary = {"type": "round_summary", "machine_id": mid, "round": rnd,
|
|
"log_ts": end_ev.get("log_ts"), "files": len(synced),
|
|
"files_up": len(up), "files_down": len(synced) - len(up),
|
|
"bytes_up": bytes_up, "duration_s": duration,
|
|
"avg_bps": (bytes_up / duration) if duration and bytes_up else None,
|
|
"received_at": ts}
|
|
sync_events.insert_one(dict(summary))
|
|
if up:
|
|
summary.pop("_id", None)
|
|
sync_machines.update_one({"machine_id": mid},
|
|
{"$set": {"last_transfer": summary}})
|
|
|
|
|
|
@app.post("/api/sync-ingest")
|
|
async def sync_ingest(payload: Dict[str, Any], request: Request,
|
|
_auth: None = Depends(require_agent_token)):
|
|
"""Heartbeat + parsed sync-log events from the RMM agent's sync module."""
|
|
mid = payload.get("machine_id")
|
|
if not mid:
|
|
raise HTTPException(status_code=400, detail="machine_id required")
|
|
ts = _sdt.now(_stz.utc)
|
|
mset = {"machine_id": mid, "site": payload.get("site"), "last_seen": ts,
|
|
"no_container": bool(payload.get("no_container"))}
|
|
if "inventory" in payload:
|
|
mset["inventory"] = payload["inventory"]
|
|
if payload.get("events"):
|
|
mset["last_activity"] = ts
|
|
|
|
to_insert = []
|
|
for ev in payload.get("events", []):
|
|
etype = ev.get("type")
|
|
if etype == "round_start":
|
|
mset["current_round"] = ev.get("round")
|
|
mset["progress"] = None
|
|
elif etype == "round_end":
|
|
mset["last_round"] = ev.get("round")
|
|
mset["last_round_at"] = ts
|
|
mset["progress"] = None
|
|
elif etype == "progress":
|
|
mset["progress"] = {k: ev.get(k) for k in
|
|
("pair", "direction", "percent", "speed",
|
|
"eta", "done", "total")}
|
|
elif etype == "pair_skip":
|
|
sync_pairs.update_one(
|
|
{"machine_id": mid, "pair": ev.get("pair")},
|
|
{"$set": {"direction": ev.get("direction"),
|
|
"last_skip_at": ts, "last_idle_s": ev.get("idle_s")},
|
|
"$setOnInsert": {"last_status": "idle"}}, upsert=True)
|
|
elif etype in ("pair_start", "pair_ok", "pair_fail"):
|
|
status = {"pair_start": "running", "pair_ok": "ok",
|
|
"pair_fail": "fail"}[etype]
|
|
ev["transferred_bytes"] = _sync_size_bytes(ev.get("transferred"))
|
|
upd = {"machine_id": mid, "pair": ev.get("pair"),
|
|
"direction": ev.get("direction"), "last_status": status,
|
|
"updated_at": ts}
|
|
if etype == "pair_fail":
|
|
upd["last_exit"] = ev.get("exit_code")
|
|
upd["last_fail_at"] = ts
|
|
elif etype == "pair_ok":
|
|
upd["last_exit"] = 0
|
|
upd["last_ok_at"] = ts
|
|
sync_pairs.update_one({"machine_id": mid, "pair": ev.get("pair")},
|
|
{"$set": upd}, upsert=True)
|
|
if etype in _SYNC_KEEP:
|
|
to_insert.append({**ev, "machine_id": mid, "received_at": ts})
|
|
|
|
sync_machines.update_one({"machine_id": mid},
|
|
{"$set": mset,
|
|
"$setOnInsert": {"first_seen": ts}}, upsert=True)
|
|
if to_insert:
|
|
sync_events.insert_many(to_insert)
|
|
for ev in payload.get("events", []):
|
|
if ev.get("type") == "round_end":
|
|
inv = payload.get("inventory")
|
|
if inv is None:
|
|
inv = (sync_machines.find_one({"machine_id": mid},
|
|
{"inventory": 1}) or {}).get("inventory")
|
|
_sync_summarise_round(mid, ev, inv, ts)
|
|
return {"ok": True, "stored": len(to_insert)}
|
|
|
|
|
|
# =============================================================================
|
|
# Sync-monitor NATIVE engine (Phase 2, 2026-08-17): verification against the
|
|
# NAS, NAS health, fleet view/alerts, stats & timeseries — same URL contract
|
|
# the SyncMonitor UI already uses. Listings/health live in Mongo so the 4
|
|
# uvicorn workers share one verification effort instead of quadrupling it.
|
|
# rclone is invoked with env-injected config (no config file on disk).
|
|
# =============================================================================
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
|
|
_SYNC_NAS_HOST = os.getenv("REAL_SYNOLOGY_HOST", "")
|
|
_SYNC_NAS_PORT = os.getenv("REAL_SYNOLOGY_PORT", "22")
|
|
_SYNC_NAS_USER = os.getenv("REAL_SYNOLOGY_USER", "")
|
|
_SYNC_NAS_PASS = os.getenv("REAL_SYNOLOGY_PASS", "")
|
|
_SYNC_VERIFY_IVL = int(os.getenv("SYNC_VERIFY_INTERVAL", "300"))
|
|
_SYNC_GRACE_S = int(os.getenv("SYNC_MISSING_GRACE_S", "600"))
|
|
_SYNC_NAS_MIN_FREE = float(os.getenv("NAS_MIN_FREE_PCT", "10"))
|
|
_SYNC_ONLINE_S = 60
|
|
_SYNC_STALLED_S = 900
|
|
_sync_listings = db["sync_listings"] # {_id: remote_path, files: [...], at}
|
|
_sync_rclone_env = None
|
|
|
|
|
|
def _sync_now():
|
|
# their Mongo client is NOT tz_aware: reads come back naive-UTC, so all
|
|
# read-path arithmetic uses naive-UTC now
|
|
return _sdt.utcnow()
|
|
|
|
|
|
def _sync_rclone(args, timeout):
|
|
"""Run rclone against the NAS remote via env-injected config."""
|
|
global _sync_rclone_env
|
|
if not _SYNC_NAS_HOST:
|
|
return None
|
|
if _sync_rclone_env is None:
|
|
obscured = subprocess.run(["rclone", "obscure", _SYNC_NAS_PASS],
|
|
capture_output=True, text=True, timeout=15)
|
|
env = dict(os.environ)
|
|
env.update({"RCLONE_CONFIG_SYNOREAL_TYPE": "sftp",
|
|
"RCLONE_CONFIG_SYNOREAL_HOST": _SYNC_NAS_HOST,
|
|
"RCLONE_CONFIG_SYNOREAL_PORT": _SYNC_NAS_PORT,
|
|
"RCLONE_CONFIG_SYNOREAL_USER": _SYNC_NAS_USER,
|
|
"RCLONE_CONFIG_SYNOREAL_PASS": obscured.stdout.strip()})
|
|
_sync_rclone_env = env
|
|
return subprocess.run(["rclone"] + args, capture_output=True,
|
|
timeout=timeout, env=_sync_rclone_env)
|
|
|
|
|
|
def _sync_verify_loop():
|
|
time.sleep(60 + (os.getpid() % 180)) # stagger the 4 workers
|
|
while True:
|
|
try:
|
|
# NAS health first — reachability + capacity, shared via Mongo
|
|
nas_doc = _sync_listings.find_one({"_id": "@nas"})
|
|
if not nas_doc or (_sync_now() - nas_doc["at"]).total_seconds() > _SYNC_VERIFY_IVL:
|
|
# per-VOLUME capacity: `about` on the SFTP root reports the
|
|
# chrooted home (2.4 GB!), not the data shares the uploads
|
|
# land on — each share sits on its own volume
|
|
health = {"_id": "@nas", "at": _sync_now(), "ok": False,
|
|
"error": None, "volumes": []}
|
|
try:
|
|
for vol in [v for v in os.getenv(
|
|
"NAS_VOLUMES", "").split(",") if v.strip()] or [""]:
|
|
out = _sync_rclone(["about", f"synoreal:{vol.strip()}",
|
|
"--json"], 60)
|
|
if out is None:
|
|
health["error"] = "NAS creds not configured"
|
|
break
|
|
if out.returncode == 0:
|
|
about = json.loads(out.stdout)
|
|
health["ok"] = True # at least one volume answered
|
|
health["volumes"].append(
|
|
{"path": vol.strip() or "/",
|
|
"total": about.get("total"),
|
|
"free": about.get("free")})
|
|
else:
|
|
health["error"] = out.stderr.decode()[:200]
|
|
except FileNotFoundError:
|
|
health["error"] = "rclone binary not installed on server"
|
|
except Exception as e:
|
|
health["error"] = str(e)[:200]
|
|
_sync_listings.update_one({"_id": "@nas"}, {"$set": health},
|
|
upsert=True)
|
|
|
|
# listings for every known up-pair remote path
|
|
for p in sync_pairs.find({"direction": "up"}):
|
|
label = p.get("pair", "")
|
|
if "server:" not in label:
|
|
continue
|
|
rpath = label.split("server:", 1)[1].strip()
|
|
doc = _sync_listings.find_one({"_id": rpath})
|
|
if doc and (_sync_now() - doc["at"]).total_seconds() < _SYNC_VERIFY_IVL:
|
|
continue
|
|
try:
|
|
out = _sync_rclone(["lsjson", "--recursive", "--files-only",
|
|
f"synoreal:{rpath}"], 300)
|
|
if out is not None and out.returncode == 0:
|
|
files = [{"n": f["Path"], "s": f["Size"]}
|
|
for f in json.loads(out.stdout)]
|
|
_sync_listings.update_one(
|
|
{"_id": rpath},
|
|
{"$set": {"files": files, "at": _sync_now()}},
|
|
upsert=True)
|
|
except FileNotFoundError:
|
|
break # no rclone binary — health doc already says so
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
time.sleep(_SYNC_VERIFY_IVL)
|
|
|
|
|
|
threading.Thread(target=_sync_verify_loop, daemon=True).start()
|
|
|
|
|
|
def _sync_annotate(m, pairs):
|
|
inv = m.get("inventory") or {}
|
|
total_pending = total_bytes = 0
|
|
verified_any = False
|
|
for p in pairs:
|
|
label = p.get("pair", "")
|
|
if p.get("direction") != "up" or "server:" not in label:
|
|
continue
|
|
local = label.split("→")[0].strip()
|
|
rpath = label.split("server:", 1)[1].strip()
|
|
listing = _sync_listings.find_one({"_id": rpath})
|
|
files = inv.get(local)
|
|
if listing is None or files is None:
|
|
continue
|
|
verified_any = True
|
|
server_names = {f["n"] for f in listing.get("files", [])}
|
|
pending = [f for f in files if f["name"] not in server_names]
|
|
p["pending_count"] = len(pending)
|
|
p["pending_bytes"] = sum(f.get("size", 0) for f in pending)
|
|
p["verified_at"] = listing["at"]
|
|
p["missing"] = [f["name"] for f in pending
|
|
if time.time() - f["mtime"] > _SYNC_GRACE_S][:20]
|
|
total_pending += len(pending)
|
|
total_bytes += p["pending_bytes"]
|
|
if not verified_any:
|
|
return None, None
|
|
return total_pending, total_bytes
|
|
|
|
|
|
def _sync_machine_view(m, ts):
|
|
mid = m["machine_id"]
|
|
last_seen = m.get("last_seen")
|
|
online = bool(last_seen) and (ts - last_seen).total_seconds() < _SYNC_ONLINE_S
|
|
pairs = [dict(p, _id=None) for p in sync_pairs.find({"machine_id": mid})]
|
|
for p in pairs:
|
|
p.pop("_id", None)
|
|
pending_total, pending_bytes = _sync_annotate(m, pairs)
|
|
hour_ago = ts - _std(hours=1)
|
|
up_pairs = [p for p in pairs if p.get("direction") == "up"]
|
|
progress = m.get("progress")
|
|
return {
|
|
"machine_id": mid, "site": m.get("site"), "online": online,
|
|
"last_seen": last_seen, "last_activity": m.get("last_activity"),
|
|
"first_seen": m.get("first_seen"),
|
|
"no_container": m.get("no_container", False),
|
|
"current_round": m.get("current_round"),
|
|
"last_round": m.get("last_round"),
|
|
"last_round_at": m.get("last_round_at"),
|
|
"progress": progress if (progress or {}).get("direction") == "up" else None,
|
|
"last_transfer": m.get("last_transfer"),
|
|
"pairs": pairs, "up_pairs": up_pairs,
|
|
"pending_total": pending_total, "pending_bytes": pending_bytes,
|
|
"in_progress": len([p for p in up_pairs if p.get("last_status") == "running"]),
|
|
"failed_up": len([p for p in up_pairs if p.get("last_status") == "fail"]),
|
|
"uploads_1h": sync_events.count_documents(
|
|
{"machine_id": mid, "type": "file_synced", "direction": "up",
|
|
"received_at": {"$gt": hour_ago}}),
|
|
"uploads_24h": sync_events.count_documents(
|
|
{"machine_id": mid, "type": "file_synced", "direction": "up",
|
|
"received_at": {"$gt": ts - _std(hours=24)}}),
|
|
"errors_1h": sync_events.count_documents(
|
|
{"machine_id": mid, "type": "error", "direction": "up",
|
|
"received_at": {"$gt": hour_ago}}),
|
|
}
|
|
|
|
|
|
def _sync_fleet():
|
|
ts = _sync_now()
|
|
machines = sorted((_sync_machine_view(m, ts) for m in sync_machines.find()),
|
|
key=lambda x: x["machine_id"])
|
|
alerts = []
|
|
nas = _sync_listings.find_one({"_id": "@nas"}) or {}
|
|
nas.pop("_id", None)
|
|
if nas and not nas.get("ok"):
|
|
alerts.append({"severity": "critical", "machine_id": "NAS",
|
|
"message": f"NAS check failed: {nas.get('error')}"})
|
|
for vol in nas.get("volumes") or []:
|
|
if vol.get("total"):
|
|
vol["free_pct"] = round(100.0 * (vol.get("free") or 0) / vol["total"], 1)
|
|
if vol["free_pct"] < _SYNC_NAS_MIN_FREE:
|
|
alerts.append({"severity": "critical", "machine_id": "NAS",
|
|
"message": f"NAS volume {vol['path']} nearly full — "
|
|
f"{vol['free_pct']}% free"})
|
|
for m in machines:
|
|
if m["no_container"]:
|
|
continue # roster-style visibility, but no sync rules apply
|
|
if not m["online"]:
|
|
alerts.append({"severity": "critical", "machine_id": m["machine_id"],
|
|
"message": "machine offline — no heartbeat"})
|
|
if (m["online"] and not m.get("last_round_at") and not m.get("last_activity")
|
|
and m.get("first_seen")
|
|
and (ts - m["first_seen"]).total_seconds() > _SYNC_STALLED_S):
|
|
alerts.append({"severity": "serious", "machine_id": m["machine_id"],
|
|
"message": "agent alive but sync log SILENT since install "
|
|
"— is the sync container running?"})
|
|
for p in m["up_pairs"]:
|
|
if p.get("last_status") == "fail":
|
|
alerts.append({"severity": "serious", "machine_id": m["machine_id"],
|
|
"message": f"upload pair failing (exit {p.get('last_exit')}): {p['pair']}"})
|
|
if m["online"] and m["errors_1h"] > 0:
|
|
alerts.append({"severity": "warning", "machine_id": m["machine_id"],
|
|
"message": f"{m['errors_1h']} upload error(s) in the last hour"})
|
|
busy_now = (m.get("last_activity")
|
|
and (ts - m["last_activity"]).total_seconds() < 120)
|
|
for p in m["up_pairs"]:
|
|
if p.get("missing") and not (p.get("last_status") == "running" and busy_now):
|
|
alerts.append({"severity": "serious", "machine_id": m["machine_id"],
|
|
"message": f"{len(p['missing'])} file(s) NOT on server after "
|
|
f"{_SYNC_GRACE_S // 60}min grace: "
|
|
f"{', '.join(p['missing'][:3])}…"})
|
|
if m["online"] and m.get("last_round_at"):
|
|
marks = [m["last_round_at"]]
|
|
if m.get("last_activity"):
|
|
marks.append(m["last_activity"])
|
|
stalled = (ts - max(marks)).total_seconds()
|
|
if stalled > _SYNC_STALLED_S:
|
|
age = f"{stalled / 3600:.1f}h" if stalled >= 3600 else f"{int(stalled // 60)}min"
|
|
alerts.append({"severity": "serious", "machine_id": m["machine_id"],
|
|
"message": f"agent alive but NO sync round completed in {age}"})
|
|
return ts, machines, alerts, nas
|
|
|
|
|
|
@app.get("/api/sync-monitor/health")
|
|
def sync_native_health():
|
|
return {"ok": True, "time": _sync_now(), "native": True}
|
|
|
|
|
|
@app.get("/api/sync-monitor/overview")
|
|
def sync_native_overview(current_user: str = Depends(get_current_user)):
|
|
ts, machines, alerts, nas = _sync_fleet()
|
|
return {"generated_at": ts, "machines": machines, "alerts": alerts, "nas": nas}
|
|
|
|
|
|
@app.get("/api/sync-monitor/alerts")
|
|
def sync_native_alerts(current_user: str = Depends(get_current_user)):
|
|
ts, _, alerts, _nas = _sync_fleet()
|
|
return {"generated_at": ts, "count": len(alerts), "alerts": alerts}
|
|
|
|
|
|
_SYNC_WINDOWS = [("1h", 3600), ("24h", 86400), ("3d", 259200),
|
|
("7d", 604800), ("30d", 2592000)]
|
|
_SYNC_BUCKET = {"1h": 300, "24h": 3600, "3d": 10800, "7d": 21600, "30d": 86400}
|
|
|
|
|
|
@app.get("/api/sync-monitor/stats")
|
|
def sync_native_stats(current_user: str = Depends(get_current_user)):
|
|
ts = _sync_now()
|
|
cuts = {lbl: ts - _std(seconds=s) for lbl, s in _SYNC_WINDOWS}
|
|
counters = {"uploaded": {"type": "file_synced", "direction": "up"},
|
|
"downloaded": {"type": "file_synced", "direction": "down"},
|
|
"errors": {"type": "error", "direction": "up"},
|
|
"failures": {"type": "pair_fail", "direction": "up"},
|
|
"rounds": {"type": "round_summary"}}
|
|
sums = {"bytes": ({"type": "round_summary"}, "bytes_up"),
|
|
"seconds": ({"type": "round_summary"}, "duration_s"),
|
|
"up_bytes": ({"type": "pair_ok", "direction": "up"}, "transferred_bytes"),
|
|
"down_bytes": ({"type": "pair_ok", "direction": "down"}, "transferred_bytes")}
|
|
|
|
def cond(cut, conds):
|
|
cl = [{"$gte": ["$received_at", cut]}]
|
|
cl += [{"$eq": [{"$ifNull": [f"${k}", None]}, v]} for k, v in conds.items()]
|
|
return {"$and": cl}
|
|
|
|
group = {"_id": "$machine_id"}
|
|
for lbl, _s in _SYNC_WINDOWS:
|
|
for name, conds in counters.items():
|
|
group[f"{name}|{lbl}"] = {"$sum": {"$cond": [cond(cuts[lbl], conds), 1, 0]}}
|
|
for name, (conds, field) in sums.items():
|
|
group[f"{name}|{lbl}"] = {"$sum": {"$cond": [
|
|
cond(cuts[lbl], conds), {"$ifNull": [f"${field}", 0]}, 0]}}
|
|
|
|
per_machine = {}
|
|
for row in sync_events.aggregate([
|
|
{"$match": {"received_at": {"$gte": cuts["30d"]}}},
|
|
{"$group": group}]):
|
|
windows = {}
|
|
for lbl, _s in _SYNC_WINDOWS:
|
|
byts = row.get(f"bytes|{lbl}") or 0
|
|
secs = row.get(f"seconds|{lbl}") or 0
|
|
windows[lbl] = {k: row.get(f"{k}|{lbl}", 0) for k in
|
|
("uploaded", "downloaded", "errors", "failures",
|
|
"rounds", "up_bytes", "down_bytes")}
|
|
windows[lbl]["bytes"] = byts
|
|
windows[lbl]["seconds"] = secs
|
|
windows[lbl]["avg_bps"] = (byts / secs) if secs > 0 and byts else None
|
|
per_machine[row["_id"]] = windows
|
|
|
|
fleet = {}
|
|
for lbl, _s in _SYNC_WINDOWS:
|
|
agg = {k: 0 for k in ("uploaded", "downloaded", "errors", "failures",
|
|
"rounds", "bytes", "up_bytes", "down_bytes", "seconds")}
|
|
for wins in per_machine.values():
|
|
w = wins.get(lbl) or {}
|
|
for k in agg:
|
|
agg[k] += w.get(k) or 0
|
|
agg["avg_bps"] = (agg["bytes"] / agg["seconds"]) if agg["seconds"] else None
|
|
fleet[lbl] = agg
|
|
return {"generated_at": ts, "windows": [w for w, _ in _SYNC_WINDOWS],
|
|
"retention_days": 30, "fleet": fleet, "machines": per_machine}
|
|
|
|
|
|
@app.get("/api/sync-monitor/timeseries")
|
|
def sync_native_timeseries(range: str = "24h", machine_id: str = None,
|
|
current_user: str = Depends(get_current_user)):
|
|
if range not in _SYNC_BUCKET:
|
|
raise HTTPException(status_code=400, detail="bad range")
|
|
step = _SYNC_BUCKET[range]
|
|
ts = _sync_now()
|
|
start = ts - _std(seconds=dict(_SYNC_WINDOWS)[range])
|
|
match = {"received_at": {"$gte": start},
|
|
"type": {"$in": ["file_synced", "error"]}}
|
|
volm = {"received_at": {"$gte": start}, "type": "pair_ok"}
|
|
if machine_id:
|
|
match["machine_id"] = machine_id
|
|
volm["machine_id"] = machine_id
|
|
bucket = {"$toDate": {"$subtract": [{"$toLong": "$received_at"},
|
|
{"$mod": [{"$toLong": "$received_at"}, step * 1000]}]}}
|
|
is_up = {"$eq": [{"$ifNull": ["$direction", ""]}, "up"]}
|
|
is_down = {"$eq": [{"$ifNull": ["$direction", ""]}, "down"]}
|
|
series = {"$switch": {"branches": [
|
|
{"case": {"$and": [{"$eq": ["$type", "error"]}, is_up]}, "then": "errors"},
|
|
{"case": {"$and": [{"$eq": ["$type", "file_synced"]}, is_up]}, "then": "uploaded"},
|
|
{"case": {"$and": [{"$eq": ["$type", "file_synced"]}, is_down]}, "then": "downloaded"},
|
|
], "default": "skip"}}
|
|
counts, volumes = {}, {}
|
|
for row in sync_events.aggregate([{"$match": match},
|
|
{"$group": {"_id": {"b": bucket, "s": series}, "n": {"$sum": 1}}}]):
|
|
if row["_id"]["s"] != "skip":
|
|
counts[(int(row["_id"]["b"].replace(tzinfo=_stz.utc).timestamp()), row["_id"]["s"])] = row["n"]
|
|
for row in sync_events.aggregate([{"$match": volm},
|
|
{"$group": {"_id": {"b": bucket, "d": "$direction"},
|
|
"bytes": {"$sum": {"$ifNull": ["$transferred_bytes", 0]}}}}]):
|
|
volumes[(int(row["_id"]["b"].replace(tzinfo=_stz.utc).timestamp()), row["_id"]["d"])] = row["bytes"]
|
|
first = int(start.replace(tzinfo=_stz.utc).timestamp()) // step * step
|
|
last = int(ts.replace(tzinfo=_stz.utc).timestamp()) // step * step
|
|
points = []
|
|
b = first
|
|
while b <= last:
|
|
points.append({"t": _sdt.fromtimestamp(b, _stz.utc),
|
|
"uploaded": counts.get((b, "uploaded"), 0),
|
|
"downloaded": counts.get((b, "downloaded"), 0),
|
|
"errors": counts.get((b, "errors"), 0),
|
|
"up_bytes": volumes.get((b, "up"), 0),
|
|
"down_bytes": volumes.get((b, "down"), 0)})
|
|
b += step
|
|
return {"generated_at": ts, "range": range, "bucket_s": step,
|
|
"machine_id": machine_id, "points": points}
|
|
|
|
|
|
@app.get("/api/sync-monitor/machines/{machine_id}/events")
|
|
def sync_native_events(machine_id: str, type: str = None, direction: str = None,
|
|
q: str = None, since: _sdt = None, until: _sdt = None,
|
|
busy: bool = False, limit: int = 50, skip: int = 0,
|
|
current_user: str = Depends(get_current_user)):
|
|
query = {"machine_id": machine_id}
|
|
if type:
|
|
query["type"] = {"$in": type.split(",")}
|
|
if direction:
|
|
query["direction"] = direction
|
|
if busy:
|
|
query.setdefault("$and", []).append(
|
|
{"$or": [{"files_up": {"$gt": 0}}, {"files": {"$gt": 0}},
|
|
{"type": {"$ne": "round_summary"}}]})
|
|
if since or until:
|
|
rng = {}
|
|
if since:
|
|
rng["$gte"] = since.replace(tzinfo=None) if since.tzinfo else since
|
|
if until:
|
|
rng["$lt"] = until.replace(tzinfo=None) if until.tzinfo else until
|
|
query["received_at"] = rng
|
|
if q:
|
|
rx = {"$regex": re.escape(q), "$options": "i"}
|
|
query.setdefault("$and", []).append(
|
|
{"$or": [{"file": rx}, {"message": rx}, {"pair": rx}]})
|
|
limit = max(1, min(limit, 500))
|
|
total = sync_events.count_documents(query)
|
|
evs = list(sync_events.find(query, {"_id": 0})
|
|
.sort("received_at", -1).skip(max(0, skip)).limit(limit))
|
|
return {"machine_id": machine_id, "total": total, "skip": skip,
|
|
"limit": limit, "events": evs}
|
|
|
|
|
|
@app.delete("/api/sync-monitor/machines/{machine_id}")
|
|
def sync_native_forget(machine_id: str,
|
|
current_user: str = Depends(get_current_user)):
|
|
sync_machines.delete_one({"machine_id": machine_id})
|
|
pairs = sync_pairs.delete_many({"machine_id": machine_id}).deleted_count
|
|
events = sync_events.delete_many({"machine_id": machine_id}).deleted_count
|
|
return {"ok": True, "machine_id": machine_id, "pairs": pairs, "events": events}
|