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

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)