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:
66
agent/README.md
Normal file
66
agent/README.md
Normal 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
438
agent/agent.py
Normal 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
110
agent/agent.spec
Normal 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
34
agent/build.sh
Normal 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
116
agent/installer.nsi
Normal 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
|
||||
42
agent/remotelink-agent.spec
Normal file
42
agent/remotelink-agent.spec
Normal 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
10
agent/requirements.txt
Normal 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
98
agent/service_win.py
Normal 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)
|
||||
Reference in New Issue
Block a user