| | import asyncio |
| | import json |
| | import os |
| | import shutil |
| | import subprocess |
| | import time |
| | import logging |
| | from aiohttp import web |
| | from aiortc import RTCPeerConnection, RTCSessionDescription, RTCIceServer, RTCConfiguration |
| |
|
| | |
| | HOST = "0.0.0.0" |
| | PORT = 7860 |
| | DISPLAY_NUM = ":99" |
| | VNC_PORT = 5900 |
| |
|
| | |
| | DEFAULT_WIDTH = 1280 |
| | DEFAULT_HEIGHT = 720 |
| |
|
| | |
| | TURN_USER = "g08abe68c81a07f098bb5f0914549bb32440e5aad0b216c7fba2b61e76fd62c6" |
| | TURN_PASS = "aed1a10dd10eba9401ad9d99e5c66036d8a970eab5ba8e6dc9845ab57c771a7d" |
| |
|
| | logging.basicConfig(level=logging.INFO) |
| | logger = logging.getLogger("VNC-RTC-Bridge") |
| |
|
| | |
| |
|
| | def start_system(): |
| | """Initializes the virtual display environment and VNC server.""" |
| | os.environ["DISPLAY"] = DISPLAY_NUM |
| | |
| | |
| | logger.info(f"Starting Xvfb on {DISPLAY_NUM}...") |
| | subprocess.Popen([ |
| | "Xvfb", DISPLAY_NUM, |
| | "-screen", "0", f"{DEFAULT_WIDTH}x{DEFAULT_HEIGHT}x24", |
| | "-ac", "-noreset" |
| | ]) |
| | time.sleep(2) |
| |
|
| | |
| | |
| | |
| | |
| | |
| | logger.info(f"Starting x11vnc on port {VNC_PORT}...") |
| | subprocess.Popen([ |
| | "x11vnc", "-display", DISPLAY_NUM, |
| | "-rfbport", str(VNC_PORT), |
| | "-forever", "-shared", "-nopw", |
| | "-bg", "-quiet", "-xkb" |
| | ]) |
| |
|
| | |
| | if shutil.which("matchbox-window-manager"): |
| | logger.info("Starting Matchbox Window Manager...") |
| | subprocess.Popen("matchbox-window-manager -use_titlebar no", shell=True) |
| |
|
| | |
| | logger.info("Starting Opera Browser...") |
| | opera_cmd = ( |
| | "opera " |
| | "--no-sandbox " |
| | "--start-maximized " |
| | "--user-data-dir=/home/user/opera-data " |
| | "--disable-infobars " |
| | "--disable-dev-shm-usage " |
| | "--disable-gpu " |
| | "--no-first-run " |
| | ) |
| | subprocess.Popen(opera_cmd, shell=True) |
| |
|
| | |
| |
|
| | async def bridge_vnc_to_datachannel(channel): |
| | """ |
| | Connects to the local VNC TCP port and pipes data bidirectionally |
| | to the WebRTC DataChannel. |
| | """ |
| | retry_count = 5 |
| | reader, writer = None, None |
| |
|
| | |
| | while retry_count > 0: |
| | try: |
| | reader, writer = await asyncio.open_connection('127.0.0.1', VNC_PORT) |
| | break |
| | except ConnectionRefusedError: |
| | retry_count -= 1 |
| | logger.warning(f"VNC connection refused, retrying... ({retry_count} left)") |
| | await asyncio.sleep(1) |
| | |
| | if not writer: |
| | logger.error("Could not connect to local VNC server.") |
| | channel.close() |
| | return |
| |
|
| | logger.info("Successfully bridged DataChannel to local VNC.") |
| |
|
| | |
| | async def vnc_to_webrtc(): |
| | try: |
| | while True: |
| | data = await reader.read(16384) |
| | if not data: |
| | break |
| | channel.send(data) |
| | except Exception as e: |
| | logger.error(f"VNC to WebRTC Bridge error: {e}") |
| | finally: |
| | logger.info("VNC Socket closed.") |
| |
|
| | |
| | @channel.on("message") |
| | def on_message(message): |
| | if isinstance(message, bytes): |
| | |
| | writer.write(message) |
| | elif isinstance(message, str): |
| | |
| | try: |
| | data = json.loads(message) |
| | if data.get("type") == "resize": |
| | w, h = data["width"], data["height"] |
| | subprocess.run(["xrandr", "--fb", f"{w}x{h}"], check=False) |
| | except: |
| | pass |
| |
|
| | try: |
| | await vnc_to_webrtc() |
| | finally: |
| | writer.close() |
| | await writer.wait_closed() |
| |
|
| | |
| |
|
| | pcs = set() |
| |
|
| | async def offer(request): |
| | """Handles the WebRTC signaling offer.""" |
| | try: |
| | params = await request.json() |
| | offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"]) |
| | except Exception as e: |
| | return web.Response(status=400, text=str(e)) |
| |
|
| | |
| | pc = RTCPeerConnection(RTCConfiguration(iceServers=[ |
| | RTCIceServer(urls=["turns:turn.cloudflare.com:443?transport=tcp", "turn:turn.cloudflare.com:3478?transport=udp"], |
| | username=TURN_USER, credential=TURN_PASS), |
| | RTCIceServer(urls=["stun:stun.l.google.com:19302"]) |
| | ])) |
| | pcs.add(pc) |
| |
|
| | @pc.on("connectionstatechange") |
| | async def on_state(): |
| | if pc.connectionState in ["failed", "closed"]: |
| | await pc.close() |
| | pcs.discard(pc) |
| |
|
| | @pc.on("datachannel") |
| | def on_dc(channel): |
| | |
| | asyncio.create_task(bridge_vnc_to_datachannel(channel)) |
| |
|
| | await pc.setRemoteDescription(offer) |
| | answer = await pc.createAnswer() |
| | await pc.setLocalDescription(answer) |
| |
|
| | return web.Response( |
| | content_type="application/json", |
| | text=json.dumps({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}), |
| | headers={"Access-Control-Allow-Origin": "*"} |
| | ) |
| |
|
| | async def options(request): |
| | """CORS preflight handler.""" |
| | return web.Response(headers={ |
| | "Access-Control-Allow-Origin": "*", |
| | "Access-Control-Allow-Methods": "POST, OPTIONS", |
| | "Access-Control-Allow-Headers": "Content-Type" |
| | }) |
| |
|
| | async def on_shutdown(app): |
| | """Clean up PeerConnections on server stop.""" |
| | coros = [pc.close() for pc in pcs] |
| | await asyncio.gather(*coros) |
| | pcs.clear() |
| |
|
| | |
| |
|
| | if __name__ == "__main__": |
| | |
| | start_system() |
| |
|
| | |
| | app = web.Application() |
| | app.on_shutdown.append(on_shutdown) |
| | |
| | app.router.add_get("/", lambda r: web.Response(text="WebRTC VNC Bridge is running.")) |
| | app.router.add_post("/offer", offer) |
| | app.router.add_options("/offer", options) |
| | |
| | web.run_app(app, host=HOST, port=PORT) |