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