Add ScreenConnect-parity features (high + medium)

Viewer:
- Toolbar: Ctrl+Alt+Del, clipboard paste, monitor picker, file transfer, chat, WoL buttons
- Multi-monitor: agent sends monitor_list on connect, viewer can switch via dropdown
- Clipboard sync: agent polls local clipboard → sends to viewer; viewer paste → agent sets remote clipboard
- File transfer panel: drag-drop upload to agent, directory browser, download files from remote
- Chat panel: bidirectional text chat forwarded through relay

Agent:
- Multi-monitor capture with set_monitor/set_quality message handlers
- exec_key_combo for Ctrl+Alt+Del and arbitrary combos
- Clipboard polling via pyperclip (both directions)
- File upload/download/list_files with base64 chunked protocol
- Attended mode (--attended): zenity/kdialog/PowerShell consent dialog before accepting stream
- Auto-update: heartbeat checks version, downloads new binary and exec-replaces self (Linux)
- Reports MAC address on registration (for WoL)

Relay:
- Forwards monitor_list, clipboard_content, file_chunk, file_list, chat_message agent→viewer
- Session recording: when RECORDING_DIR env set, saves JPEG frames as .remrec files
- ALLOWED_ORIGINS CORS now set from NEXT_PUBLIC_APP_URL in docker-compose

Database:
- groups table (id, name, description, created_by)
- machines: group_id, mac_address, notes, tags text[]
- Migration 0003 applied

Dashboard:
- Machines page: search, tag filter, group filter, inline notes/tags/rename editing
- MachineCard: inline tag management, group picker, notes textarea
- Admin page: new Groups tab (create/list/delete groups)
- API: PATCH /api/machines/[id] (name, notes, tags, groupId)
- API: GET/POST/DELETE /api/groups
- API: POST /api/machines/wol (broadcast magic packet)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
monoadmin
2026-04-10 23:57:47 -07:00
parent 27673daa63
commit 61edbf59bf
20 changed files with 1881 additions and 241 deletions

View File

@@ -14,6 +14,7 @@ Usage:
"""
import asyncio
import base64
import json
import logging
import os
@@ -21,6 +22,7 @@ import platform
import subprocess
import sys
import time
import uuid as uuid_lib
from io import BytesIO
from pathlib import Path
from typing import Optional
@@ -126,6 +128,7 @@ async def register(server_url: str, enrollment_token: str) -> dict:
"osVersion": os_version,
"agentVersion": AGENT_VERSION,
"ipAddress": None,
"macAddress": get_mac_address(),
})
if resp.status_code != 200:
raise RuntimeError(f"Registration failed: {resp.status_code} {resp.text}")
@@ -135,18 +138,49 @@ async def register(server_url: str, enrollment_token: str) -> dict:
async def heartbeat(server_url: str, access_key: str) -> Optional[dict]:
"""Send heartbeat, returns pending connection info if any."""
"""Send heartbeat, returns server response including update info."""
url = f"{server_url.rstrip('/')}/api/agent/heartbeat"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json={"accessKey": access_key})
resp = await client.post(url, json={"accessKey": access_key, "agentVersion": AGENT_VERSION})
if resp.status_code == 200:
return resp.json()
data = resp.json()
if data.get("updateAvailable"):
asyncio.create_task(_auto_update(data["updateAvailable"]))
return data
except Exception as e:
log.warning(f"Heartbeat failed: {e}")
return None
async def _auto_update(update_info: dict) -> None:
"""Download new agent binary and replace self, then restart."""
download_url = update_info.get("downloadUrl")
if not download_url:
return
log.info(f"Update available: {update_info.get('version')}. Downloading from {download_url}")
try:
async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client:
resp = await client.get(download_url)
if resp.status_code != 200:
log.warning(f"Update download failed: {resp.status_code}")
return
# Write to a temp file alongside the current binary
current = Path(sys.executable)
tmp = current.with_suffix(".new")
tmp.write_bytes(resp.content)
tmp.chmod(0o755)
# Atomically replace (Unix only — Windows needs a separate updater process)
if platform.system() != "Windows":
tmp.replace(current)
log.info(f"Agent updated to {update_info.get('version')}. Restarting…")
os.execv(str(current), sys.argv)
else:
log.info("Update downloaded. Restart the service to apply on Windows.")
except Exception as e:
log.warning(f"Auto-update failed: {e}")
async def get_session_code(server_url: str, access_key: str) -> Optional[str]:
"""Request a new session code from the server."""
url = f"{server_url.rstrip('/')}/api/agent/session-code"
@@ -166,13 +200,17 @@ class ScreenCapture:
self.quality = quality
self._frame_delay = 1.0 / fps
self._sct = None
self._monitors: list = [] # mss monitors (excluding index 0 = all)
self._monitor_idx: int = 0 # 0-based into _monitors
def __enter__(self):
try:
self._sct = mss()
self._monitors = self._sct.monitors[1:] # skip [0] = virtual all-screens
except Exception as e:
log.warning(f"Screen capture unavailable: {e}")
self._sct = None
self._monitors = []
return self
def __exit__(self, *args):
@@ -183,11 +221,39 @@ class ScreenCapture:
def available(self) -> bool:
return self._sct is not None
def set_monitor(self, idx: int):
if 0 <= idx < len(self._monitors):
self._monitor_idx = idx
def set_quality(self, quality: int):
self.quality = max(10, min(95, quality))
fps_map = {"high": 15, "medium": 10, "low": 5}
# quality levels sent from viewer as strings
if isinstance(quality, str):
self.fps = fps_map.get(quality, 15)
self.quality = {"high": 70, "medium": 55, "low": 35}.get(quality, 70)
else:
self.quality = max(10, min(95, quality))
self._frame_delay = 1.0 / self.fps
def get_monitor_list(self) -> list:
return [
{
"index": i,
"width": m["width"],
"height": m["height"],
"left": m["left"],
"top": m["top"],
"primary": i == 0,
}
for i, m in enumerate(self._monitors)
]
def capture(self) -> Optional[bytes]:
if not self._sct:
if not self._sct or not self._monitors:
return None
try:
monitor = self._sct.monitors[1] # Primary monitor
monitor = self._monitors[self._monitor_idx]
img = self._sct.grab(monitor)
pil = Image.frombytes("RGB", img.size, img.bgra, "raw", "BGRX")
buf = BytesIO()
@@ -239,10 +305,8 @@ class InputController:
elif t == "mouse_scroll":
self._mouse.scroll(event.get("dx", 0), event.get("dy", 0))
elif t == "key_press":
key_str = event.get("key", "")
self._keyboard.type(key_str)
self._keyboard.type(event.get("key", ""))
elif t == "key_special":
# Special keys like Enter, Tab, Escape, etc.
key_name = event.get("key", "")
try:
key = getattr(self._Key, key_name)
@@ -250,6 +314,19 @@ class InputController:
self._keyboard.release(key)
except AttributeError:
pass
elif t == "exec_key_combo":
# e.g. {"type":"exec_key_combo","keys":["ctrl_l","alt_l","delete"]}
keys = event.get("keys", [])
pressed = []
for k in keys:
try:
key_obj = getattr(self._Key, k)
self._keyboard.press(key_obj)
pressed.append(key_obj)
except AttributeError:
pass
for key_obj in reversed(pressed):
self._keyboard.release(key_obj)
except Exception as e:
log.debug(f"Input error: {e}")
@@ -301,6 +378,157 @@ async def exec_script(script: str, shell: str, session_id: str, ws) -> None:
}))
# ── Clipboard helpers ─────────────────────────────────────────────────────────
def _get_clipboard() -> str:
try:
import pyperclip
return pyperclip.paste() or ""
except Exception:
return ""
def _set_clipboard(text: str):
try:
import pyperclip
pyperclip.copy(text)
except Exception:
pass
# ── File transfer helpers ─────────────────────────────────────────────────────
CHUNK_SIZE = 65536 # 64 KB per chunk
async def send_file(path_str: str, session_id: str, ws) -> None:
"""Read a file and send it to the viewer as base64 chunks."""
try:
path = Path(path_str).expanduser().resolve()
if not path.is_file():
await ws.send(json.dumps({
"type": "file_chunk", "session_id": session_id,
"error": f"File not found: {path_str}", "done": True,
}))
return
total = path.stat().st_size
filename = path.name
seq = 0
with open(path, "rb") as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk:
break
await ws.send(json.dumps({
"type": "file_chunk",
"session_id": session_id,
"filename": filename,
"size": total,
"seq": seq,
"chunk": base64.b64encode(chunk).decode(),
"done": False,
}))
seq += 1
await ws.send(json.dumps({
"type": "file_chunk",
"session_id": session_id,
"filename": filename,
"size": total,
"seq": seq,
"chunk": "",
"done": True,
}))
log.info(f"Sent file {filename} ({total} bytes) to viewer")
except Exception as e:
await ws.send(json.dumps({
"type": "file_chunk", "session_id": session_id,
"error": str(e), "done": True,
}))
async def list_files(path_str: str, session_id: str, ws) -> None:
"""Send a directory listing to the viewer."""
try:
path = Path(path_str or "~").expanduser().resolve()
if not path.is_dir():
path = path.parent
entries = []
for entry in sorted(path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
try:
stat = entry.stat()
entries.append({
"name": entry.name,
"is_dir": entry.is_dir(),
"size": stat.st_size if entry.is_file() else 0,
"path": str(entry),
})
except PermissionError:
pass
await ws.send(json.dumps({
"type": "file_list",
"session_id": session_id,
"path": str(path),
"parent": str(path.parent) if path.parent != path else None,
"entries": entries,
}))
except Exception as e:
await ws.send(json.dumps({
"type": "file_list", "session_id": session_id,
"path": path_str, "entries": [], "error": str(e),
}))
async def _prompt_user_consent(timeout: int = 30) -> bool:
"""Show a local dialog asking the user to accept the incoming connection.
Returns True if accepted within timeout, False otherwise."""
try:
if platform.system() == "Windows":
# Use PowerShell MessageBox
script = (
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null;"
"$r = [System.Windows.Forms.MessageBox]::Show("
"'A technician is requesting remote access to this computer. Allow?',"
"'RemoteLink — Incoming Connection',"
"[System.Windows.Forms.MessageBoxButtons]::YesNo,"
"[System.Windows.Forms.MessageBoxIcon]::Question);"
"exit ($r -eq 'Yes' ? 0 : 1)"
)
proc = await asyncio.create_subprocess_exec(
"powershell", "-NonInteractive", "-Command", script,
)
try:
await asyncio.wait_for(proc.wait(), timeout=timeout)
return proc.returncode == 0
except asyncio.TimeoutError:
proc.kill()
return False
elif platform.system() == "Linux":
# Try zenity (GNOME), then kdialog (KDE), then xterm fallback
for cmd in [
["zenity", "--question", "--title=RemoteLink", f"--text=A technician is requesting remote access. Allow?", f"--timeout={timeout}"],
["kdialog", "--yesno", "A technician is requesting remote access. Allow?", "--title", "RemoteLink"],
]:
try:
proc = await asyncio.create_subprocess_exec(*cmd)
await asyncio.wait_for(proc.wait(), timeout=timeout + 5)
return proc.returncode == 0
except (FileNotFoundError, asyncio.TimeoutError):
continue
# No GUI available — log and deny
log.warning("Attended mode: no GUI dialog available, denying connection")
return False
else:
return True # macOS: always allow for now
except Exception as e:
log.warning(f"Consent dialog error: {e}")
return False
def get_mac_address() -> str:
mac = uuid_lib.getnode()
return ":".join(f"{(mac >> (8 * i)) & 0xff:02X}" for i in reversed(range(6)))
# ── Main agent loop ───────────────────────────────────────────────────────────
class Agent:
@@ -314,6 +542,10 @@ class Agent:
self._active_session: Optional[str] = None
self._input = InputController()
self._stop_event = asyncio.Event()
self._screen: Optional[ScreenCapture] = None
self._attended_mode: bool = False # set via --attended flag
# file upload buffers: transfer_id → {filename, dest, chunks[]}
self._uploads: dict = {}
async def run(self):
log.info(f"Agent starting. Machine ID: {self.machine_id}")
@@ -344,8 +576,28 @@ class Agent:
log.info("Connected to relay")
await self._message_loop(ws)
async def _clipboard_poll(self, ws):
"""Poll local clipboard every 2s; send changes to active viewer."""
last = _get_clipboard()
while not self._stop_event.is_set():
await asyncio.sleep(2)
if not self._active_session:
continue
current = _get_clipboard()
if current != last and current:
last = current
try:
await ws.send(json.dumps({
"type": "clipboard_content",
"session_id": self._active_session,
"content": current,
}))
except Exception:
break
async def _message_loop(self, ws):
with ScreenCapture(fps=15, quality=60) as screen:
self._screen = screen
if not screen.available:
log.warning(
"Screen capture unavailable — agent will stay connected "
@@ -353,6 +605,7 @@ class Agent:
)
stream_task: Optional[asyncio.Task] = None
clipboard_task: Optional[asyncio.Task] = None
async def stream_frames():
while self._streaming and not self._stop_event.is_set():
@@ -382,17 +635,38 @@ class Agent:
if msg_type == "start_stream":
session_id = msg.get("session_id")
log.info(f"Viewer connected — session {session_id}")
# Attended mode: ask local user for consent
if self._attended_mode:
accepted = await _prompt_user_consent()
if not accepted:
await ws.send(json.dumps({
"type": "error",
"session_id": session_id,
"message": "Remote session denied by local user.",
}))
continue
self._streaming = True
self._active_session = session_id
if not screen.available:
# Tell viewer why they won't see frames
await ws.send(json.dumps({
"type": "error",
"message": "Screen capture unavailable on this machine (no display). Set $DISPLAY and restart the agent.",
"message": "Screen capture unavailable (no display). Set $DISPLAY and restart the agent.",
}))
# Send monitor list to viewer
await ws.send(json.dumps({
"type": "monitor_list",
"session_id": session_id,
"monitors": screen.get_monitor_list(),
}))
if stream_task and not stream_task.done():
stream_task.cancel()
stream_task = asyncio.create_task(stream_frames())
# Start clipboard polling
if clipboard_task and not clipboard_task.done():
clipboard_task.cancel()
clipboard_task = asyncio.create_task(self._clipboard_poll(ws))
elif msg_type == "stop_stream":
log.info("Viewer disconnected — stopping stream")
@@ -400,11 +674,28 @@ class Agent:
self._active_session = None
if stream_task and not stream_task.done():
stream_task.cancel()
if clipboard_task and not clipboard_task.done():
clipboard_task.cancel()
elif msg_type == "set_monitor":
screen.set_monitor(msg.get("index", 0))
log.info(f"Switched to monitor {msg.get('index', 0)}")
elif msg_type == "set_quality":
q = msg.get("quality", "high")
quality_map = {"high": 70, "medium": 55, "low": 35}
fps_map = {"high": 15, "medium": 10, "low": 5}
screen.quality = quality_map.get(q, 70)
screen.fps = fps_map.get(q, 15)
screen._frame_delay = 1.0 / screen.fps
elif msg_type in ("mouse_move", "mouse_click", "mouse_scroll",
"key_press", "key_special"):
"key_press", "key_special", "exec_key_combo"):
self._input.handle(msg)
elif msg_type == "clipboard_paste":
_set_clipboard(msg.get("content", ""))
elif msg_type == "exec_script":
asyncio.create_task(exec_script(
msg.get("script", ""),
@@ -413,13 +704,62 @@ class Agent:
ws,
))
elif msg_type == "file_download":
asyncio.create_task(send_file(
msg.get("path", ""),
msg.get("session_id", self._active_session or ""),
ws,
))
elif msg_type == "list_files":
asyncio.create_task(list_files(
msg.get("path", "~"),
msg.get("session_id", self._active_session or ""),
ws,
))
elif msg_type == "file_upload_start":
tid = msg.get("transfer_id", "")
filename = Path(msg.get("filename", "upload")).name
dest = Path(msg.get("dest_path", "~")).expanduser() / filename
self._uploads[tid] = {"dest": dest, "chunks": []}
log.info(f"File upload starting: {filename}{dest}")
elif msg_type == "file_upload_chunk":
tid = msg.get("transfer_id", "")
if tid in self._uploads:
chunk = base64.b64decode(msg.get("chunk", ""))
self._uploads[tid]["chunks"].append(chunk)
elif msg_type == "file_upload_end":
tid = msg.get("transfer_id", "")
if tid in self._uploads:
info = self._uploads.pop(tid)
dest: Path = info["dest"]
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as f:
for chunk in info["chunks"]:
f.write(chunk)
log.info(f"File upload complete: {dest}")
await ws.send(json.dumps({
"type": "file_chunk",
"session_id": self._active_session or "",
"transfer_id": tid,
"done": True,
"upload_complete": True,
"path": str(dest),
}))
elif msg_type == "ping":
await ws.send(json.dumps({"type": "pong"}))
finally:
self._streaming = False
self._screen = None
if stream_task and not stream_task.done():
stream_task.cancel()
if clipboard_task and not clipboard_task.done():
clipboard_task.cancel()
def stop(self):
self._stop_event.set()
@@ -466,6 +806,7 @@ async def main():
parser.add_argument("--relay", help="Relay WebSocket URL (e.g. ws://remotelink.example.com:8765)")
parser.add_argument("--enroll", metavar="TOKEN", help="Enrollment token for first-time registration")
parser.add_argument("--run-once", action="store_true", help="Exit after first session ends")
parser.add_argument("--attended", action="store_true", help="Prompt local user to accept each incoming connection")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
@@ -503,6 +844,7 @@ async def main():
access_key = config["access_key"]
agent = Agent(server_url, machine_id, access_key, relay_url)
agent._attended_mode = args.attended
# Handle Ctrl+C / SIGTERM gracefully
loop = asyncio.get_event_loop()

View File

@@ -4,6 +4,7 @@ websockets==13.1
mss==9.0.2
Pillow==11.0.0
pynput==1.7.7
pyperclip==1.9.0
# Windows service support (Windows only)
pywin32==308; sys_platform == "win32"