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