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

@@ -24,6 +24,8 @@ log = logging.getLogger("relay")
DATABASE_URL = os.environ["DATABASE_URL"]
# Comma-separated list of allowed origins for CORS, e.g. "https://app.example.com"
ALLOWED_ORIGINS = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "").split(",") if o.strip()]
# Set RECORDING_DIR to enable session recording (JPEG frame archive)
RECORDING_DIR = os.environ.get("RECORDING_DIR", "") # e.g. "/recordings"
# ── In-memory connection registry ────────────────────────────────────────────
# machine_id (str) → WebSocket
@@ -34,6 +36,12 @@ viewers: dict[str, WebSocket] = {}
session_to_machine: dict[str, str] = {}
# session_id → viewer's user role ("admin" | "user")
viewer_roles: dict[str, str] = {}
# session_id → open file handle for recording
recordings: dict[str, "io.BufferedWriter"] = {}
import io
import struct
import time as _time
db_pool: Optional[asyncpg.Pool] = None
@@ -96,6 +104,48 @@ async def send_json(ws: WebSocket, data: dict):
pass
# ── Recording helpers ─────────────────────────────────────────────────────────
def _start_recording(session_id: str):
"""Open a .remrec file for this session (simple frame archive format)."""
try:
import pathlib
rec_dir = pathlib.Path(RECORDING_DIR)
rec_dir.mkdir(parents=True, exist_ok=True)
ts = _time.strftime("%Y%m%d_%H%M%S")
path = rec_dir / f"{ts}_{session_id[:8]}.remrec"
recordings[session_id] = open(path, "wb")
# File header: magic "RLREC" + version 1
recordings[session_id].write(b"RLREC\x01")
log.info(f"Recording started: {path}")
except Exception as e:
log.warning(f"Failed to start recording for {session_id}: {e}")
def _write_frame(session_id: str, frame_data: bytes):
"""Append a JPEG frame with timestamp to the recording file."""
f = recordings.get(session_id)
if not f:
return
try:
ts = int(_time.time() * 1000) # milliseconds
# Frame record: 8-byte timestamp + 4-byte length + JPEG bytes
f.write(struct.pack(">QI", ts, len(frame_data)))
f.write(frame_data)
except Exception as e:
log.warning(f"Recording write error: {e}")
def _stop_recording(session_id: str):
f = recordings.pop(session_id, None)
if f:
try:
f.close()
log.info(f"Recording stopped: {session_id}")
except Exception:
pass
# ── Agent WebSocket endpoint ──────────────────────────────────────────────────
@app.websocket("/ws/agent")
@@ -129,21 +179,32 @@ async def agent_endpoint(
if mid == machine_id and sid in viewers:
try:
await viewers[sid].send_bytes(frame_data)
if RECORDING_DIR:
_write_frame(sid, frame_data)
except Exception:
viewers.pop(sid, None)
session_to_machine.pop(sid, None)
elif "text" in msg and msg["text"]:
# JSON message from agent (script output, status, etc.)
# JSON message from agent
try:
data = json.loads(msg["text"])
# Forward script output to the relevant viewer
if data.get("type") == "script_output":
msg_type = data.get("type")
if msg_type == "ping":
await set_machine_online(machine_id, True)
elif msg_type in {
"script_output", "monitor_list", "clipboard_content",
"file_chunk", "file_list", "chat_message",
}:
# Forward to the relevant viewer(s)
session_id = data.get("session_id")
if session_id and session_id in viewers:
await send_json(viewers[session_id], data)
elif data.get("type") == "ping":
await set_machine_online(machine_id, True)
else:
# Broadcast to all viewers watching this machine
for sid, mid in list(session_to_machine.items()):
if mid == machine_id and sid in viewers:
await send_json(viewers[sid], data)
except json.JSONDecodeError:
pass
@@ -194,6 +255,9 @@ async def viewer_endpoint(
"session_id": session_id,
})
await send_json(websocket, {"type": "agent_connected", "machine_name": session["machine_name"]})
# Start recording if enabled
if RECORDING_DIR:
_start_recording(session_id)
else:
await send_json(websocket, {"type": "agent_offline"})
@@ -224,6 +288,8 @@ async def viewer_endpoint(
viewers.pop(session_id, None)
session_to_machine.pop(session_id, None)
viewer_roles.pop(session_id, None)
if RECORDING_DIR:
_stop_recording(session_id)
if machine_id in agents:
await send_json(agents[machine_id], {"type": "stop_stream", "session_id": session_id})
log.info(f"Viewer disconnected from session {session_id}")