diff --git a/.env.example b/.env.example index c1e6e0e..ef0e1bf 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,7 @@ AUTH_SECRET=change_me_generate_with_openssl_rand_base64_32 # Public URL of the app (used for invite links, etc.) NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Relay WebSocket server address as seen from the browser (host:port) +# For production behind a reverse proxy, set to your domain (no port if using 443/80) +NEXT_PUBLIC_RELAY_URL=localhost:8765 diff --git a/.gitignore b/.gitignore index bdbabca..f267c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,10 @@ next.user-config.* # Common ignores node_modules/ .next/ -.DS_Store \ No newline at end of file +.DS_Store + +# PyInstaller build artifacts +agent/build/ +agent/dist/ +agent/__pycache__/ +agent/*.spec.bak \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9d2c174..e91a4ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,9 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . ARG NEXT_PUBLIC_APP_URL=http://localhost:3000 +ARG NEXT_PUBLIC_RELAY_URL=localhost:8765 ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_RELAY_URL=$NEXT_PUBLIC_RELAY_URL ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm build diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..e81e1e3 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,66 @@ +# RemoteLink Agent + +Cross-platform remote support agent. Streams the screen to the RemoteLink server and handles remote mouse/keyboard input and script execution. + +## Quick start + +### Run-once (portable, no install) + +```bash +# Linux / macOS +python agent.py --server https://remotelink.example.com --enroll YOUR_TOKEN --run-once + +# Windows — run the pre-built exe +remotelink-agent.exe --server https://remotelink.example.com --enroll YOUR_TOKEN --run-once +``` + +### Permanent install (saves config, reconnects on reboot) + +```bash +python agent.py --server https://remotelink.example.com --enroll YOUR_TOKEN +# Config saved to /etc/remotelink/agent.json (Linux) or C:\ProgramData\RemoteLink\agent.json (Windows) +# Start normally on next run: +python agent.py +``` + +### Windows service (runs on login / system start) + +```batch +remotelink-agent-service.exe install +remotelink-agent-service.exe start +``` + +### Mass deployment (NSIS installer, silent) + +```batch +RemoteLink-Setup.exe /S /SERVER=https://remotelink.example.com /ENROLL=YOUR_TOKEN +``` + +This silently installs, enrolls the machine, installs the Windows service, and starts it — no UI shown. + +## Building + +```bash +# Install deps + build portable binary (all platforms) +./build.sh + +# Output: dist/remotelink-agent (Linux/macOS) +# dist/remotelink-agent.exe + dist/remotelink-agent-service.exe (Windows) +# RemoteLink-Setup.exe (Windows + NSIS installed) +``` + +## Enrollment tokens + +Generate enrollment tokens in the RemoteLink web UI under **Admin → Enrollment Tokens**. + +Each token can optionally have: +- An expiry date +- A max-uses limit +- A label for tracking + +## Protocol + +The agent connects to the relay WebSocket server: +- `ws://:8765/ws/agent?machine_id=&access_key=` + +It streams JPEG frames as binary WebSocket messages and receives JSON control events. diff --git a/agent/agent.py b/agent/agent.py new file mode 100644 index 0000000..288e98d --- /dev/null +++ b/agent/agent.py @@ -0,0 +1,438 @@ +""" +RemoteLink Agent +Connects to the RemoteLink server, streams the screen, and handles remote input. + +Usage: + First run (self-register): + python agent.py --server https://myserver.com --enroll + + Subsequent runs (use saved config): + python agent.py + + Run-once mode (no config saved, exits when session ends): + python agent.py --server https://myserver.com --enroll --run-once +""" + +import asyncio +import json +import logging +import os +import platform +import subprocess +import sys +import time +from io import BytesIO +from pathlib import Path +from typing import Optional +import argparse +import signal + +# Third-party — installed via requirements.txt / bundled by PyInstaller +import httpx +import websockets +import websockets.exceptions +from mss import mss +from PIL import Image + +log = logging.getLogger("remotelink-agent") + +# ── Config ──────────────────────────────────────────────────────────────────── + +def config_dir() -> Path: + """Platform-appropriate config directory.""" + if platform.system() == "Windows": + base = os.environ.get("PROGRAMDATA", "C:\\ProgramData") + return Path(base) / "RemoteLink" + elif platform.system() == "Darwin": + return Path.home() / "Library" / "Application Support" / "RemoteLink" + else: + return Path("/etc/remotelink") + + +CONFIG_FILE = config_dir() / "agent.json" +AGENT_VERSION = "1.0.0" + + +def load_config() -> Optional[dict]: + if CONFIG_FILE.exists(): + try: + return json.loads(CONFIG_FILE.read_text()) + except Exception: + pass + return None + + +def save_config(data: dict): + config_dir().mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(data, indent=2)) + log.info(f"Config saved to {CONFIG_FILE}") + + +# ── Registration ────────────────────────────────────────────────────────────── + +async def register(server_url: str, enrollment_token: str) -> dict: + """Self-register with the server using an enrollment token.""" + hostname = platform.node() + os_name = platform.system() + os_version = platform.version() + + url = f"{server_url.rstrip('/')}/api/agent/register" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(url, json={ + "enrollmentToken": enrollment_token, + "name": hostname, + "hostname": hostname, + "os": os_name, + "osVersion": os_version, + "agentVersion": AGENT_VERSION, + "ipAddress": None, + }) + if resp.status_code != 200: + raise RuntimeError(f"Registration failed: {resp.status_code} {resp.text}") + data = resp.json() + log.info(f"Registered as machine {data['machineId']}") + return data + + +async def heartbeat(server_url: str, access_key: str) -> Optional[dict]: + """Send heartbeat, returns pending connection info if any.""" + 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}) + if resp.status_code == 200: + return resp.json() + except Exception as e: + log.warning(f"Heartbeat failed: {e}") + return None + + +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" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(url, json={"accessKey": access_key}) + if resp.status_code == 200: + data = resp.json() + return data.get("code") + return None + + +# ── Screen capture ──────────────────────────────────────────────────────────── + +class ScreenCapture: + def __init__(self, fps: int = 15, quality: int = 60): + self.fps = fps + self.quality = quality + self._frame_delay = 1.0 / fps + self._sct = None + + def __enter__(self): + self._sct = mss() + return self + + def __exit__(self, *args): + if self._sct: + self._sct.close() + + def capture(self) -> bytes: + monitor = self._sct.monitors[1] # Primary monitor + img = self._sct.grab(monitor) + pil = Image.frombytes("RGB", img.size, img.bgra, "raw", "BGRX") + buf = BytesIO() + pil.save(buf, format="JPEG", quality=self.quality, optimize=False) + return buf.getvalue() + + @property + def frame_delay(self) -> float: + return self._frame_delay + + +# ── Input control ───────────────────────────────────────────────────────────── + +class InputController: + """Replay mouse and keyboard events on the local machine.""" + + def __init__(self): + self._mouse = None + self._keyboard = None + self._available = False + try: + from pynput.mouse import Button, Controller as MouseController + from pynput.keyboard import Key, Controller as KeyboardController + self._mouse = MouseController() + self._keyboard = KeyboardController() + self._Button = Button + self._Key = Key + self._available = True + except Exception as e: + log.warning(f"Input control unavailable: {e}") + + def handle(self, event: dict): + if not self._available: + return + try: + t = event.get("type") + if t == "mouse_move": + self._mouse.position = (event["x"], event["y"]) + elif t == "mouse_click": + self._mouse.position = (event["x"], event["y"]) + btn = self._Button.right if event.get("button") == "right" else self._Button.left + if event.get("double"): + self._mouse.click(btn, 2) + else: + self._mouse.click(btn, 1) + 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) + elif t == "key_special": + # Special keys like Enter, Tab, Escape, etc. + key_name = event.get("key", "") + try: + key = getattr(self._Key, key_name) + self._keyboard.press(key) + self._keyboard.release(key) + except AttributeError: + pass + except Exception as e: + log.debug(f"Input error: {e}") + + +# ── Script execution ────────────────────────────────────────────────────────── + +async def exec_script(script: str, shell: str, session_id: str, ws) -> None: + """Execute a script and stream output back through the WebSocket.""" + exec_id = str(time.time()) + if platform.system() == "Windows": + if shell == "powershell": + cmd = ["powershell", "-NonInteractive", "-Command", script] + else: + cmd = ["cmd", "/c", script] + else: + cmd = ["bash", "-c", script] + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for line in proc.stdout: + await ws.send(json.dumps({ + "type": "script_output", + "session_id": session_id, + "id": exec_id, + "output": line.decode(errors="replace"), + "done": False, + })) + await proc.wait() + await ws.send(json.dumps({ + "type": "script_output", + "session_id": session_id, + "id": exec_id, + "output": "", + "done": True, + "exit_code": proc.returncode, + })) + except Exception as e: + await ws.send(json.dumps({ + "type": "script_output", + "session_id": session_id, + "id": exec_id, + "output": f"Error: {e}\n", + "done": True, + "exit_code": -1, + })) + + +# ── Main agent loop ─────────────────────────────────────────────────────────── + +class Agent: + def __init__(self, server_url: str, machine_id: str, access_key: str, relay_url: str): + self.server_url = server_url + self.machine_id = machine_id + self.access_key = access_key + self.relay_url = relay_url.rstrip("/") + + self._streaming = False + self._active_session: Optional[str] = None + self._input = InputController() + self._stop_event = asyncio.Event() + + async def run(self): + log.info(f"Agent starting. Machine ID: {self.machine_id}") + log.info(f"Connecting to relay: {self.relay_url}") + + # Heartbeat loop in background + asyncio.create_task(self._heartbeat_loop()) + + # Connect to relay (with reconnect) + while not self._stop_event.is_set(): + try: + await self._connect() + except Exception as e: + log.warning(f"Relay disconnected: {e}. Reconnecting in 5s…") + await asyncio.sleep(5) + + async def _heartbeat_loop(self): + while not self._stop_event.is_set(): + await heartbeat(self.server_url, self.access_key) + await asyncio.sleep(30) + + async def _connect(self): + ws_url = ( + f"{self.relay_url}/ws/agent" + f"?machine_id={self.machine_id}&access_key={self.access_key}" + ) + async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws: + log.info("Connected to relay") + await self._message_loop(ws) + + async def _message_loop(self, ws): + with ScreenCapture(fps=15, quality=60) as screen: + stream_task: Optional[asyncio.Task] = None + + async def stream_frames(): + while self._streaming and not self._stop_event.is_set(): + t0 = time.monotonic() + try: + frame = screen.capture() + await ws.send(frame) + except Exception: + break + elapsed = time.monotonic() - t0 + delay = max(0, screen.frame_delay - elapsed) + await asyncio.sleep(delay) + + try: + async for raw_msg in ws: + if isinstance(raw_msg, bytes): + continue # agents don't receive binary + + try: + msg = json.loads(raw_msg) + except json.JSONDecodeError: + continue + + msg_type = msg.get("type") + + if msg_type == "start_stream": + session_id = msg.get("session_id") + log.info(f"Viewer connected — session {session_id}") + self._streaming = True + self._active_session = session_id + if stream_task and not stream_task.done(): + stream_task.cancel() + stream_task = asyncio.create_task(stream_frames()) + + elif msg_type == "stop_stream": + log.info("Viewer disconnected — stopping stream") + self._streaming = False + self._active_session = None + if stream_task and not stream_task.done(): + stream_task.cancel() + + elif msg_type in ("mouse_move", "mouse_click", "mouse_scroll", + "key_press", "key_special"): + self._input.handle(msg) + + elif msg_type == "exec_script": + asyncio.create_task(exec_script( + msg.get("script", ""), + msg.get("shell", "bash"), + msg.get("session_id", ""), + ws, + )) + + elif msg_type == "ping": + await ws.send(json.dumps({"type": "pong"})) + + finally: + self._streaming = False + if stream_task and not stream_task.done(): + stream_task.cancel() + + def stop(self): + self._stop_event.set() + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +def setup_logging(verbose: bool): + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler()], + ) + # Also log to file + log_dir = config_dir() + log_dir.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(log_dir / "agent.log") + fh.setLevel(level) + fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + logging.getLogger().addHandler(fh) + + +async def main(): + parser = argparse.ArgumentParser(description="RemoteLink Agent") + parser.add_argument("--server", help="Server URL (e.g. https://remotelink.example.com)") + 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("--verbose", "-v", action="store_true") + args = parser.parse_args() + + setup_logging(args.verbose) + + config = load_config() + + # ── First-time registration ────────────────────────────────────────────── + if args.enroll: + if not args.server: + log.error("--server is required with --enroll") + sys.exit(1) + log.info(f"Enrolling with server {args.server}…") + reg = await register(args.server, args.enroll) + relay_url = args.relay or args.server.replace("https://", "ws://").replace("http://", "ws://") + ":8765" + config = { + "server_url": args.server, + "relay_url": relay_url, + "machine_id": reg["machineId"], + "access_key": reg["accessKey"], + } + if not args.run_once: + save_config(config) + + elif not config: + log.error( + f"No config found at {CONFIG_FILE}.\n" + "Run with --server --enroll to register this machine." + ) + sys.exit(1) + + server_url = config["server_url"] + relay_url = config.get("relay_url") or ( + server_url.replace("https://", "ws://").replace("http://", "ws://") + ":8765" + ) + machine_id = config["machine_id"] + access_key = config["access_key"] + + agent = Agent(server_url, machine_id, access_key, relay_url) + + # Handle Ctrl+C / SIGTERM gracefully + loop = asyncio.get_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, agent.stop) + except NotImplementedError: + pass # Windows + + log.info("RemoteLink Agent running. Press Ctrl+C to stop.") + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agent/agent.spec b/agent/agent.spec new file mode 100644 index 0000000..eb73097 --- /dev/null +++ b/agent/agent.spec @@ -0,0 +1,110 @@ +# PyInstaller spec for RemoteLink Agent +# Build: pyinstaller agent.spec + +import sys +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +block_cipher = None + +# Collect mss data files (monitor detection) +mss_datas = collect_data_files('mss') + +a = Analysis( + ['agent.py'], + pathex=[], + binaries=[], + datas=mss_datas, + hiddenimports=[ + 'mss', + 'mss.windows', + 'mss.darwin', + 'mss.linux', + 'PIL', + 'PIL.Image', + 'PIL.JpegImagePlugin', + 'pynput', + 'pynput.mouse', + 'pynput.keyboard', + 'pynput._util', + 'pynput._util.win32', + 'pynput._util.darwin', + 'pynput._util.xorg', + 'websockets', + 'websockets.legacy', + 'websockets.legacy.client', + 'httpx', + 'httpcore', + 'asyncio', + 'json', + 'logging', + 'platform', + 'subprocess', + 'signal', + ], + hookspath=[], + runtime_hooks=[], + excludes=['tkinter', 'matplotlib', 'numpy', 'scipy'], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +# Single-file portable executable +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='remotelink-agent', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # No console window (set True for debugging) + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='assets/icon.ico' if sys.platform == 'win32' else None, + version='version_info.txt' if sys.platform == 'win32' else None, +) + +# Windows service executable (separate build target) +svc_a = Analysis( + ['service_win.py'], + pathex=[], + binaries=[], + datas=mss_datas, + hiddenimports=[ + 'win32serviceutil', 'win32service', 'win32event', 'servicemanager', + 'agent', + ] + collect_submodules('win32'), + hookspath=[], + runtime_hooks=[], + excludes=['tkinter'], + cipher=block_cipher, +) + +svc_pyz = PYZ(svc_a.pure, svc_a.zipped_data, cipher=block_cipher) + +svc_exe = EXE( + svc_pyz, + svc_a.scripts, + svc_a.binaries, + svc_a.zipfiles, + svc_a.datas, + [], + name='remotelink-agent-service', + debug=False, + strip=False, + upx=True, + console=True, + icon='assets/icon.ico' if sys.platform == 'win32' else None, +) diff --git a/agent/build.sh b/agent/build.sh new file mode 100644 index 0000000..6c784aa --- /dev/null +++ b/agent/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build script for RemoteLink Agent +# Run on the target platform (Windows/macOS/Linux) +# +# Requirements: Python 3.11+, pip, pyinstaller, (Windows) NSIS + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +echo "==> Installing dependencies…" +pip install -r requirements.txt +pip install pyinstaller + +echo "==> Building with PyInstaller…" +pyinstaller agent.spec --clean --noconfirm + +echo "==> Portable binary: dist/remotelink-agent" +ls -lh dist/remotelink-agent* 2>/dev/null || ls -lh dist/ + +# Windows: build NSIS installer +if [[ "$OSTYPE" == "msys"* ]] || [[ "$OS" == "Windows_NT" ]]; then + if command -v makensis &>/dev/null; then + echo "==> Building NSIS installer…" + makensis installer.nsi + echo "==> Installer: RemoteLink-Setup.exe" + else + echo "==> NSIS not found — skipping installer build." + echo " Download NSIS from https://nsis.sourceforge.io" + fi +fi + +echo "==> Done." diff --git a/agent/installer.nsi b/agent/installer.nsi new file mode 100644 index 0000000..b8edeb5 --- /dev/null +++ b/agent/installer.nsi @@ -0,0 +1,116 @@ +; RemoteLink Agent NSIS Installer +; Requires: NSIS 3.x, the dist/ folder from PyInstaller build +; +; Build: makensis installer.nsi +; Silent install: RemoteLink-Setup.exe /S +; Silent install + enroll: RemoteLink-Setup.exe /S /SERVER=https://myserver.com /ENROLL=mytoken + +!define APP_NAME "RemoteLink Agent" +!define APP_VERSION "1.0.0" +!define APP_PUBLISHER "RemoteLink" +!define APP_URL "https://remotelink.example.com" +!define INSTALL_DIR "$PROGRAMFILES64\RemoteLink" +!define SERVICE_EXE "remotelink-agent-service.exe" +!define AGENT_EXE "remotelink-agent.exe" +!define REG_KEY "Software\RemoteLink\Agent" +!define UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\RemoteLinkAgent" + +; Installer settings +Name "${APP_NAME} ${APP_VERSION}" +OutFile "RemoteLink-Setup.exe" +InstallDir "${INSTALL_DIR}" +InstallDirRegKey HKLM "${REG_KEY}" "InstallDir" +RequestExecutionLevel admin +SetCompressor /SOLID lzma + +; Command-line parameters +Var ServerURL +Var EnrollToken + +; Modern UI +!include "MUI2.nsh" +!include "LogicLib.nsh" +!include "nsProcess.nsh" + +!define MUI_ABORTWARNING +!define MUI_ICON "assets\icon.ico" +!define MUI_UNICON "assets\icon.ico" + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" + +; ── Functions ───────────────────────────────────────────────────────────────── + +Function .onInit + ; Parse command-line switches /SERVER= and /ENROLL= + ${GetParameters} $R0 + ${GetOptions} $R0 "/SERVER=" $ServerURL + ${GetOptions} $R0 "/ENROLL=" $EnrollToken + + ; Silent mode: skip UI if /S passed + IfSilent 0 +2 + SetSilent silent +FunctionEnd + +; ── Main install section ────────────────────────────────────────────────────── + +Section "Install" SEC_MAIN + SetOutPath "${INSTALL_DIR}" + + ; Stop existing service if running + nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" stop' + nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" remove' + Sleep 1000 + + ; Copy files from PyInstaller dist/ + File "dist\remotelink-agent.exe" + File "dist\remotelink-agent-service.exe" + + ; Write registry + WriteRegStr HKLM "${REG_KEY}" "InstallDir" "${INSTALL_DIR}" + WriteRegStr HKLM "${REG_KEY}" "Version" "${APP_VERSION}" + + ; Run enrollment if tokens were provided (silent mass-deploy) + ${If} $ServerURL != "" + ${AndIf} $EnrollToken != "" + DetailPrint "Enrolling with server $ServerURL…" + nsExec::ExecToLog '"${INSTALL_DIR}\${AGENT_EXE}" --server "$ServerURL" --enroll "$EnrollToken"' + ${EndIf} + + ; Install + start Windows service + DetailPrint "Installing Windows service…" + nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" install' + nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" start' + + ; Create uninstaller + WriteUninstaller "${INSTALL_DIR}\Uninstall.exe" + WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayName" "${APP_NAME}" + WriteRegStr HKLM "${UNINSTALL_KEY}" "UninstallString" "${INSTALL_DIR}\Uninstall.exe" + WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayVersion" "${APP_VERSION}" + WriteRegStr HKLM "${UNINSTALL_KEY}" "Publisher" "${APP_PUBLISHER}" + WriteRegDWORD HKLM "${UNINSTALL_KEY}" "NoModify" 1 + WriteRegDWORD HKLM "${UNINSTALL_KEY}" "NoRepair" 1 +SectionEnd + +; ── Uninstall section ───────────────────────────────────────────────────────── + +Section "Uninstall" + nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" stop' + nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" remove' + Sleep 1000 + + Delete "${INSTALL_DIR}\remotelink-agent.exe" + Delete "${INSTALL_DIR}\remotelink-agent-service.exe" + Delete "${INSTALL_DIR}\Uninstall.exe" + RMDir "${INSTALL_DIR}" + + DeleteRegKey HKLM "${REG_KEY}" + DeleteRegKey HKLM "${UNINSTALL_KEY}" +SectionEnd diff --git a/agent/remotelink-agent.spec b/agent/remotelink-agent.spec new file mode 100644 index 0000000..146d264 --- /dev/null +++ b/agent/remotelink-agent.spec @@ -0,0 +1,42 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_data_files + +datas = [] +datas += collect_data_files('mss') + + +a = Analysis( + ['agent.py'], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=['mss', 'mss.linux', 'PIL', 'PIL.Image', 'PIL.JpegImagePlugin', 'pynput', 'pynput.mouse', 'pynput.keyboard', 'websockets', 'websockets.legacy', 'websockets.legacy.client', 'httpx', 'httpcore'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='remotelink-agent', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/agent/requirements.txt b/agent/requirements.txt new file mode 100644 index 0000000..49b672d --- /dev/null +++ b/agent/requirements.txt @@ -0,0 +1,10 @@ +# Core +httpx==0.27.2 +websockets==13.1 +mss==9.0.2 +Pillow==11.0.0 +pynput==1.7.7 + +# Windows service support (Windows only) +pywin32==308; sys_platform == "win32" +pywin32-ctypes==0.2.3; sys_platform == "win32" diff --git a/agent/service_win.py b/agent/service_win.py new file mode 100644 index 0000000..18e9daa --- /dev/null +++ b/agent/service_win.py @@ -0,0 +1,98 @@ +""" +Windows Service wrapper for the RemoteLink Agent. +Installs/uninstalls/runs as a Windows Service via pywin32. + +Usage: + remotelink-agent-service.exe install + remotelink-agent-service.exe start + remotelink-agent-service.exe stop + remotelink-agent-service.exe remove +""" + +import sys +import os +import asyncio +import logging + +# Only import win32 on Windows +if sys.platform == "win32": + import win32serviceutil + import win32service + import win32event + import servicemanager + + class RemoteLinkService(win32serviceutil.ServiceFramework): + _svc_name_ = "RemoteLinkAgent" + _svc_display_name_ = "RemoteLink Agent" + _svc_description_ = "RemoteLink remote support agent service" + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self.stop_event = win32event.CreateEvent(None, 0, 0, None) + self._loop = None + self._agent = None + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + if self._agent: + self._agent.stop() + win32event.SetEvent(self.stop_event) + + def SvcDoRun(self): + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + self._run() + + def _run(self): + # Set up logging to Windows Event Log + file + log_path = os.path.join( + os.environ.get("PROGRAMDATA", "C:\\ProgramData"), + "RemoteLink", "agent.log" + ) + os.makedirs(os.path.dirname(log_path), exist_ok=True) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.FileHandler(log_path)], + ) + + # Import here to avoid circular issues when this module is imported + from agent import Agent, load_config, heartbeat + import json + + config = load_config() + if not config: + logging.error("No agent config found. Run enrollment first.") + return + + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + agent = Agent( + config["server_url"], + config["machine_id"], + config["access_key"], + config.get("relay_url", ""), + ) + self._agent = agent + + try: + self._loop.run_until_complete(agent.run()) + finally: + self._loop.close() + + + if __name__ == "__main__": + if len(sys.argv) == 1: + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(RemoteLinkService) + servicemanager.StartServiceCtrlDispatcher() + else: + win32serviceutil.HandleCommandLine(RemoteLinkService) + +else: + print("Windows service wrapper — only runs on Windows.") + sys.exit(1) diff --git a/app/(dashboard)/dashboard/admin/page.tsx b/app/(dashboard)/dashboard/admin/page.tsx index c7f19fd..c202f2b 100644 --- a/app/(dashboard)/dashboard/admin/page.tsx +++ b/app/(dashboard)/dashboard/admin/page.tsx @@ -11,7 +11,34 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' -import { UserPlus, Copy, Check, Trash2, Clock, CheckCircle2, Loader2 } from 'lucide-react' +import { + UserPlus, Copy, Check, Trash2, Clock, CheckCircle2, + Loader2, KeyRound, Ban, Infinity, Hash, +} from 'lucide-react' + +// ── Shared helper ───────────────────────────────────────────────────────────── + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +// ── Invites ─────────────────────────────────────────────────────────────────── interface Invite { id: string @@ -28,30 +55,14 @@ function inviteStatus(invite: Invite): 'used' | 'expired' | 'pending' { return 'pending' } -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false) - - const handleCopy = async () => { - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - return ( - - ) -} - -export default function AdminPage() { +function InvitesSection() { const [invites, setInvites] = useState([]) const [email, setEmail] = useState('') const [isLoading, setIsLoading] = useState(true) const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) - const [successEmail, setSuccessEmail] = useState(null) const [newToken, setNewToken] = useState(null) + const [successEmail, setSuccessEmail] = useState(null) const fetchInvites = useCallback(async () => { try { @@ -63,17 +74,12 @@ export default function AdminPage() { } }, []) - useEffect(() => { - fetchInvites() - }, [fetchInvites]) + useEffect(() => { fetchInvites() }, [fetchInvites]) - const handleCreate = async (e: React.FormEvent) => { + const handleCreate = async (e: React.SyntheticEvent) => { e.preventDefault() - setError(null) - setSuccessEmail(null) - setNewToken(null) + setError(null); setSuccessEmail(null); setNewToken(null) setIsCreating(true) - try { const res = await fetch('/api/invites', { method: 'POST', @@ -81,12 +87,7 @@ export default function AdminPage() { body: JSON.stringify({ email }), }) const data = await res.json() - - if (!res.ok) { - setError(data.error) - return - } - + if (!res.ok) { setError(data.error); return } setSuccessEmail(email) setNewToken(data.invite.token) setEmail('') @@ -99,95 +100,48 @@ export default function AdminPage() { } const handleDelete = async (id: string) => { - const res = await fetch('/api/invites', { + await fetch('/api/invites', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) - if (res.ok) await fetchInvites() + await fetchInvites() } - const inviteUrl = (token: string) => - `${window.location.origin}/auth/invite/${token}` + const inviteUrl = (token: string) => `${window.location.origin}/auth/invite/${token}` const statusBadge = (invite: Invite) => { - const status = inviteStatus(invite) - if (status === 'used') - return ( - - Used - - ) - if (status === 'expired') - return ( - - Expired - - ) - return ( - - Pending - - ) + const s = inviteStatus(invite) + if (s === 'used') return Used + if (s === 'expired') return Expired + return Pending } return ( -
-
-

Admin

-

Manage user invitations

-
- - {/* Create invite */} +
- - - Invite user - - - Send an invite link to a new user. Links expire after 7 days. - + Invite user + Send an invite link to a new user. Links expire after 7 days.
- setEmail(e.target.value)} - className="bg-secondary/50" - /> + setEmail(e.target.value)} className="bg-secondary/50" />
- - {error && ( -
-

{error}

-
- )} - + {error &&

{error}

} {newToken && successEmail && (
-

- Invite created for {successEmail} -

+

Invite created for {successEmail}

- - {inviteUrl(newToken)} - + {inviteUrl(newToken)}
@@ -196,7 +150,6 @@ export default function AdminPage() { - {/* Invites list */} Invitations @@ -204,41 +157,26 @@ export default function AdminPage() { {isLoading ? ( -
- -
+
) : invites.length === 0 ? ( -

- No invitations yet -

+

No invitations yet

) : (
{invites.map((invite) => { const status = inviteStatus(invite) return ( -
+

{invite.email}

{statusBadge(invite)} - - {new Date(invite.created_at).toLocaleDateString()} - + {new Date(invite.created_at).toLocaleDateString()}
- {status === 'pending' && ( - - )} -
@@ -252,3 +190,269 @@ export default function AdminPage() {
) } + +// ── Enrollment tokens ───────────────────────────────────────────────────────── + +interface EnrollmentToken { + id: string + token: string + label: string | null + created_at: string + expires_at: string | null + used_count: number + max_uses: number | null + revoked_at: string | null +} + +function tokenStatus(t: EnrollmentToken): 'active' | 'revoked' | 'expired' | 'exhausted' { + if (t.revoked_at) return 'revoked' + if (t.expires_at && new Date(t.expires_at) < new Date()) return 'expired' + if (t.max_uses !== null && t.used_count >= t.max_uses) return 'exhausted' + return 'active' +} + +function EnrollmentSection() { + const [tokens, setTokens] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isCreating, setIsCreating] = useState(false) + const [error, setError] = useState(null) + const [newToken, setNewToken] = useState(null) + + // form state + const [label, setLabel] = useState('') + const [expiresInDays, setExpiresInDays] = useState('') + const [maxUses, setMaxUses] = useState('') + + const fetchTokens = useCallback(async () => { + try { + const res = await fetch('/api/enrollment-tokens') + const data = await res.json() + if (res.ok) setTokens(data.tokens) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { fetchTokens() }, [fetchTokens]) + + const handleCreate = async (e: React.SyntheticEvent) => { + e.preventDefault() + setError(null); setNewToken(null) + setIsCreating(true) + try { + const res = await fetch('/api/enrollment-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: label || undefined, + expiresInDays: expiresInDays ? parseInt(expiresInDays) : undefined, + maxUses: maxUses ? parseInt(maxUses) : undefined, + }), + }) + const data = await res.json() + if (!res.ok) { setError(data.error); return } + setNewToken(data.token) + setLabel(''); setExpiresInDays(''); setMaxUses('') + await fetchTokens() + } catch { + setError('Network error. Please try again.') + } finally { + setIsCreating(false) + } + } + + const handleRevoke = async (id: string) => { + await fetch('/api/enrollment-tokens', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + await fetchTokens() + } + + const statusBadge = (t: EnrollmentToken) => { + const s = tokenStatus(t) + const styles: Record = { + active: 'text-green-500', + revoked: 'text-destructive', + expired: 'text-muted-foreground', + exhausted: 'text-yellow-500', + } + const labels: Record = { + active: 'Active', + revoked: 'Revoked', + expired: 'Expired', + exhausted: 'Exhausted', + } + return {labels[s]} + } + + const agentCommand = (token: string) => + `python agent.py --server ${window.location.origin} --enroll ${token}` + + return ( +
+ + + Create enrollment token + + Tokens let agents self-register. Share the token or use it in a silent installer for mass deployment. + + + + +
+ + setLabel(e.target.value)} className="bg-secondary/50" /> +
+
+
+ + setExpiresInDays(e.target.value)} className="bg-secondary/50" /> +
+
+ + setMaxUses(e.target.value)} className="bg-secondary/50" /> +
+
+ + {error &&

{error}

} + + + + + {newToken && ( +
+

Token created

+ +
+

Token

+
+ {newToken.token} + +
+
+ +
+

Agent command

+
+ {agentCommand(newToken.token)} + +
+
+ +
+

Silent Windows installer

+
+ + {`RemoteLink-Setup.exe /S /SERVER=${window.location.origin} /ENROLL=${newToken.token}`} + + +
+
+
+ )} +
+
+ + + + Enrollment tokens + Manage tokens used to register new agents + + + {isLoading ? ( +
+ ) : tokens.length === 0 ? ( +

No enrollment tokens yet

+ ) : ( +
+ {tokens.map((t) => { + const status = tokenStatus(t) + const isActive = status === 'active' + return ( +
+
+
+

{t.label || Unlabelled}

+ {statusBadge(t)} +
+
+ {t.token} + + + {t.used_count} use{t.used_count !== 1 ? 's' : ''} + {t.max_uses !== null ? ` / ${t.max_uses}` : ''} + + {t.expires_at ? ( + + + {new Date(t.expires_at) > new Date() + ? `Expires ${new Date(t.expires_at).toLocaleDateString()}` + : `Expired ${new Date(t.expires_at).toLocaleDateString()}`} + + ) : ( + No expiry + )} +
+
+
+ {isActive && } + {isActive && ( + + )} +
+
+ ) + })} +
+ )} +
+
+
+ ) +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +type Tab = 'invites' | 'enrollment' + +export default function AdminPage() { + const [tab, setTab] = useState('invites') + + return ( +
+
+

Admin

+

Manage users and agent deployment

+
+ + {/* Tab bar */} +
+ {(['invites', 'enrollment'] as Tab[]).map((t) => ( + + ))} +
+ + {tab === 'invites' ? : } +
+ ) +} diff --git a/app/(dashboard)/dashboard/connect/page.tsx b/app/(dashboard)/dashboard/connect/page.tsx index 953cd5a..3cd3714 100644 --- a/app/(dashboard)/dashboard/connect/page.tsx +++ b/app/(dashboard)/dashboard/connect/page.tsx @@ -42,7 +42,7 @@ export default function ConnectPage() { return } - router.push(`/viewer/${data.sessionId}`) + router.push(`/viewer/${data.sessionId}?token=${data.viewerToken}`) } catch { setError('Network error. Please try again.') setIsConnecting(false) diff --git a/app/(dashboard)/download/page.tsx b/app/(dashboard)/download/page.tsx index 1c2efbd..8520402 100644 --- a/app/(dashboard)/download/page.tsx +++ b/app/(dashboard)/download/page.tsx @@ -1,14 +1,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { - Download, - Monitor, - Apple, - Terminal, - Shield, - Cpu, - HardDrive -} from 'lucide-react' +import { Download, Monitor, Apple, Terminal, Shield, Cpu, HardDrive, Clock } from 'lucide-react' +import Link from 'next/link' const platforms = [ { @@ -16,43 +9,35 @@ const platforms = [ icon: Monitor, description: 'Windows 10/11 (64-bit)', filename: 'RemoteLink-Setup.exe', - size: '45 MB', - available: true, + downloadPath: null, + available: false, + note: 'Coming soon — build on Windows with PyInstaller + NSIS', }, { name: 'macOS', icon: Apple, description: 'macOS 11+ (Apple Silicon & Intel)', - filename: 'RemoteLink.dmg', - size: '52 MB', - available: true, + filename: 'remotelink-agent-macos', + downloadPath: null, + available: false, + note: 'Coming soon — build on macOS with PyInstaller', }, { name: 'Linux', icon: Terminal, - description: 'Ubuntu, Debian, Fedora, Arch', - filename: 'remotelink-agent.AppImage', - size: '48 MB', + description: 'x86_64 — Ubuntu, Debian, Fedora, Arch', + filename: 'remotelink-agent-linux', + downloadPath: '/downloads/remotelink-agent-linux', available: true, + size: '19 MB', + note: null, }, ] const features = [ - { - icon: Shield, - title: 'Secure', - description: 'End-to-end encryption for all connections', - }, - { - icon: Cpu, - title: 'Lightweight', - description: 'Minimal system resource usage', - }, - { - icon: HardDrive, - title: 'Portable', - description: 'No installation required on Windows', - }, + { icon: Shield, title: 'Secure', description: 'All traffic routed through your own relay server' }, + { icon: Cpu, title: 'Lightweight', description: 'Single binary, minimal CPU and memory usage' }, + { icon: HardDrive, title: 'Portable', description: 'Run once with no install, or deploy as a service' }, ] export default function DownloadPage() { @@ -61,17 +46,17 @@ export default function DownloadPage() {

Download RemoteLink Agent

- Install the agent on machines you want to control remotely. - The agent runs in the background and enables secure connections. + Install the agent on machines you want to control remotely. + It connects back to your server and waits for a viewer session.

{platforms.map((platform) => ( - + -
- +
+
{platform.name} {platform.description} @@ -79,15 +64,26 @@ export default function DownloadPage() {

{platform.filename}

-

{platform.size}

+ {platform.available && 'size' in platform &&

{platform.size}

}
- + + {platform.available && platform.downloadPath ? ( + + ) : ( + + )} + + {platform.note && ( +

{platform.note}

+ )}
))} @@ -118,30 +114,27 @@ export default function DownloadPage() {
-

Windows

+

Linux — run once (portable)

    -
  1. Download and run RemoteLink-Setup.exe
  2. -
  3. Follow the installation wizard
  4. -
  5. The agent will start automatically and appear in your system tray
  6. -
  7. Click the tray icon to generate a session code
  8. +
  9. Download remotelink-agent-linux
  10. +
  11. Make it executable: chmod +x remotelink-agent-linux
  12. +
  13. Get an enrollment token from Admin → Agent enrollment
  14. +
  15. Run: ./remotelink-agent-linux --server https://your-server --enroll YOUR_TOKEN --run-once
-

macOS

+

Linux — permanent install (reconnects on reboot)

    -
  1. Download and open RemoteLink.dmg
  2. -
  3. Drag RemoteLink to your Applications folder
  4. -
  5. Open RemoteLink from Applications
  6. -
  7. Grant accessibility permissions when prompted
  8. +
  9. Run without --run-once — config is saved to /etc/remotelink/agent.json
  10. +
  11. Create a systemd service or add to crontab with @reboot
-

Linux

+

Windows — silent mass deploy (coming soon)

    -
  1. Download the AppImage file
  2. -
  3. Make it executable: chmod +x remotelink-agent.AppImage
  4. -
  5. Run the AppImage
  6. -
  7. The agent will appear in your system tray
  8. +
  9. Build RemoteLink-Setup.exe on a Windows machine using the NSIS installer script in the agent source
  10. +
  11. Deploy silently: RemoteLink-Setup.exe /S /SERVER=https://your-server /ENROLL=YOUR_TOKEN
  12. +
  13. The installer registers a Windows Service that auto-starts on boot
diff --git a/app/api/agent/register/route.ts b/app/api/agent/register/route.ts index c38117e..1addb43 100644 --- a/app/api/agent/register/route.ts +++ b/app/api/agent/register/route.ts @@ -1,44 +1,112 @@ import { db } from '@/lib/db' -import { machines } from '@/lib/db/schema' -import { eq } from 'drizzle-orm' +import { machines, enrollmentTokens } from '@/lib/db/schema' +import { eq, and, isNull, or, gt } from 'drizzle-orm' import { NextRequest, NextResponse } from 'next/server' +import { randomBytes } from 'crypto' +// POST /api/agent/register +// Two modes: +// 1. First-time: { enrollmentToken, name, hostname, os, osVersion, agentVersion, ipAddress } +// → creates machine, returns { machineId, accessKey } +// 2. Re-register: { accessKey, name, hostname, os, osVersion, agentVersion, ipAddress } +// → updates existing machine, returns { machineId } export async function POST(request: NextRequest) { try { const body = await request.json() - const { accessKey, name, hostname, os, osVersion, agentVersion, ipAddress } = body + const { accessKey, enrollmentToken, name, hostname, os, osVersion, agentVersion, ipAddress } = body - if (!accessKey) { - return NextResponse.json({ error: 'Access key required' }, { status: 400 }) + // ── Mode 2: existing agent re-registering ───────────────────────────────── + if (accessKey) { + const result = await db + .select() + .from(machines) + .where(eq(machines.accessKey, accessKey)) + .limit(1) + + const machine = result[0] + if (!machine) { + return NextResponse.json({ error: 'Invalid access key' }, { status: 401 }) + } + + await db + .update(machines) + .set({ + name: name || machine.name, + hostname: hostname || machine.hostname, + os: os || machine.os, + osVersion: osVersion || machine.osVersion, + agentVersion: agentVersion || machine.agentVersion, + ipAddress: ipAddress || machine.ipAddress, + isOnline: true, + lastSeen: new Date(), + updatedAt: new Date(), + }) + .where(eq(machines.id, machine.id)) + + return NextResponse.json({ machineId: machine.id }) } - const result = await db + // ── Mode 1: first-time registration with enrollment token ───────────────── + if (!enrollmentToken) { + return NextResponse.json({ error: 'accessKey or enrollmentToken required' }, { status: 400 }) + } + + const tokenResult = await db .select() - .from(machines) - .where(eq(machines.accessKey, accessKey)) + .from(enrollmentTokens) + .where(eq(enrollmentTokens.token, enrollmentToken)) .limit(1) - const machine = result[0] - if (!machine) { - return NextResponse.json({ error: 'Invalid access key' }, { status: 401 }) + const token = tokenResult[0] + if (!token) { + return NextResponse.json({ error: 'Invalid enrollment token' }, { status: 401 }) } - await db - .update(machines) - .set({ - name: name || machine.name, - hostname: hostname || machine.hostname, - os: os || machine.os, - osVersion: osVersion || machine.osVersion, - agentVersion: agentVersion || machine.agentVersion, - ipAddress: ipAddress || machine.ipAddress, + // Check revoked + if (token.revokedAt) { + return NextResponse.json({ error: 'Enrollment token has been revoked' }, { status: 401 }) + } + + // Check expiry + if (token.expiresAt && token.expiresAt < new Date()) { + return NextResponse.json({ error: 'Enrollment token has expired' }, { status: 401 }) + } + + // Check max uses + if (token.maxUses !== null && token.usedCount >= token.maxUses) { + return NextResponse.json({ error: 'Enrollment token has reached its use limit' }, { status: 401 }) + } + + if (!name) { + return NextResponse.json({ error: 'name is required for first-time registration' }, { status: 400 }) + } + + // Generate a secure access key for this machine + const newAccessKey = randomBytes(32).toString('hex') + + const newMachine = await db + .insert(machines) + .values({ + userId: token.createdBy!, + name, + hostname, + os, + osVersion, + agentVersion, + ipAddress, + accessKey: newAccessKey, isOnline: true, lastSeen: new Date(), - updatedAt: new Date(), }) - .where(eq(machines.id, machine.id)) + .returning({ id: machines.id }) - return NextResponse.json({ success: true, machineId: machine.id }) + // Increment use count + await db + .update(enrollmentTokens) + .set({ usedCount: token.usedCount + 1 }) + .where(eq(enrollmentTokens.id, token.id)) + + return NextResponse.json({ machineId: newMachine[0].id, accessKey: newAccessKey }) } catch (error) { console.error('[Agent Register] Error:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/app/api/connect/route.ts b/app/api/connect/route.ts index cfbe701..f731eb6 100644 --- a/app/api/connect/route.ts +++ b/app/api/connect/route.ts @@ -51,7 +51,7 @@ export async function POST(request: NextRequest) { .set({ usedAt: new Date(), usedBy: session.user.id, isActive: false }) .where(eq(sessionCodes.id, sessionCode.id)) - // Create session record + // Create session record (viewer_token is auto-generated by DB default) const newSession = await db .insert(sessions) .values({ @@ -61,7 +61,11 @@ export async function POST(request: NextRequest) { connectionType: 'session_code', sessionCode: normalizedCode, }) - .returning({ id: sessions.id }) + .returning({ id: sessions.id, viewerToken: sessions.viewerToken }) - return NextResponse.json({ sessionId: newSession[0].id }) + return NextResponse.json({ + sessionId: newSession[0].id, + viewerToken: newSession[0].viewerToken, + machineId: sessionCode.machineId, + }) } diff --git a/app/api/enrollment-tokens/route.ts b/app/api/enrollment-tokens/route.ts new file mode 100644 index 0000000..15ef2ac --- /dev/null +++ b/app/api/enrollment-tokens/route.ts @@ -0,0 +1,62 @@ +import { auth } from '@/auth' +import { db } from '@/lib/db' +import { enrollmentTokens } from '@/lib/db/schema' +import { eq, desc } from 'drizzle-orm' +import { NextRequest, NextResponse } from 'next/server' + +// GET /api/enrollment-tokens — list all (admin only) +export async function GET() { + const session = await auth() + if (!session?.user || session.user.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const tokens = await db + .select() + .from(enrollmentTokens) + .orderBy(desc(enrollmentTokens.createdAt)) + + return NextResponse.json({ tokens }) +} + +// POST /api/enrollment-tokens — create new token (admin only) +export async function POST(request: NextRequest) { + const session = await auth() + if (!session?.user || session.user.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { label, expiresInDays, maxUses } = await request.json() + + const expiresAt = expiresInDays + ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) + : null + + const token = await db + .insert(enrollmentTokens) + .values({ + label: label || null, + createdBy: session.user.id, + expiresAt, + maxUses: maxUses || null, + }) + .returning() + + return NextResponse.json({ token: token[0] }, { status: 201 }) +} + +// DELETE /api/enrollment-tokens — revoke token (admin only) +export async function DELETE(request: NextRequest) { + const session = await auth() + if (!session?.user || session.user.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { id } = await request.json() + await db + .update(enrollmentTokens) + .set({ revokedAt: new Date() }) + .where(eq(enrollmentTokens.id, id)) + + return NextResponse.json({ success: true }) +} diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx index 59aa4bd..d549826 100644 --- a/app/auth/error/page.tsx +++ b/app/auth/error/page.tsx @@ -39,7 +39,7 @@ export default function AuthErrorPage() { diff --git a/app/auth/sign-up-success/page.tsx b/app/auth/sign-up-success/page.tsx index 8f49ded..9aad6dc 100644 --- a/app/auth/sign-up-success/page.tsx +++ b/app/auth/sign-up-success/page.tsx @@ -20,15 +20,15 @@ export default function SignUpSuccessPage() {
- Check your email + Account created - {"We've sent you a confirmation link. Please check your inbox and click the link to activate your account."} + Your account has been set up. You can now sign in.

- {"Didn't receive the email? Check your spam folder or try signing up again with a different email address."} + Use the email address from your invite link to log in.

) } diff --git a/components/viewer/connection-status.tsx b/components/viewer/connection-status.tsx index b992e72..f19caea 100644 --- a/components/viewer/connection-status.tsx +++ b/components/viewer/connection-status.tsx @@ -5,27 +5,27 @@ import { Signal, Clock, Gauge } from 'lucide-react' interface ConnectionStatusProps { quality: 'high' | 'medium' | 'low' + fps?: number } -export function ConnectionStatus({ quality }: ConnectionStatusProps) { +export function ConnectionStatus({ quality, fps: realFps }: ConnectionStatusProps) { const [stats, setStats] = useState({ - latency: 45, - fps: 60, - bitrate: 8.5, + latency: 0, + fps: 0, + bitrate: 0, }) - // Simulate varying connection stats useEffect(() => { const interval = setInterval(() => { - setStats({ + setStats((prev) => ({ latency: Math.floor(35 + Math.random() * 20), - fps: quality === 'high' ? 60 : quality === 'medium' ? 30 : 15, + fps: realFps ?? prev.fps, bitrate: quality === 'high' ? 8 + Math.random() * 2 : quality === 'medium' ? 4 + Math.random() : 1 + Math.random() * 0.5, - }) + })) }, 1000) return () => clearInterval(interval) - }, [quality]) + }, [quality, realFps]) return (
diff --git a/db/migrations/0002_agent_relay.sql b/db/migrations/0002_agent_relay.sql new file mode 100644 index 0000000..1cd73ce --- /dev/null +++ b/db/migrations/0002_agent_relay.sql @@ -0,0 +1,19 @@ +-- Migration 0002: relay support +-- Adds viewer_token to sessions and enrollment_tokens table + +ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS viewer_token UUID DEFAULT gen_random_uuid(); + +CREATE TABLE IF NOT EXISTS enrollment_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), + label TEXT, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, + used_count INTEGER NOT NULL DEFAULT 0, + max_uses INTEGER, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_token ON enrollment_tokens(token); diff --git a/docker-compose.yml b/docker-compose.yml index a7d6692..09fea6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: dockerfile: Dockerfile args: NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + NEXT_PUBLIC_RELAY_URL: ${NEXT_PUBLIC_RELAY_URL:-localhost:8765} image: remotelink:latest container_name: remotelink-app restart: unless-stopped @@ -33,11 +34,27 @@ services: DATABASE_URL: postgresql://${POSTGRES_USER:-remotelink}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-remotelink} AUTH_SECRET: ${AUTH_SECRET} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + NEXT_PUBLIC_RELAY_URL: ${NEXT_PUBLIC_RELAY_URL:-localhost:8765} env_file: - .env depends_on: postgres: condition: service_healthy + relay: + build: + context: ./relay + dockerfile: Dockerfile + image: remotelink-relay:latest + container_name: remotelink-relay + restart: unless-stopped + ports: + - "8765:8765" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-remotelink}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-remotelink} + depends_on: + postgres: + condition: service_healthy + volumes: postgres_data: diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 0eff9b3..7625733 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -53,12 +53,25 @@ export const sessions = pgTable('sessions', { viewerUserId: uuid('viewer_user_id').references(() => users.id, { onDelete: 'set null' }), connectionType: text('connection_type'), sessionCode: text('session_code'), + viewerToken: uuid('viewer_token').default(sql`gen_random_uuid()`), startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(), endedAt: timestamp('ended_at', { withTimezone: true }), durationSeconds: integer('duration_seconds'), notes: text('notes'), }) +export const enrollmentTokens = pgTable('enrollment_tokens', { + id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), + token: uuid('token').unique().notNull().default(sql`gen_random_uuid()`), + label: text('label'), + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }), + usedCount: integer('used_count').default(0).notNull(), + maxUses: integer('max_uses'), + revokedAt: timestamp('revoked_at', { withTimezone: true }), +}) + export const invites = pgTable('invites', { id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), token: uuid('token').unique().notNull().default(sql`gen_random_uuid()`), @@ -77,3 +90,4 @@ export type Machine = typeof machines.$inferSelect export type SessionCode = typeof sessionCodes.$inferSelect export type Session = typeof sessions.$inferSelect export type Invite = typeof invites.$inferSelect +export type EnrollmentToken = typeof enrollmentTokens.$inferSelect diff --git a/public/downloads/remotelink-agent-linux b/public/downloads/remotelink-agent-linux new file mode 100755 index 0000000..8693628 Binary files /dev/null and b/public/downloads/remotelink-agent-linux differ diff --git a/relay/Dockerfile b/relay/Dockerfile new file mode 100644 index 0000000..1ff5c70 --- /dev/null +++ b/relay/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 8765 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765", "--log-level", "info"] diff --git a/relay/main.py b/relay/main.py new file mode 100644 index 0000000..5f051a5 --- /dev/null +++ b/relay/main.py @@ -0,0 +1,220 @@ +""" +RemoteLink WebSocket Relay +Bridges agent (remote machine) ↔ viewer (browser) connections. + +Agent connects: ws://relay:8765/ws/agent?machine_id=&access_key= +Viewer connects: ws://relay:8765/ws/viewer?session_id=&viewer_token= +""" + +import asyncio +import json +import logging +import os +from contextlib import asynccontextmanager +from typing import Optional +from uuid import UUID + +import asyncpg +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("relay") + +DATABASE_URL = os.environ["DATABASE_URL"] + +# ── In-memory connection registry ──────────────────────────────────────────── +# machine_id (str) → WebSocket +agents: dict[str, WebSocket] = {} +# session_id (str) → WebSocket +viewers: dict[str, WebSocket] = {} +# session_id → machine_id +session_to_machine: dict[str, str] = {} + +db_pool: Optional[asyncpg.Pool] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global db_pool + db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) + log.info("Database pool ready") + yield + await db_pool.close() + + +app = FastAPI(lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +async def validate_agent(machine_id: str, access_key: str) -> Optional[dict]: + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, name, user_id FROM machines WHERE id = $1 AND access_key = $2", + machine_id, access_key, + ) + return dict(row) if row else None + + +async def validate_viewer(session_id: str, viewer_token: str) -> Optional[dict]: + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + """SELECT id, machine_id, machine_name + FROM sessions + WHERE id = $1 AND viewer_token = $2 AND ended_at IS NULL""", + session_id, viewer_token, + ) + return dict(row) if row else None + + +async def set_machine_online(machine_id: str, online: bool): + async with db_pool.acquire() as conn: + await conn.execute( + "UPDATE machines SET is_online = $1, last_seen = now() WHERE id = $2", + online, machine_id, + ) + + +async def send_json(ws: WebSocket, data: dict): + try: + await ws.send_text(json.dumps(data)) + except Exception: + pass + + +# ── Agent WebSocket endpoint ────────────────────────────────────────────────── + +@app.websocket("/ws/agent") +async def agent_endpoint( + websocket: WebSocket, + machine_id: str = Query(...), + access_key: str = Query(...), +): + machine = await validate_agent(machine_id, access_key) + if not machine: + await websocket.close(code=4001, reason="Unauthorized") + return + + await websocket.accept() + agents[machine_id] = websocket + await set_machine_online(machine_id, True) + log.info(f"Agent connected: {machine['name']} ({machine_id})") + + try: + while True: + msg = await websocket.receive() + + if "bytes" in msg and msg["bytes"]: + # Binary = JPEG frame → forward to all viewers watching this machine + frame_data = msg["bytes"] + for sid, mid in list(session_to_machine.items()): + if mid == machine_id and sid in viewers: + try: + await viewers[sid].send_bytes(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.) + try: + data = json.loads(msg["text"]) + # Forward script output to the relevant viewer + if data.get("type") == "script_output": + 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) + except json.JSONDecodeError: + pass + + except WebSocketDisconnect: + pass + except Exception as e: + log.warning(f"Agent {machine_id} error: {e}") + finally: + agents.pop(machine_id, None) + await set_machine_online(machine_id, False) + # Notify any connected viewers that the agent disconnected + for sid, mid in list(session_to_machine.items()): + if mid == machine_id and sid in viewers: + await send_json(viewers[sid], {"type": "agent_disconnected"}) + log.info(f"Agent disconnected: {machine['name']} ({machine_id})") + + +# ── Viewer WebSocket endpoint ───────────────────────────────────────────────── + +@app.websocket("/ws/viewer") +async def viewer_endpoint( + websocket: WebSocket, + session_id: str = Query(...), + viewer_token: str = Query(...), +): + session = await validate_viewer(session_id, viewer_token) + if not session: + await websocket.close(code=4001, reason="Session not found or expired") + return + + machine_id = str(session["machine_id"]) if session["machine_id"] else None + if not machine_id: + await websocket.close(code=4002, reason="No machine associated with session") + return + + await websocket.accept() + viewers[session_id] = websocket + session_to_machine[session_id] = machine_id + log.info(f"Viewer connected to session {session_id} (machine {machine_id})") + + if machine_id in agents: + # Tell agent to start streaming for this session + await send_json(agents[machine_id], { + "type": "start_stream", + "session_id": session_id, + }) + await send_json(websocket, {"type": "agent_connected", "machine_name": session["machine_name"]}) + else: + await send_json(websocket, {"type": "agent_offline"}) + + try: + while True: + text = await websocket.receive_text() + try: + event = json.loads(text) + event["session_id"] = session_id + # Forward control events to the agent + if machine_id in agents: + await send_json(agents[machine_id], event) + except json.JSONDecodeError: + pass + except WebSocketDisconnect: + pass + except Exception as e: + log.warning(f"Viewer {session_id} error: {e}") + finally: + viewers.pop(session_id, None) + session_to_machine.pop(session_id, None) + 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}") + + +# ── Health / status endpoints ───────────────────────────────────────────────── + +@app.get("/health") +async def health(): + return {"status": "ok", "agents": len(agents), "viewers": len(viewers)} + + +@app.get("/status/{machine_id}") +async def machine_status(machine_id: str): + return {"online": machine_id in agents} diff --git a/relay/requirements.txt b/relay/requirements.txt new file mode 100644 index 0000000..142fd30 --- /dev/null +++ b/relay/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +asyncpg==0.30.0