Add Python agent, WebSocket relay, real viewer, enrollment tokens

- WebSocket relay service (FastAPI) bridges agents and viewers
- Python agent with screen capture (mss), input control (pynput),
  script execution, and auto-reconnect
- Windows service wrapper, PyInstaller spec, NSIS installer for
  silent mass deployment (RemoteLink-Setup.exe /S /SERVER= /ENROLL=)
- Enrollment token system: admin generates tokens, agents self-register
- Real WebSocket viewer replaces simulated canvas
- Linux agent binary served from /downloads/remotelink-agent-linux
- DB migration 0002: viewer_token on sessions, enrollment_tokens table
- Sign-up pages cleaned up (invite-only redirect)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
monoadmin
2026-04-10 16:25:10 -07:00
parent b2be19ed14
commit e16a2fa978
28 changed files with 1953 additions and 343 deletions

View File

@@ -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

6
.gitignore vendored
View File

@@ -16,3 +16,9 @@ next.user-config.*
node_modules/
.next/
.DS_Store
# PyInstaller build artifacts
agent/build/
agent/dist/
agent/__pycache__/
agent/*.spec.bak

View File

@@ -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

66
agent/README.md Normal file
View File

@@ -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://<relay-host>:8765/ws/agent?machine_id=<id>&access_key=<key>`
It streams JPEG frames as binary WebSocket messages and receives JSON control events.

438
agent/agent.py Normal file
View File

@@ -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 <enrollment-token>
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 <token> --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 <url> --enroll <token> 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())

110
agent/agent.spec Normal file
View File

@@ -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,
)

34
agent/build.sh Normal file
View File

@@ -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."

116
agent/installer.nsi Normal file
View File

@@ -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

View File

@@ -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,
)

10
agent/requirements.txt Normal file
View File

@@ -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"

98
agent/service_win.py Normal file
View File

@@ -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)

View File

@@ -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 (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied
? <Check className="h-3.5 w-3.5 text-green-500" />
: <Copy className="h-3.5 w-3.5" />}
</Button>
)
}
// ── 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 (
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-7 px-2">
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
)
}
export default function AdminPage() {
function InvitesSection() {
const [invites, setInvites] = useState<Invite[]>([])
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successEmail, setSuccessEmail] = useState<string | null>(null)
const [newToken, setNewToken] = useState<string | null>(null)
const [successEmail, setSuccessEmail] = useState<string | null>(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<HTMLFormElement>) => {
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 (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3 w-3" /> Used
</span>
)
if (status === 'expired')
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> Expired
</span>
)
return (
<span className="flex items-center gap-1 text-xs text-primary">
<Clock className="h-3 w-3" /> Pending
</span>
)
const s = inviteStatus(invite)
if (s === 'used') return <span className="flex items-center gap-1 text-xs text-green-500"><CheckCircle2 className="h-3 w-3" /> Used</span>
if (s === 'expired') return <span className="flex items-center gap-1 text-xs text-muted-foreground"><Clock className="h-3 w-3" /> Expired</span>
return <span className="flex items-center gap-1 text-xs text-primary"><Clock className="h-3 w-3" /> Pending</span>
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Admin</h2>
<p className="text-muted-foreground">Manage user invitations</p>
</div>
{/* Create invite */}
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Invite user
</CardTitle>
<CardDescription>
Send an invite link to a new user. Links expire after 7 days.
</CardDescription>
<CardTitle className="flex items-center gap-2"><UserPlus className="h-5 w-5" />Invite user</CardTitle>
<CardDescription>Send an invite link to a new user. Links expire after 7 days.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<div className="flex gap-2">
<Input
id="email"
type="email"
placeholder="user@company.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-secondary/50"
/>
<Input id="email" type="email" placeholder="user@company.com" required value={email}
onChange={(e) => setEmail(e.target.value)} className="bg-secondary/50" />
<Button type="submit" disabled={isCreating}>
{isCreating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Send invite'
)}
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Send invite'}
</Button>
</div>
</div>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{error && <div className="rounded-md bg-destructive/10 border border-destructive/20 p-3"><p className="text-sm text-destructive">{error}</p></div>}
{newToken && successEmail && (
<div className="rounded-md bg-green-500/10 border border-green-500/20 p-3 space-y-2">
<p className="text-sm text-green-500 font-medium">
Invite created for {successEmail}
</p>
<p className="text-sm text-green-500 font-medium">Invite created for {successEmail}</p>
<div className="flex items-center gap-2">
<code className="text-xs text-muted-foreground truncate flex-1 bg-muted/50 px-2 py-1 rounded">
{inviteUrl(newToken)}
</code>
<code className="text-xs text-muted-foreground truncate flex-1 bg-muted/50 px-2 py-1 rounded">{inviteUrl(newToken)}</code>
<CopyButton text={inviteUrl(newToken)} />
</div>
</div>
@@ -196,7 +150,6 @@ export default function AdminPage() {
</CardContent>
</Card>
{/* Invites list */}
<Card className="border-border/50">
<CardHeader>
<CardTitle>Invitations</CardTitle>
@@ -204,41 +157,26 @@ export default function AdminPage() {
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : invites.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No invitations yet
</p>
<p className="text-sm text-muted-foreground text-center py-8">No invitations yet</p>
) : (
<div className="space-y-2">
{invites.map((invite) => {
const status = inviteStatus(invite)
return (
<div
key={invite.id}
className="flex items-center justify-between p-3 rounded-lg bg-muted/30"
>
<div key={invite.id} className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{invite.email}</p>
<div className="flex items-center gap-3 mt-0.5">
{statusBadge(invite)}
<span className="text-xs text-muted-foreground">
{new Date(invite.created_at).toLocaleDateString()}
</span>
<span className="text-xs text-muted-foreground">{new Date(invite.created_at).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-3">
{status === 'pending' && (
<CopyButton text={inviteUrl(invite.token)} />
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(invite.id)}
>
{status === 'pending' && <CopyButton text={inviteUrl(invite.token)} />}
<Button variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(invite.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -252,3 +190,269 @@ export default function AdminPage() {
</div>
)
}
// ── 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<EnrollmentToken[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [newToken, setNewToken] = useState<EnrollmentToken | null>(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<HTMLFormElement>) => {
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<string, string> = {
active: 'text-green-500',
revoked: 'text-destructive',
expired: 'text-muted-foreground',
exhausted: 'text-yellow-500',
}
const labels: Record<string, string> = {
active: 'Active',
revoked: 'Revoked',
expired: 'Expired',
exhausted: 'Exhausted',
}
return <span className={`text-xs font-medium ${styles[s]}`}>{labels[s]}</span>
}
const agentCommand = (token: string) =>
`python agent.py --server ${window.location.origin} --enroll ${token}`
return (
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2"><KeyRound className="h-5 w-5" />Create enrollment token</CardTitle>
<CardDescription>
Tokens let agents self-register. Share the token or use it in a silent installer for mass deployment.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="token-label">Label <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input id="token-label" placeholder="e.g. Office PCs batch 1"
value={label} onChange={(e) => setLabel(e.target.value)} className="bg-secondary/50" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="expires">Expires after <span className="text-muted-foreground font-normal">(days, blank = never)</span></Label>
<Input id="expires" type="number" min="1" placeholder="Never"
value={expiresInDays} onChange={(e) => setExpiresInDays(e.target.value)} className="bg-secondary/50" />
</div>
<div className="grid gap-2">
<Label htmlFor="max-uses">Max uses <span className="text-muted-foreground font-normal">(blank = unlimited)</span></Label>
<Input id="max-uses" type="number" min="1" placeholder="Unlimited"
value={maxUses} onChange={(e) => setMaxUses(e.target.value)} className="bg-secondary/50" />
</div>
</div>
{error && <div className="rounded-md bg-destructive/10 border border-destructive/20 p-3"><p className="text-sm text-destructive">{error}</p></div>}
<Button type="submit" disabled={isCreating}>
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate token'}
</Button>
</form>
{newToken && (
<div className="mt-4 rounded-md bg-green-500/10 border border-green-500/20 p-4 space-y-3">
<p className="text-sm text-green-500 font-medium">Token created</p>
<div>
<p className="text-xs text-muted-foreground mb-1">Token</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">{newToken.token}</code>
<CopyButton text={newToken.token} />
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Agent command</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">{agentCommand(newToken.token)}</code>
<CopyButton text={agentCommand(newToken.token)} />
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Silent Windows installer</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">
{`RemoteLink-Setup.exe /S /SERVER=${window.location.origin} /ENROLL=${newToken.token}`}
</code>
<CopyButton text={`RemoteLink-Setup.exe /S /SERVER=${window.location.origin} /ENROLL=${newToken.token}`} />
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="border-border/50">
<CardHeader>
<CardTitle>Enrollment tokens</CardTitle>
<CardDescription>Manage tokens used to register new agents</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : tokens.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No enrollment tokens yet</p>
) : (
<div className="space-y-2">
{tokens.map((t) => {
const status = tokenStatus(t)
const isActive = status === 'active'
return (
<div key={t.id} className={`flex items-center justify-between p-3 rounded-lg bg-muted/30 ${!isActive ? 'opacity-60' : ''}`}>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{t.label || <span className="text-muted-foreground italic">Unlabelled</span>}</p>
{statusBadge(t)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono truncate max-w-[160px]">{t.token}</span>
<span className="flex items-center gap-1">
<Hash className="h-3 w-3" />
{t.used_count} use{t.used_count !== 1 ? 's' : ''}
{t.max_uses !== null ? ` / ${t.max_uses}` : ''}
</span>
{t.expires_at ? (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(t.expires_at) > new Date()
? `Expires ${new Date(t.expires_at).toLocaleDateString()}`
: `Expired ${new Date(t.expires_at).toLocaleDateString()}`}
</span>
) : (
<span className="flex items-center gap-1"><Infinity className="h-3 w-3" /> No expiry</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-3 shrink-0">
{isActive && <CopyButton text={t.token} />}
{isActive && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleRevoke(t.id)} title="Revoke token">
<Ban className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
type Tab = 'invites' | 'enrollment'
export default function AdminPage() {
const [tab, setTab] = useState<Tab>('invites')
return (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Admin</h2>
<p className="text-muted-foreground">Manage users and agent deployment</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 p-1 rounded-lg bg-muted/50 w-fit">
{(['invites', 'enrollment'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
tab === t
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'invites' ? 'User invites' : 'Agent enrollment'}
</button>
))}
</div>
{tab === 'invites' ? <InvitesSection /> : <EnrollmentSection />}
</div>
)
}

View File

@@ -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)

View File

@@ -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() {
@@ -62,16 +47,16 @@ export default function DownloadPage() {
<h2 className="text-2xl font-bold mb-2">Download RemoteLink Agent</h2>
<p className="text-muted-foreground max-w-xl mx-auto text-balance">
Install the agent on machines you want to control remotely.
The agent runs in the background and enables secure connections.
It connects back to your server and waits for a viewer session.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{platforms.map((platform) => (
<Card key={platform.name} className="border-border/50">
<Card key={platform.name} className={`border-border/50 ${!platform.available ? 'opacity-60' : ''}`}>
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<platform.icon className="h-7 w-7 text-primary" />
<div className={`mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-xl ${platform.available ? 'bg-primary/10' : 'bg-muted'}`}>
<platform.icon className={`h-7 w-7 ${platform.available ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<CardTitle>{platform.name}</CardTitle>
<CardDescription>{platform.description}</CardDescription>
@@ -79,15 +64,26 @@ export default function DownloadPage() {
<CardContent className="space-y-4">
<div className="text-center text-sm text-muted-foreground">
<p className="font-mono">{platform.filename}</p>
<p>{platform.size}</p>
{platform.available && 'size' in platform && <p>{platform.size}</p>}
</div>
<Button
className="w-full"
disabled={!platform.available}
>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
{platform.available && platform.downloadPath ? (
<Button className="w-full" asChild>
<a href={platform.downloadPath} download={platform.filename}>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
) : (
<Button className="w-full" disabled>
<Clock className="mr-2 h-4 w-4" />
Coming soon
</Button>
)}
{platform.note && (
<p className="text-xs text-muted-foreground text-center">{platform.note}</p>
)}
</CardContent>
</Card>
))}
@@ -118,30 +114,27 @@ export default function DownloadPage() {
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="font-medium mb-2">Windows</h4>
<h4 className="font-medium mb-2">Linux run once (portable)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download and run RemoteLink-Setup.exe</li>
<li>Follow the installation wizard</li>
<li>The agent will start automatically and appear in your system tray</li>
<li>Click the tray icon to generate a session code</li>
<li>Download <code className="bg-muted px-1 rounded">remotelink-agent-linux</code></li>
<li>Make it executable: <code className="bg-muted px-1 rounded">chmod +x remotelink-agent-linux</code></li>
<li>Get an enrollment token from <Link href="/dashboard/admin" className="text-primary underline underline-offset-2">Admin Agent enrollment</Link></li>
<li>Run: <code className="bg-muted px-1 rounded">./remotelink-agent-linux --server https://your-server --enroll YOUR_TOKEN --run-once</code></li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">macOS</h4>
<h4 className="font-medium mb-2">Linux permanent install (reconnects on reboot)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download and open RemoteLink.dmg</li>
<li>Drag RemoteLink to your Applications folder</li>
<li>Open RemoteLink from Applications</li>
<li>Grant accessibility permissions when prompted</li>
<li>Run without <code className="bg-muted px-1 rounded">--run-once</code> config is saved to <code className="bg-muted px-1 rounded">/etc/remotelink/agent.json</code></li>
<li>Create a systemd service or add to crontab with <code className="bg-muted px-1 rounded">@reboot</code></li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">Linux</h4>
<h4 className="font-medium mb-2">Windows silent mass deploy (coming soon)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download the AppImage file</li>
<li>Make it executable: <code className="bg-muted px-1 rounded">chmod +x remotelink-agent.AppImage</code></li>
<li>Run the AppImage</li>
<li>The agent will appear in your system tray</li>
<li>Build <code className="bg-muted px-1 rounded">RemoteLink-Setup.exe</code> on a Windows machine using the NSIS installer script in the agent source</li>
<li>Deploy silently: <code className="bg-muted px-1 rounded">RemoteLink-Setup.exe /S /SERVER=https://your-server /ENROLL=YOUR_TOKEN</code></li>
<li>The installer registers a Windows Service that auto-starts on boot</li>
</ol>
</div>
</CardContent>

View File

@@ -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 })

View File

@@ -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,
})
}

View File

@@ -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 })
}

View File

@@ -39,7 +39,7 @@ export default function AuthErrorPage() {
</Link>
</Button>
<Button asChild className="flex-1">
<Link href="/auth/sign-up">
<Link href="/auth/login">
Try again
</Link>
</Button>

View File

@@ -20,15 +20,15 @@ export default function SignUpSuccessPage() {
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-2xl">Check your email</CardTitle>
<CardTitle className="text-2xl">Account created</CardTitle>
<CardDescription className="text-balance">
{"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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg bg-muted/50 p-4 text-sm text-muted-foreground">
<p className="text-balance">
{"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.
</p>
</div>
<Button asChild className="w-full">

View File

@@ -1,15 +1,9 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Maximize2,
Minimize2,
Monitor,
Loader2,
AlertCircle,
} from 'lucide-react'
import { Maximize2, Minimize2, Monitor, Loader2, AlertCircle, WifiOff } from 'lucide-react'
import { ViewerToolbar } from '@/components/viewer/toolbar'
import { ConnectionStatus } from '@/components/viewer/connection-status'
@@ -22,12 +16,15 @@ interface Session {
connectionType: string | null
}
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
type ConnectionState = 'connecting' | 'waiting' | 'connected' | 'disconnected' | 'error'
export default function ViewerPage() {
const params = useParams()
const searchParams = useSearchParams()
const router = useRouter()
const sessionId = params.sessionId as string
// viewerToken is passed as a query param from the connect flow
const viewerToken = searchParams.get('token')
const [session, setSession] = useState<Session | null>(null)
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting')
@@ -36,10 +33,16 @@ export default function ViewerPage() {
const [quality, setQuality] = useState<'high' | 'medium' | 'low'>('high')
const [isMuted, setIsMuted] = useState(true)
const [error, setError] = useState<string | null>(null)
const [statusMsg, setStatusMsg] = useState('Connecting to relay…')
const [fps, setFps] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const fpsCounterRef = useRef({ frames: 0, lastTime: Date.now() })
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// ── Load session info ──────────────────────────────────────────────────────
useEffect(() => {
fetch(`/api/sessions/${sessionId}`)
.then((r) => r.json())
@@ -55,7 +58,6 @@ export default function ViewerPage() {
return
}
setSession(data.session)
simulateConnection()
})
.catch(() => {
setError('Failed to load session')
@@ -63,109 +65,171 @@ export default function ViewerPage() {
})
}, [sessionId])
const simulateConnection = () => {
// ── WebSocket connection ───────────────────────────────────────────────────
const connectWS = useCallback(() => {
if (!viewerToken) return
const relayHost = process.env.NEXT_PUBLIC_RELAY_URL ||
`${window.location.hostname}:8765`
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const wsUrl = `${proto}://${relayHost}/ws/viewer?session_id=${sessionId}&viewer_token=${viewerToken}`
setConnectionState('connecting')
setTimeout(() => {
setConnectionState('connected')
startDemoScreen()
}, 2000)
}
setStatusMsg('Connecting to relay…')
const startDemoScreen = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
wsRef.current = ws
canvas.width = 1920
canvas.height = 1080
const draw = () => {
ctx.fillStyle = '#1e1e2e'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const icons = [
{ x: 50, y: 50, label: 'Documents' },
{ x: 50, y: 150, label: 'Pictures' },
{ x: 50, y: 250, label: 'Downloads' },
]
ctx.font = '14px system-ui'
ctx.textAlign = 'center'
icons.forEach((icon) => {
ctx.fillStyle = '#313244'
ctx.fillRect(icon.x, icon.y, 60, 60)
ctx.fillStyle = '#cdd6f4'
ctx.fillText(icon.label, icon.x + 30, icon.y + 80)
})
ctx.fillStyle = '#181825'
ctx.fillRect(0, canvas.height - 48, canvas.width, 48)
ctx.fillStyle = '#89b4fa'
ctx.fillRect(10, canvas.height - 40, 40, 32)
const time = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
ctx.fillStyle = '#cdd6f4'
ctx.font = '14px system-ui'
ctx.textAlign = 'right'
ctx.fillText(time, canvas.width - 20, canvas.height - 18)
ctx.fillStyle = '#1e1e2e'
ctx.fillRect(300, 100, 800, 500)
ctx.strokeStyle = '#313244'
ctx.lineWidth = 1
ctx.strokeRect(300, 100, 800, 500)
ctx.fillStyle = '#181825'
ctx.fillRect(300, 100, 800, 32)
ctx.fillStyle = '#cdd6f4'
ctx.font = '13px system-ui'
ctx.textAlign = 'left'
ctx.fillText('RemoteLink Agent - Connected', 312, 121)
ctx.fillStyle = '#a6adc8'
ctx.font = '16px system-ui'
ctx.fillText('Remote session active', 320, 180)
ctx.fillText('Connection: Secure (WebRTC)', 320, 210)
ctx.fillText('Latency: ~45ms', 320, 240)
ctx.fillStyle = '#a6e3a1'
ctx.beginPath()
ctx.arc(320, 280, 6, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#cdd6f4'
ctx.fillText('Connected to viewer', 335, 285)
ws.onopen = () => {
setStatusMsg('Waiting for agent…')
setConnectionState('waiting')
}
draw()
const interval = setInterval(draw, 1000)
return () => clearInterval(interval)
ws.onmessage = (evt) => {
if (evt.data instanceof ArrayBuffer) {
// Binary = JPEG frame
renderFrame(evt.data)
} else {
try {
const msg = JSON.parse(evt.data as string)
handleRelayMessage(msg)
} catch { /* ignore */ }
}
}
ws.onclose = (evt) => {
wsRef.current = null
if (connectionState !== 'disconnected' && evt.code !== 4001) {
setConnectionState('waiting')
setStatusMsg('Connection lost — reconnecting…')
reconnectTimerRef.current = setTimeout(connectWS, 3000)
}
}
ws.onerror = () => {
setStatusMsg('Relay connection failed')
}
}, [sessionId, viewerToken])
useEffect(() => {
if (session && viewerToken) {
connectWS()
}
return () => {
wsRef.current?.close()
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
}
}, [session, viewerToken, connectWS])
const handleRelayMessage = (msg: Record<string, unknown>) => {
switch (msg.type) {
case 'agent_connected':
setConnectionState('connected')
setStatusMsg('')
break
case 'agent_offline':
setConnectionState('waiting')
setStatusMsg('Agent is offline — waiting for connection…')
break
case 'agent_disconnected':
setConnectionState('waiting')
setStatusMsg('Agent disconnected — waiting for reconnect…')
break
}
}
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (connectionState !== 'connected') return
console.log('[Viewer] Key pressed:', e.key)
},
[connectionState]
)
// ── Frame rendering ────────────────────────────────────────────────────────
const renderFrame = useCallback((data: ArrayBuffer) => {
const canvas = canvasRef.current
if (!canvas) return
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const _x = ((e.clientX - rect.left) * canvas.width) / rect.width
const _y = ((e.clientY - rect.top) * canvas.height) / rect.height
},
[connectionState]
)
const blob = new Blob([data], { type: 'image/jpeg' })
const url = URL.createObjectURL(blob)
const img = new Image()
img.onload = () => {
const ctx = canvas.getContext('2d')
if (!ctx) return
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width
canvas.height = img.height
}
ctx.drawImage(img, 0, 0)
URL.revokeObjectURL(url)
const handleMouseClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
console.log('[Viewer] Mouse clicked:', e.button)
},
[connectionState]
)
// FPS counter
const counter = fpsCounterRef.current
counter.frames++
const now = Date.now()
if (now - counter.lastTime >= 1000) {
setFps(counter.frames)
counter.frames = 0
counter.lastTime = now
}
}
img.src = url
}, [])
// ── Input forwarding ───────────────────────────────────────────────────────
const sendEvent = useCallback((event: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(event))
}
}, [])
const getCanvasCoords = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!
const rect = canvas.getBoundingClientRect()
return {
x: Math.round(((e.clientX - rect.left) * canvas.width) / rect.width),
y: Math.round(((e.clientY - rect.top) * canvas.height) / rect.height),
}
}, [])
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
const { x, y } = getCanvasCoords(e)
sendEvent({ type: 'mouse_move', x, y })
}, [connectionState, sendEvent, getCanvasCoords])
const handleMouseClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
const { x, y } = getCanvasCoords(e)
sendEvent({
type: 'mouse_click',
button: e.button === 2 ? 'right' : 'left',
double: e.detail === 2,
x, y,
})
}, [connectionState, sendEvent, getCanvasCoords])
const handleMouseScroll = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
e.preventDefault()
sendEvent({ type: 'mouse_scroll', dx: e.deltaX, dy: -e.deltaY })
}, [connectionState, sendEvent])
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (connectionState !== 'connected') return
e.preventDefault()
const specialKeys = [
'Enter', 'Escape', 'Tab', 'Backspace', 'Delete', 'Home', 'End',
'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12',
]
if (specialKeys.includes(e.key)) {
sendEvent({ type: 'key_special', key: e.key.toLowerCase().replace('arrow', '') })
} else if (e.key.length === 1) {
sendEvent({ type: 'key_press', key: e.key })
}
}, [connectionState, sendEvent])
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
// ── Fullscreen / toolbar auto-hide ─────────────────────────────────────────
const toggleFullscreen = async () => {
if (!containerRef.current) return
if (!document.fullscreenElement) {
@@ -177,7 +241,16 @@ export default function ViewerPage() {
}
}
useEffect(() => {
if (isFullscreen && connectionState === 'connected') {
const timer = setTimeout(() => setShowToolbar(false), 3000)
return () => clearTimeout(timer)
}
}, [isFullscreen, connectionState, showToolbar])
// ── End session ────────────────────────────────────────────────────────────
const endSession = async () => {
wsRef.current?.close()
if (session) {
const duration = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000)
await fetch(`/api/sessions/${session.id}`, {
@@ -190,18 +263,7 @@ export default function ViewerPage() {
router.push('/dashboard/sessions')
}
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
useEffect(() => {
if (isFullscreen && connectionState === 'connected') {
const timer = setTimeout(() => setShowToolbar(false), 3000)
return () => clearTimeout(timer)
}
}, [isFullscreen, connectionState, showToolbar])
// ── Render ─────────────────────────────────────────────────────────────────
if (error) {
return (
<div className="min-h-svh bg-background flex items-center justify-center">
@@ -228,21 +290,26 @@ export default function ViewerPage() {
quality={quality}
isMuted={isMuted}
onToggleFullscreen={toggleFullscreen}
onQualityChange={setQuality}
onQualityChange={(q) => {
setQuality(q)
sendEvent({ type: 'set_quality', quality: q })
}}
onToggleMute={() => setIsMuted(!isMuted)}
onDisconnect={endSession}
onReconnect={simulateConnection}
onReconnect={connectWS}
/>
</div>
<div className="flex-1 flex items-center justify-center relative">
{connectionState === 'connecting' && (
{(connectionState === 'connecting' || connectionState === 'waiting') && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center space-y-4">
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
{connectionState === 'waiting'
? <WifiOff className="h-12 w-12 text-muted-foreground mx-auto" />
: <Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />}
<div>
<p className="font-semibold">Connecting to remote machine...</p>
<p className="text-sm text-muted-foreground">{session?.machineName || 'Establishing connection'}</p>
<p className="font-semibold">{statusMsg}</p>
<p className="text-sm text-muted-foreground">{session?.machineName}</p>
</div>
</div>
</div>
@@ -250,13 +317,14 @@ export default function ViewerPage() {
<canvas
ref={canvasRef}
className="max-w-full max-h-full object-contain cursor-default"
className="max-w-full max-h-full object-contain cursor-crosshair"
style={{
display: connectionState === 'connected' ? 'block' : 'none',
imageRendering: quality === 'low' ? 'pixelated' : 'auto',
}}
onMouseMove={handleMouseMove}
onClick={handleMouseClick}
onWheel={handleMouseScroll}
onContextMenu={(e) => { e.preventDefault(); handleMouseClick(e) }}
/>
@@ -272,7 +340,7 @@ export default function ViewerPage() {
)}
</div>
{connectionState === 'connected' && <ConnectionStatus quality={quality} />}
{connectionState === 'connected' && <ConnectionStatus quality={quality} fps={fps} />}
</div>
)
}

View File

@@ -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 (
<div className="flex items-center justify-center gap-6 h-8 bg-card/80 backdrop-blur border-t border-border/50 text-xs text-muted-foreground">

View File

@@ -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);

View File

@@ -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:

View File

@@ -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

Binary file not shown.

12
relay/Dockerfile Normal file
View File

@@ -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"]

220
relay/main.py Normal file
View File

@@ -0,0 +1,220 @@
"""
RemoteLink WebSocket Relay
Bridges agent (remote machine) ↔ viewer (browser) connections.
Agent connects: ws://relay:8765/ws/agent?machine_id=<uuid>&access_key=<hex>
Viewer connects: ws://relay:8765/ws/viewer?session_id=<uuid>&viewer_token=<uuid>
"""
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}

3
relay/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
asyncpg==0.30.0