file transfer

This commit is contained in:
2026-07-16 14:30:36 +05:30
parent 80e1bd8e3a
commit b9aeedc5ef
10 changed files with 1388 additions and 82 deletions

View File

@@ -10,26 +10,57 @@ if [ "$EUID" -ne 0 ]; then
fi
# ====================================================
# ⚙️ CONFIGURATION: Set your Client ID & Central URL
# ⚙️ CONFIGURATION: Client ID, Central URL, Agent Token
# ====================================================
CLIENT_ID=""
# Resolution priority for each value: explicit CLI arg > existing install > default.
# Reading from the existing install makes re-running with no args a safe in-place
# upgrade — the node keeps its identity. This is what fleet self-update relies on.
# Usage: deploy_agent.sh [CLIENT_ID] [CENTRAL_URL] [AGENT_TOKEN]
EXISTING_PY="/opt/seekright-agent/client_agent_prototype.py"
# For Production deployment, use: "http://rmm-backend.seekright.com"
# For Local WSL testing on your laptop, use: "http://172.18.16.1:8000"
# Check if Client ID is set; if not, check command line arguments, otherwise default to hostname
if [ -z "$CLIENT_ID" ]; then
CLIENT_ID="${1:-$(hostname)}"
CLIENT_ID="${1:-}"
CENTRAL_URL="${2:-}"
ARG_TOKEN="${3:-}"
if [ -f "$EXISTING_PY" ]; then
if [ -z "$CLIENT_ID" ]; then
CLIENT_ID="$(sed -n 's/^CLIENT_ID = "\(.*\)"/\1/p' "$EXISTING_PY" | head -1)"
fi
if [ -z "$CENTRAL_URL" ]; then
CENTRAL_URL="$(sed -n 's/^CENTRAL_BASE = "\(.*\)"/\1/p' "$EXISTING_PY" | head -1)"
fi
fi
CENTRAL_URL="${2:-http://rmm-backend.seekright.com}"
# Fallbacks when nothing was provided and there is no prior install
if [ -z "$CLIENT_ID" ]; then CLIENT_ID="$(hostname)"; fi
if [ -z "$CENTRAL_URL" ]; then CENTRAL_URL="http://rmm-backend.seekright.com"; fi
echo "🚀 Preparing SeekRight RMM Agent installation..."
# Agent token. The server injects the live token into the line below at download
# time (replacing __AGENT_TOKEN__). Priority: CLI arg > server-injected > existing install.
AGENT_TOKEN="__AGENT_TOKEN__"
if [ -n "$ARG_TOKEN" ]; then
AGENT_TOKEN="$ARG_TOKEN"
elif [ "$AGENT_TOKEN" = "__AGENT_TOKEN__" ]; then
if [ -f "$EXISTING_PY" ]; then
AGENT_TOKEN="$(sed -n 's/^AGENT_TOKEN = "\(.*\)"/\1/p' "$EXISTING_PY" | head -1)"
else
AGENT_TOKEN=""
fi
fi
echo "🚀 Preparing SeekRight RMM Agent installation (agent v3.2-auth)..."
echo "📍 Target Client ID: $CLIENT_ID"
echo "🌐 Central Server URL: $CENTRAL_URL"
# 1. Install System Dependencies
echo "🔄 Updating system package indexes and installing python dependencies..."
apt-get update -y
# Fix potential MySQL GPG key expiration issues before updating (common issue on Ubuntu 22.04)
apt-key del B7B3B788A8D3785C || true
wget -q -O - https://repo.mysql.com/RPM-GPG-KEY-mysql-2025 | apt-key add - || true
wget -q -O - https://repo.mysql.com/RPM-GPG-KEY-mysql-2025 | gpg --dearmor --yes -o /usr/share/keyrings/mysql-apt-config.gpg || true
apt-get update -y || true
apt-get install -y python3 python3-pip python3-psutil
# 2. Create Destination Directories
@@ -41,6 +72,7 @@ chmod 755 /opt/seekright-agent
echo "📝 Generating client agent file..."
cat << 'EOF' > /opt/seekright-agent/client_agent_prototype.py
import urllib.request
import urllib.parse
import json
import time
import subprocess
@@ -50,17 +82,47 @@ import sys
import string
import threading
def get_cpu_temp():
if not hasattr(psutil, "sensors_temperatures"):
return None
try:
temps = psutil.sensors_temperatures()
if not temps:
return None
for name in ['coretemp', 'k10temp', 'acpitz', 'cpu_thermal']:
if name in temps and temps[name]:
return temps[name][0].current
for name, entries in temps.items():
if entries:
return entries[0].current
except Exception:
pass
return None
AGENT_VERSION = "3.2-auth"
CLIENT_ID = "TEMPLATE_CLIENT_ID"
CENTRAL_BASE = "TEMPLATE_CENTRAL_URL"
AGENT_TOKEN = "TEMPLATE_AGENT_TOKEN"
# Attach the shared headers (agent token + tunnel bypass) to every outbound request
# by installing a default opener, so we don't have to touch each call site.
_opener = urllib.request.build_opener()
_opener.addheaders = [('ngrok-skip-browser-warning', 'true'), ('X-Agent-Token', AGENT_TOKEN)]
urllib.request.install_opener(_opener)
CENTRAL_API_URL = f"{CENTRAL_BASE}/api/get-command?client_id={CLIENT_ID}"
CENTRAL_TELEMETRY_URL = f"{CENTRAL_BASE}/api/telemetry?client_id={CLIENT_ID}"
CENTRAL_HEARTBEAT_URL = f"{CENTRAL_BASE}/api/heartbeat?client_id={CLIENT_ID}"
platform_name = "nt" if os.name == "nt" else "posix"
CENTRAL_COMMANDS_URL = f"{CENTRAL_BASE}/api/get-commands?platform={platform_name}"
CENTRAL_FILE_REQUEST_URL = f"{CENTRAL_BASE}/api/get-file-request?client_id={CLIENT_ID}"
CENTRAL_FILE_STATUS_URL = f"{CENTRAL_BASE}/api/file-transfer-status?client_id={CLIENT_ID}"
CACHED_COMMANDS = {}
command_in_progress = False
file_transfer_in_progress = False
def run_command_async(command_key, safe_cmd_list, is_shutdown_or_restart):
global command_in_progress
@@ -87,12 +149,43 @@ def run_command_async(command_key, safe_cmd_list, is_shutdown_or_restart):
finally:
command_in_progress = False
def execute_command_async(command_key):
# Resolve a command key received via heartbeat against the whitelist and run it.
# (The heartbeat loop sets command_in_progress=True before dispatching here, so
# every exit path must clear it unless run_command_async takes over that duty.)
global command_in_progress
try:
allowed_commands = load_allowed_commands()
if command_key not in allowed_commands:
warning_msg = f"Server requested unknown/unwhitelisted command: '{command_key}'. Ignored."
print(f" -> [!] SECURITY WARNING: {warning_msg}")
send_rocket_chat_notification("⚠️ SECURITY WARNING", warning_msg, "#FFA500")
command_in_progress = False
return
print(f" -> Executing approved command: '{command_key}'")
safe_cmd_list = allowed_commands[command_key]
is_shutdown_or_restart = any(x in command_key.lower() for x in ["reboot", "restart", "kill_python"])
if is_shutdown_or_restart:
print(" -> Shutdown/restart command detected. Sending pre-execution notification...")
send_rocket_chat_notification(
f"🔄 Executing: {command_key}",
f"System/agent is performing an action: {' '.join(safe_cmd_list)}\nConnection may drop temporarily.",
"#FFA500"
)
time.sleep(3)
# run_command_async clears command_in_progress in its finally block
run_command_async(command_key, safe_cmd_list, is_shutdown_or_restart)
except Exception as e:
print(f" -> [!] Command dispatch error: {e}")
command_in_progress = False
def load_allowed_commands():
global CACHED_COMMANDS
try:
req = urllib.request.Request(CENTRAL_COMMANDS_URL)
req.add_header('ngrok-skip-browser-warning', 'true')
with urllib.request.urlopen(req, timeout=3) as response:
with urllib.request.urlopen(req, timeout=15) as response:
commands = json.loads(response.read().decode())
if commands:
CACHED_COMMANDS = commands
@@ -121,7 +214,7 @@ def send_rocket_chat_notification(title, message, color="#764FA5"):
}
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(ROCKET_CHAT_WEBHOOK, data=data, headers={'Content-Type': 'application/json'}, method='POST')
urllib.request.urlopen(req, timeout=3)
urllib.request.urlopen(req, timeout=15)
except Exception as e:
print(f" -> [!] Failed to send webhook: {e}")
@@ -156,7 +249,7 @@ def get_windows_label(drive):
pass
return ""
def collect_and_send_telemetry():
def send_heartbeat():
try:
gpus = []
try:
@@ -240,20 +333,44 @@ def collect_and_send_telemetry():
payload = {
"cpu_percent": psutil.cpu_percent(interval=0.5),
"cpu_temp": get_cpu_temp(),
"memory_percent": psutil.virtual_memory().percent,
"memory_total_gb": round(psutil.virtual_memory().total / (1024**3), 2),
"memory_free_gb": round(psutil.virtual_memory().available / (1024**3), 2),
"disks": disks,
"gpus": gpus
"gpus": gpus,
"agent_version": AGENT_VERSION
}
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(CENTRAL_TELEMETRY_URL, data=data, headers={'Content-Type': 'application/json'}, method='POST')
req = urllib.request.Request(CENTRAL_HEARTBEAT_URL, data=data, headers={'Content-Type': 'application/json'}, method='POST')
req.add_header('ngrok-skip-browser-warning', 'true')
urllib.request.urlopen(req, timeout=3)
print("[*] Telemetry pushed successfully.")
with urllib.request.urlopen(req, timeout=30) as response:
res = json.loads(response.read().decode())
# Handle commands
cmd = res.get("command", "none")
if cmd != "none":
global command_in_progress
if not command_in_progress:
command_in_progress = True
t = threading.Thread(target=execute_command_async, args=(cmd,))
t.daemon = True
t.start()
# Handle file requests
fname = res.get("filename", "none")
if fname != "none":
global file_transfer_in_progress
if not file_transfer_in_progress:
file_transfer_in_progress = True
t = threading.Thread(target=handle_file_request_async, args=(fname,))
t.daemon = True
t.start()
print("[*] Heartbeat successful.")
except Exception as e:
print(f"[!] Failed to push telemetry: {e}")
print(f"[!] Failed heartbeat: {e}")
def send_command_result_to_server(command: str, returncode: int, stdout: str, stderr: str):
try:
@@ -273,7 +390,7 @@ def send_command_result_to_server(command: str, returncode: int, stdout: str, st
method='POST'
)
req.add_header('ngrok-skip-browser-warning', 'true')
with urllib.request.urlopen(req, timeout=3) as response:
with urllib.request.urlopen(req, timeout=15) as response:
pass
print(" -> Sent command execution output to Central Server.")
except Exception as e:
@@ -287,7 +404,7 @@ def poll_server():
print(f"[*] Checking for commands...")
req = urllib.request.Request(CENTRAL_API_URL)
req.add_header('ngrok-skip-browser-warning', 'true')
with urllib.request.urlopen(req, timeout=3) as response:
with urllib.request.urlopen(req, timeout=15) as response:
data = json.loads(response.read().decode())
command_key = data.get("command", "none")
@@ -568,6 +685,213 @@ def list_takeleap_subfolder_files(subfolder_name):
def list_takeleap_subfolder(subfolder_name):
list_takeleap_subfolder_files(subfolder_name)
# ============================================================
# Remote File Transfer: dashboard requests a file by name,
# agent finds it under any TAKELEAP folder and uploads it.
# ============================================================
def send_file_transfer_status(filename, status, message="", progress_percent=None):
try:
payload = {"filename": filename, "status": status, "message": message}
if progress_percent is not None:
payload["progress_percent"] = progress_percent
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
CENTRAL_FILE_STATUS_URL,
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
req.add_header('ngrok-skip-browser-warning', 'true')
urllib.request.urlopen(req, timeout=30)
except Exception as e:
print(f" -> [!] Failed to send file transfer status: {e}")
def find_file_in_takeleap(filename):
target = filename.lower()
# Fast-path optimization: Extract YYYYMMDD from filename
# e.g., 20260612112725_000000.MP4 -> SHIFT/2026/June/12
fast_path_rel = None
try:
if len(filename) >= 8 and filename[:8].isdigit():
import calendar
y = filename[0:4]
m = int(filename[4:6])
d = filename[6:8]
if 1 <= m <= 12:
month_name = calendar.month_name[m]
fast_path_rel = os.path.join("SHIFT", y, month_name, d)
except Exception:
pass
if os.name == "nt":
drives = [f"{letter}:\\" for letter in string.ascii_uppercase if os.path.exists(f"{letter}:\\")]
else:
drives = ["/mnt", "/media", "/home"]
if os.path.exists("/TAKELEAP"):
drives.append("/TAKELEAP")
for drive in drives:
for root, dirs, files in os.walk(drive, topdown=True):
if os.name == "nt":
dirs[:] = [d for d in dirs if d.lower() not in {"windows", "program files", "program files (x86)", "appdata", "programdata", "$recycle.bin", "system volume information", "node_modules", ".git"}]
else:
if root == "/":
dirs[:] = [d for d in dirs if d.lower() not in {"proc", "sys", "dev", "var", "lib", "run", "boot", "snap", "node_modules", ".git"}]
elif root == "/mnt":
dirs[:] = [d for d in dirs if not (len(d) == 1 and d.isalpha()) and d.lower() not in {"wslg", "wsl"}]
if os.path.basename(root).upper() == "TAKELEAP":
# FAST PATH: Check the specific date folder first
if fast_path_rel:
specific_dir = os.path.join(root, fast_path_rel)
if os.path.exists(specific_dir):
for froot, fdirs, ffiles in os.walk(specific_dir):
for f in ffiles:
if f.lower() == target:
return os.path.join(froot, f)
# Exhaustively search this TAKELEAP subtree for the requested file
for froot, fdirs, ffiles in os.walk(root):
for f in ffiles:
if f.lower() == target:
return os.path.join(froot, f)
dirs[:] = [] # subtree fully searched, don't descend again
return None
def upload_file_to_server(filename, filepath):
import time
size = os.path.getsize(filepath)
quoted = urllib.parse.quote(filename)
CHUNK_SIZE = 512 * 1024 # 512 KB: small enough to finish well under the tunnel gateway timeout
if size == 0:
total_chunks = 1
else:
total_chunks = (size + CHUNK_SIZE - 1) // CHUNK_SIZE
def fetch_received():
# Returns the set of chunk indexes the server already holds, or None if unreachable
url = f"{CENTRAL_BASE}/api/received-chunks?client_id={CLIENT_ID}&filename={quoted}&chunk_size={CHUNK_SIZE}&file_size={size}"
try:
req = urllib.request.Request(url)
req.add_header('ngrok-skip-browser-warning', 'true')
with urllib.request.urlopen(req, timeout=30) as r:
return set(json.loads(r.read().decode()).get("received", []))
except Exception as e:
print(f" -> [!] Could not fetch resume state ({e}).")
return None
def upload_one(f, chunk_index):
# Returns True on success, False if the server reports the transfer cancelled
f.seek(chunk_index * CHUNK_SIZE)
chunk_data = f.read(CHUNK_SIZE)
chunk_url = f"{CENTRAL_BASE}/api/upload-chunk?client_id={CLIENT_ID}&filename={quoted}&chunk_index={chunk_index}"
while True:
try:
req = urllib.request.Request(chunk_url, data=chunk_data, method='POST')
req.add_header('Content-Type', 'application/octet-stream')
req.add_header('ngrok-skip-browser-warning', 'true')
with urllib.request.urlopen(req, timeout=120) as response:
res = json.loads(response.read().decode())
return res.get("status") != "cancelled"
except Exception as e:
print(f" -> [!] Chunk {chunk_index} failed ({e}). Retrying in 5s...")
time.sleep(5)
received = fetch_received() or set()
if received:
print(f" -> Resuming: server already has {len(received)}/{total_chunks} chunks.")
uploaded = 0
with open(filepath, 'rb') as f:
for chunk_index in range(total_chunks):
if chunk_index in received:
continue
if not upload_one(f, chunk_index):
print(f" -> [!] Upload cancelled by server.")
send_file_transfer_status(filename, "error", "Upload cancelled.")
return
uploaded += 1
# Brief pause between chunks so heartbeat traffic can slip through
time.sleep(0.1)
if uploaded % 8 == 0 or chunk_index == total_chunks - 1:
progress = round(((len(received) + uploaded) / total_chunks) * 100, 1)
send_file_transfer_status(filename, "uploading", f"Uploading... ({progress}%)", progress_percent=progress)
# Finalize with self-healing: verify the server holds every chunk (re-uploading
# any lost to a mid-transfer cancel or cleanup), then ask it to stitch the file.
while True:
on_server = fetch_received()
if on_server is None:
time.sleep(5)
continue
missing = sorted(set(range(total_chunks)) - on_server)
if missing:
print(f" -> [!] Server is missing {len(missing)} chunk(s). Re-uploading...")
for chunk_index in missing:
if not upload_one(f, chunk_index):
print(f" -> [!] Upload cancelled by server.")
send_file_transfer_status(filename, "error", "Upload cancelled.")
return
time.sleep(0.1)
continue
complete_url = f"{CENTRAL_BASE}/api/upload-complete?client_id={CLIENT_ID}&filename={quoted}&total_chunks={total_chunks}"
try:
req = urllib.request.Request(complete_url, method='POST', data=b'')
req.add_header('ngrok-skip-browser-warning', 'true')
with urllib.request.urlopen(req, timeout=180) as response:
return json.loads(response.read().decode())
except Exception as e:
print(f" -> [!] Finalize failed ({e}). Re-verifying chunks in 5s...")
time.sleep(5)
def handle_file_request_async(filename):
global file_transfer_in_progress
try:
print(f" -> File request received: '{filename}'. Searching TAKELEAP folders...")
send_file_transfer_status(filename, "searching", "Searching TAKELEAP folders on client...")
filepath = find_file_in_takeleap(filename)
if not filepath:
print(f" -> [!] File '{filename}' not found in any TAKELEAP folder.")
send_file_transfer_status(filename, "not_found", "File was not found in any TAKELEAP folder on this client.")
send_rocket_chat_notification(f"❌ File Not Found: {filename}", f"Requested file was not found on client {CLIENT_ID}.", "#FF0000")
return
size_mb = round(os.path.getsize(filepath) / (1024 * 1024), 2)
print(f" -> Found at {filepath} ({size_mb} MB). Uploading...")
send_file_transfer_status(filename, "uploading", f"Found at {filepath} ({size_mb} MB). Uploading to server...")
upload_file_to_server(filename, filepath)
# Server marks the transfer 'ready' once the upload completes
print(f" -> Upload complete: {filename}")
send_rocket_chat_notification(f"✅ File Uploaded: {filename}", f"Uploaded {size_mb} MB from {filepath} to the central server.", "#00FF00")
except Exception as ex:
print(f" -> [!] File transfer error: {ex}")
send_file_transfer_status(filename, "error", f"Transfer failed on client: {ex}")
send_rocket_chat_notification(f"❌ File Transfer Failed: {filename}", str(ex), "#FF0000")
finally:
file_transfer_in_progress = False
def poll_file_request():
global file_transfer_in_progress
if file_transfer_in_progress:
return
try:
req = urllib.request.Request(CENTRAL_FILE_REQUEST_URL)
req.add_header('ngrok-skip-browser-warning', 'true')
with urllib.request.urlopen(req, timeout=15) as response:
data = json.loads(response.read().decode())
filename = data.get("filename", "none")
if filename and filename != "none":
file_transfer_in_progress = True
t = threading.Thread(target=handle_file_request_async, args=(filename,))
t.daemon = True
t.start()
except Exception as e:
print(f" -> [!] Failed to poll file requests: {e}")
if __name__ == "__main__":
if len(sys.argv) > 1:
if sys.argv[1] == "--check-takeleap":
@@ -592,20 +916,21 @@ if __name__ == "__main__":
print(f"Unknown argument or missing parameters: {sys.argv[1]}")
sys.exit(1)
else:
print(f"Starting Agent for {CLIENT_ID}...")
print(f"Starting Agent v{AGENT_VERSION} for {CLIENT_ID}...")
try:
while True:
collect_and_send_telemetry()
poll_server()
send_heartbeat()
time.sleep(10)
except KeyboardInterrupt:
print("\n[*] Agent stopped.")
EOF
# Inject the Client ID and Central URL dynamically
# Inject the Client ID, Central URL, and Agent Token dynamically
sed -i "s/TEMPLATE_CLIENT_ID/$CLIENT_ID/g" /opt/seekright-agent/client_agent_prototype.py
sed -i "s|TEMPLATE_CENTRAL_URL|$CENTRAL_URL|g" /opt/seekright-agent/client_agent_prototype.py
chmod 644 /opt/seekright-agent/client_agent_prototype.py
sed -i "s|TEMPLATE_AGENT_TOKEN|$AGENT_TOKEN|g" /opt/seekright-agent/client_agent_prototype.py
# Agent file holds the shared secret; restrict it to root
chmod 600 /opt/seekright-agent/client_agent_prototype.py
# 4. Create Background systemd Service
echo "⚙️ Configuring background systemd daemon service..."