feat: per-site SHIFT path with video search, fix agent token injection, accept cpu_temp

- deploy_agent.sh: agent v3.3-shift — use dashboard-configured SHIFT path for
  file fetch/search (SR/SHIFT sites have no TAKELEAP folder), date fast-path;
  fix installer discarding the injected agent token (sentinel was being
  rewritten by the server's placeholder replace, agents ended up tokenless)
- central_api_prototype.py: replace only the token assignment when serving the
  installer; add cpu_temp to TelemetryPayload (agents already send it, it was
  silently dropped); new endpoints set-shift-path, request-search,
  search-results; heartbeat response now carries shift_path + pending search
- commands.json: restore last_reboot/dummy, add update_diag diagnostic
- CLAUDE.md: document deployment layout, procedures, and gotchas
This commit is contained in:
2026-07-16 17:31:33 +05:30
parent b9aeedc5ef
commit c929176cfa
4 changed files with 323 additions and 9 deletions

124
CLAUDE.md Normal file
View File

@@ -0,0 +1,124 @@
# SeekRight Pulse RMM — Production Deployment Map
This VM (`vm3-mint`, LAN `192.168.1.201`, Tailscale `100.79.183.41`) is the
**production server** for the SeekRight Pulse RMM system. Field agents and the
public dashboard both talk to services running here.
## Architecture
```
Internet
Synology reverse proxy (takeleapindia.synology.me / 106.51.70.111)
├── https://rmm.seekright.com → this VM : vite port (currently 4173)
└── https://rmm-backend.seekright.com → this VM : 8000
Field agents (X-Agent-Token) ───────────────────┤
FastAPI backend (uvicorn :8000, 4 workers)
MongoDB localhost:27017, db `rmm_db`
```
## Components
### Backend — `/opt/rmm-backend`
- FastAPI app `central_api_prototype.py`, run by **systemd `rmm-backend.service`**
(User=root): `venv/bin/uvicorn central_api_prototype:app --host 0.0.0.0 --port 8000 --workers 4`
- Env: systemd `EnvironmentFile=/opt/rmm-backend/.env` injects vars **at service
start**. The `APP_ENV` / `.env.production` / `.env.development` logic in the
code is mostly vestigial here: values from `.env` are already in the process
environment and `load_dotenv` never overrides them. **Editing `.env` requires
a service restart to take effect.**
- Data: MongoDB `rmm_db` (`clients`, `logs`, `config` collections). Alerts go to
Rocket.Chat via `ROCKETCHAT_WEBHOOK_URL`.
- `commands.json` = whitelist of commands agents may run. Read from disk **per
request** (no restart needed). Also editable live from the dashboard config
editor (`/api` load/save endpoints) — meaning production edits land in the
working tree and will conflict with `git pull` (see gotchas).
- Agent auth: agents send `X-Agent-Token` (value `AGENT_TOKEN` in `.env`). Mode
lives in Mongo `config._id=agent_auth`: `grace` (default — tokenless legacy
agents still accepted) or `strict` (reject without token). Flip with
`set_strict.py` only after every field agent has been updated
(`deploy_agent.sh` / the `update_agent` command).
- Dashboard login: `POST /api/login`, credentials `DASHBOARD_USERNAME` /
`DASHBOARD_PASSWORD` from `.env`, JWT signed with `JWT_SECRET_KEY`.
### Frontend — `/opt/rmm-ui`
- React + Vite, run by **systemd `rmm-frontend.service`** (User=root):
`npm run dev -- --mode production` (a vite **dev server**, not a static build).
- Port is set in `vite.config.js` (`server.port`, currently **4173** — must
match the Synology reverse-proxy upstream);
`allowedHosts: ["rmm.seekright.com"]` must include the public hostname or
vite rejects proxied requests.
- Vite watches files: pulled code changes hot-reload automatically, and a
`vite.config.js` change makes vite restart itself — BUT an in-process
restart keeps whatever port vite already bound (including an auto-bumped
one like 4174 after a port collision). To change ports for real:
`sudo systemctl restart rmm-frontend.service`.
- API base URL comes from `/opt/rmm-ui/.env.production`
(`VITE_API_BASE_URL=https://rmm-backend.seekright.com`).
### Reverse proxy — Synology (NOT on this VM)
- No nginx/apache runs on this VM. TLS + routing for both public hostnames is
done on the Synology at `takeleapindia.synology.me` (106.51.70.111).
- **If the vite port changes in `vite.config.js`, the Synology reverse-proxy
upstream port must be updated to match** — otherwise the site 502s.
This exact thing happened 2026-07-16 (commit `dc604a4` moved 4173 → 6173).
### Field agents (remote machines)
- Run `/opt/seekright-agent/client_agent_prototype.py` on each monitored node;
poll `GET /api/get-command` and push `POST /api/telemetry`.
- Installed/updated via `deploy_agent.sh` (served by the backend; in strict
mode downloading it requires a valid agent token).
## How to deploy
Backend:
```bash
cd /opt/rmm-backend
git status # working tree should be clean — see gotchas
git pull
sudo systemctl restart rmm-backend.service
systemctl status rmm-backend.service --no-pager -n 20 # check for crash loop
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/docs # expect 200
```
Frontend:
```bash
cd /opt/rmm-ui
git pull # vite hot-reloads; restarts itself on config change
# only if deps changed: npm install && sudo systemctl restart rmm-frontend.service
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: rmm.seekright.com' http://localhost:4173/
curl -sk -o /dev/null -w '%{http_code}\n' https://rmm.seekright.com/ # via Synology
```
## Gotchas / history
- **Don't hand-edit tracked files on this server** (`commands.json`, `.env`
are tracked in git). Local edits block `git pull`. Commit changes to the repo
instead. Dashboard edits to `commands.json` also dirty the tree — push them
upstream after changing them.
- 2026-07-16: pull of `dc604a4` changed the vite port 4173 → 6173 and broke
https://rmm.seekright.com (Synology still forwarded to 4173). Resolved by
reverting `vite.config.js` to port 4173 in the working tree. That revert is
a LOCAL change to a tracked file — commit and push it (or the next pull
will conflict and re-break the site by pulling 6173 back in).
- The backend service must be restarted after every backend pull — uvicorn has
no auto-reload in the unit. A long-running process can silently serve
weeks-old code (happened Jul 6 → Jul 16).
- Adding a telemetry field takes THREE places: agent payload (in
`deploy_agent.sh`), `TelemetryPayload` in `central_api_prototype.py`, and the
UI. Pydantic silently DROPS any field missing from `TelemetryPayload`
agents sent `cpu_temp` for a while with the backend discarding it and no
error anywhere (found 2026-07-16).
- Ports on this VM: 8000 backend, 6173 rmm-ui vite, 5173 auditor-portal vite
(localhost only), 7514 + others auditor portal, 44304433 MeshCentral,
Grafana also runs here. Check `ss -tlnp` before assigning a new port.
## Other services on this VM (not part of RMM deploys)
- `meshcentral.service` — MeshCentral remote management (node, ports 44304433)
- `grafana` — monitoring dashboards
- `/opt/auditor-portal` — separate project (its own vite/node dev servers)

View File

@@ -325,6 +325,7 @@ class TelemetryPayload(BaseModel):
memory_free_gb: float
disks: List[Dict[str, Any]]
gpus: List[Dict[str, Any]]
cpu_temp: Optional[float] = None
agent_version: Optional[str] = None
speed_upload_mbps: Optional[float] = None
speed_download_mbps: Optional[float] = None
@@ -335,6 +336,12 @@ class CommandResultPayload(BaseModel):
stdout: str
stderr: str
class SearchResultsPayload(BaseModel):
query: str
results: List[Dict[str, Any]]
searched_path: Optional[str] = None
error: Optional[str] = None
class LoginPayload(BaseModel):
username: str
password: str
@@ -735,6 +742,57 @@ async def request_file(client_id: str, filename: str, current_user: str = Depend
append_to_logs("file_requested", client_id, {"filename": safe_name})
return {"status": "success", "message": f"File '{safe_name}' requested from {client_id}"}
@app.post("/api/set-shift-path")
async def set_shift_path(client_id: str, shift_path: str = "", current_user: str = Depends(get_current_user)):
"""
Store where this node keeps its SHIFT folder (varies per site:
/mnt/<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.post("/api/search-results")
async def receive_search_results(client_id: str, payload: SearchResultsPayload, token_valid: bool = Depends(require_agent_token)):
now_str = datetime.now().isoformat()
clients_collection.update_one(
{"_id": client_id},
{"$set": {"file_search": {
"query": payload.query,
"status": "error" if payload.error else "done",
"results": payload.results[:200],
"searched_path": payload.searched_path,
"error": payload.error,
"completed_at": now_str,
}}}
)
append_to_logs("search_results", client_id, {"query": payload.query, "count": len(payload.results), "error": payload.error})
return {"status": "success"}
@app.get("/api/get-file-request")
async def get_file_request(client_id: str, token_valid: bool = Depends(require_agent_token)):
if not client_id or not client_id.strip():
@@ -907,7 +965,14 @@ async def get_agent_installer(x_agent_token: str = Header(None), token: Optional
# the live agent token so a freshly downloaded installer bakes it into the agent.
with open(path, "rb") as f:
content = f.read().replace(b"\r\n", b"\n")
content = content.replace(b"__AGENT_TOKEN__", AGENT_TOKEN.encode("utf-8"))
# Replace only the assignment, not the "was I injected?" sentinel comparison
# a few lines below it — a blanket replace turns that check into an
# always-true self-comparison and the installer discards the injected token.
content = content.replace(
b'AGENT_TOKEN="__AGENT_TOKEN__"',
b'AGENT_TOKEN="' + AGENT_TOKEN.encode("utf-8") + b'"',
1,
)
return Response(
content=content,
media_type="text/x-shellscript",
@@ -949,20 +1014,26 @@ async def receive_heartbeat(client_id: str, payload: TelemetryPayload,
)
doc = clients_collection.find_one_and_update(
{"_id": client_id},
{"$set": {"pending_command": "none", "pending_file_request": "none"}},
{"$set": {"pending_command": "none", "pending_file_request": "none", "pending_search": "none"}},
return_document=False
)
cmd = "none"
fname = "none"
search = "none"
shift_path = None
if doc:
cmd = doc.get("pending_command", "none")
fname = doc.get("pending_file_request", "none")
search = doc.get("pending_search", "none") or "none"
shift_path = doc.get("shift_path") or None
if cmd != "none":
append_to_logs("command_polled", client_id, {"command": cmd})
return {
"status": "success",
"command": cmd,
"filename": fname
"filename": fname,
"search": search,
"shift_path": shift_path
}

View File

@@ -324,6 +324,19 @@
"/opt/seekright-agent/client_agent_prototype.py",
"--list-takeleap-sub-files",
"UPLOAD_FOLDER"
],
"last_reboot": [
"last",
"reboot"
],
"dummy": [
"last",
"reboot"
],
"update_diag": [
"bash",
"-c",
"echo HOST:$(hostname); echo USER:$(id -un); echo MACHINE_ID:$(cat /etc/machine-id 2>/dev/null); echo AGENT_PROCS:; pgrep -af client_agent_prototype 2>/dev/null; echo APT_PROCS:; ps aux | grep -E \"apt-get|apt |dpkg|unattended\" | grep -v grep; echo UPDATE_UNITS:; systemctl list-units \"run-r*\" --no-legend --all 2>&1 | head -5"
]
}
}
}

View File

@@ -40,7 +40,9 @@ if [ -z "$CENTRAL_URL" ]; then CENTRAL_URL="http://rmm-backend.seekright.com"; f
AGENT_TOKEN="__AGENT_TOKEN__"
if [ -n "$ARG_TOKEN" ]; then
AGENT_TOKEN="$ARG_TOKEN"
elif [ "$AGENT_TOKEN" = "__AGENT_TOKEN__" ]; then
elif [ "$AGENT_TOKEN" = "__AGENT_""TOKEN__" ]; then
# (placeholder split in two so the server's token injection can't rewrite this
# comparison — it must only replace the assignment above)
if [ -f "$EXISTING_PY" ]; then
AGENT_TOKEN="$(sed -n 's/^AGENT_TOKEN = "\(.*\)"/\1/p' "$EXISTING_PY" | head -1)"
else
@@ -99,7 +101,7 @@ def get_cpu_temp():
pass
return None
AGENT_VERSION = "3.2-auth"
AGENT_VERSION = "3.3-shift"
CLIENT_ID = "TEMPLATE_CLIENT_ID"
CENTRAL_BASE = "TEMPLATE_CENTRAL_URL"
@@ -123,6 +125,8 @@ CENTRAL_FILE_STATUS_URL = f"{CENTRAL_BASE}/api/file-transfer-status?client_id={C
CACHED_COMMANDS = {}
command_in_progress = False
file_transfer_in_progress = False
SHIFT_PATH = None # per-site SHIFT folder, configured on the dashboard, delivered via heartbeat
search_in_progress = False
def run_command_async(command_key, safe_cmd_list, is_shutdown_or_restart):
global command_in_progress
@@ -367,7 +371,23 @@ def send_heartbeat():
t = threading.Thread(target=handle_file_request_async, args=(fname,))
t.daemon = True
t.start()
# Server-configured SHIFT folder for this node
global SHIFT_PATH
sp = res.get("shift_path")
if sp:
SHIFT_PATH = sp
# Handle video search requests
squery = res.get("search", "none")
if squery and squery != "none":
global search_in_progress
if not search_in_progress:
search_in_progress = True
t = threading.Thread(target=run_file_search, args=(squery,))
t.daemon = True
t.start()
print("[*] Heartbeat successful.")
except Exception as e:
print(f"[!] Failed heartbeat: {e}")
@@ -685,6 +705,75 @@ def list_takeleap_subfolder_files(subfolder_name):
def list_takeleap_subfolder(subfolder_name):
list_takeleap_subfolder_files(subfolder_name)
def find_shift_roots():
# The dashboard-configured path wins; without one, fall back to any
# */TAKELEAP/SHIFT discovered by the legacy drive scan.
if SHIFT_PATH and os.path.isdir(SHIFT_PATH):
return [SHIFT_PATH]
roots = []
if os.name == "nt":
drives = [f"{letter}:\\" for letter in string.ascii_uppercase if os.path.exists(f"{letter}:\\")]
else:
drives = ["/mnt", "/media", "/home"]
if os.path.exists("/TAKELEAP"):
drives.append("/TAKELEAP")
for drive in drives:
for root, dirs, files in os.walk(drive, topdown=True):
if os.name == "nt":
dirs[:] = [d for d in dirs if d.lower() not in {"windows", "program files", "program files (x86)", "appdata", "programdata", "$recycle.bin", "system volume information", "node_modules", ".git"}]
else:
if root == "/":
dirs[:] = [d for d in dirs if d.lower() not in {"proc", "sys", "dev", "var", "lib", "run", "boot", "snap", "node_modules", ".git"}]
elif root == "/mnt":
dirs[:] = [d for d in dirs if not (len(d) == 1 and d.isalpha()) and d.lower() not in {"wslg", "wsl"}]
if os.path.basename(root).upper() == "TAKELEAP":
for sub in dirs:
if sub.upper() == "SHIFT":
roots.append(os.path.join(root, sub))
dirs[:] = [] # don't descend into an already-claimed subtree
return roots
def run_file_search(query):
# Case-insensitive filename substring search in the SHIFT folder(s);
# results are posted back for the dashboard to render.
global search_in_progress
MAX_RESULTS = 200
try:
target = query.lower()
roots = find_shift_roots()
results = []
for base in roots:
for froot, fdirs, ffiles in os.walk(base):
for f in ffiles:
if target in f.lower():
fp = os.path.join(froot, f)
entry = {"name": f, "path": fp}
try:
st = os.stat(fp)
entry["size_mb"] = round(st.st_size / (1024 * 1024), 2)
entry["modified"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_mtime))
except Exception:
pass
results.append(entry)
if len(results) >= MAX_RESULTS:
break
if len(results) >= MAX_RESULTS:
break
if len(results) >= MAX_RESULTS:
break
payload = {"query": query, "results": results, "searched_path": ", ".join(roots)}
if not roots:
payload["error"] = "No SHIFT folder configured or found on this system"
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(f"{CENTRAL_BASE}/api/search-results?client_id={CLIENT_ID}", data=data, headers={'Content-Type': 'application/json'}, method='POST')
req.add_header('ngrok-skip-browser-warning', 'true')
urllib.request.urlopen(req, timeout=30)
print(f" -> Search '{query}': posted {len(results)} match(es).")
except Exception as e:
print(f" -> [!] File search failed: {e}")
finally:
search_in_progress = False
# ============================================================
# Remote File Transfer: dashboard requests a file by name,
# agent finds it under any TAKELEAP folder and uploads it.
@@ -725,6 +814,23 @@ def find_file_in_takeleap(filename):
except Exception:
pass
# Configured SHIFT path takes priority — SR/SHIFT sites have no TAKELEAP
# folder anywhere, so the legacy scan below can never find their files.
if SHIFT_PATH and os.path.isdir(SHIFT_PATH):
if fast_path_rel:
# SHIFT_PATH ends in .../SHIFT and fast_path_rel starts with SHIFT/,
# so join the date subpath onto SHIFT_PATH's parent.
specific_dir = os.path.join(os.path.dirname(SHIFT_PATH), fast_path_rel)
if os.path.exists(specific_dir):
for froot, fdirs, ffiles in os.walk(specific_dir):
for f in ffiles:
if f.lower() == target:
return os.path.join(froot, f)
for froot, fdirs, ffiles in os.walk(SHIFT_PATH):
for f in ffiles:
if f.lower() == target:
return os.path.join(froot, f)
if os.name == "nt":
drives = [f"{letter}:\\" for letter in string.ascii_uppercase if os.path.exists(f"{letter}:\\")]
else: