296 lines
11 KiB
Python
296 lines
11 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Token-auth desktop control for the Rakazo supervisor."""
|
||
|
|
|
||
|
|
import base64
|
||
|
|
import ctypes
|
||
|
|
import hmac
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
|
|
||
|
|
TOKEN = os.environ.get("RAKAZO_COMPUTER_CONTROL_TOKEN", "")
|
||
|
|
MAX_BODY_BYTES = 256 * 1024
|
||
|
|
MAX_ARGV = 32
|
||
|
|
MAX_ARG_LEN = 16_384
|
||
|
|
KNOWN_LAUNCH = frozenset(
|
||
|
|
{
|
||
|
|
"rakazo-browser",
|
||
|
|
"xterm",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
CONTROL_TIMEOUT_SEC = 10
|
||
|
|
LAUNCH_SPAWN_POLL_SEC = 0.2
|
||
|
|
NATIVE_CAPTURES = {}
|
||
|
|
NATIVE_LOCK = threading.Lock()
|
||
|
|
DISPLAY_LOCKS = {}
|
||
|
|
DISPLAY_LOCKS_GUARD = threading.Lock()
|
||
|
|
|
||
|
|
|
||
|
|
def display_lock(display):
|
||
|
|
with DISPLAY_LOCKS_GUARD:
|
||
|
|
lock = DISPLAY_LOCKS.get(display)
|
||
|
|
if lock is None:
|
||
|
|
lock = threading.Lock()
|
||
|
|
DISPLAY_LOCKS[display] = lock
|
||
|
|
return lock
|
||
|
|
|
||
|
|
|
||
|
|
class NativeCapture:
|
||
|
|
"""Persistent MIT-SHM frame source with native lossless PNG encoding."""
|
||
|
|
|
||
|
|
def __init__(self, display):
|
||
|
|
library = ctypes.CDLL("/usr/local/lib/librakazo-xcapture.so")
|
||
|
|
library.rakazo_xcapture_open.argtypes = [ctypes.c_char_p]
|
||
|
|
library.rakazo_xcapture_open.restype = ctypes.c_void_p
|
||
|
|
library.rakazo_xcapture_png.argtypes = [
|
||
|
|
ctypes.c_void_p,
|
||
|
|
ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)),
|
||
|
|
ctypes.POINTER(ctypes.c_size_t),
|
||
|
|
ctypes.POINTER(ctypes.c_int),
|
||
|
|
ctypes.POINTER(ctypes.c_int),
|
||
|
|
]
|
||
|
|
library.rakazo_xcapture_png.restype = ctypes.c_int
|
||
|
|
library.rakazo_xcapture_damage.argtypes = [
|
||
|
|
ctypes.c_void_p,
|
||
|
|
ctypes.POINTER(ctypes.c_int),
|
||
|
|
ctypes.POINTER(ctypes.c_int),
|
||
|
|
ctypes.POINTER(ctypes.c_int),
|
||
|
|
ctypes.POINTER(ctypes.c_int),
|
||
|
|
]
|
||
|
|
library.rakazo_xcapture_damage.restype = ctypes.c_int
|
||
|
|
library.rakazo_xinput_argv.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_char_p)]
|
||
|
|
library.rakazo_xinput_argv.restype = ctypes.c_int
|
||
|
|
context = library.rakazo_xcapture_open(display.encode("utf-8"))
|
||
|
|
if not context:
|
||
|
|
raise RuntimeError("MIT-SHM capture is unavailable")
|
||
|
|
self.library = library
|
||
|
|
self.context = context
|
||
|
|
|
||
|
|
def copy(self):
|
||
|
|
png = ctypes.POINTER(ctypes.c_ubyte)()
|
||
|
|
png_size = ctypes.c_size_t()
|
||
|
|
width, height = ctypes.c_int(), ctypes.c_int()
|
||
|
|
if self.library.rakazo_xcapture_png(
|
||
|
|
self.context, ctypes.byref(png), ctypes.byref(png_size), ctypes.byref(width), ctypes.byref(height)
|
||
|
|
):
|
||
|
|
raise RuntimeError("MIT-SHM screen capture failed")
|
||
|
|
damage = (ctypes.c_int(), ctypes.c_int(), ctypes.c_int(), ctypes.c_int())
|
||
|
|
changed = self.library.rakazo_xcapture_damage(
|
||
|
|
self.context, *(ctypes.byref(value) for value in damage)
|
||
|
|
)
|
||
|
|
return (
|
||
|
|
ctypes.string_at(png, png_size.value),
|
||
|
|
width.value,
|
||
|
|
height.value,
|
||
|
|
({"x": damage[0].value, "y": damage[1].value, "width": damage[2].value, "height": damage[3].value}
|
||
|
|
if changed else None),
|
||
|
|
)
|
||
|
|
|
||
|
|
def act(self, argv):
|
||
|
|
encoded = (ctypes.c_char_p * len(argv))(*(value.encode("utf-8") for value in argv))
|
||
|
|
return self.library.rakazo_xinput_argv(self.context, len(argv), encoded)
|
||
|
|
|
||
|
|
|
||
|
|
def native_capture(display):
|
||
|
|
existing = NATIVE_CAPTURES.get(display)
|
||
|
|
if existing is not None:
|
||
|
|
return existing
|
||
|
|
try:
|
||
|
|
capture = NativeCapture(display)
|
||
|
|
except (OSError, RuntimeError):
|
||
|
|
return None
|
||
|
|
NATIVE_CAPTURES[display] = capture
|
||
|
|
return capture
|
||
|
|
|
||
|
|
|
||
|
|
def drop_native_capture(display):
|
||
|
|
NATIVE_CAPTURES.pop(display, None)
|
||
|
|
|
||
|
|
|
||
|
|
def _is_int_string(value):
|
||
|
|
if not value or value[0] == "-":
|
||
|
|
return value[1:].isdigit() if len(value) > 1 else False
|
||
|
|
return value.isdigit()
|
||
|
|
|
||
|
|
|
||
|
|
def allowed_xdotool_argv(argv):
|
||
|
|
"""Only xdotool forms emitted by containerActionStep / xdotoolCommand."""
|
||
|
|
if len(argv) < 4 or argv[2] != "xdotool":
|
||
|
|
return False
|
||
|
|
op = argv[3]
|
||
|
|
if op == "key":
|
||
|
|
return len(argv) == 6 and argv[4] == "--clearmodifiers" and argv[5] != ""
|
||
|
|
if op == "mousemove":
|
||
|
|
if len(argv) == 7 and argv[4] == "--" and _is_int_string(argv[5]) and _is_int_string(argv[6]):
|
||
|
|
return True
|
||
|
|
return (
|
||
|
|
len(argv) == 9
|
||
|
|
and argv[4] == "--"
|
||
|
|
and _is_int_string(argv[5])
|
||
|
|
and _is_int_string(argv[6])
|
||
|
|
and argv[7] in ("mousedown", "click")
|
||
|
|
and argv[8] in ("1", "3")
|
||
|
|
)
|
||
|
|
if op == "mouseup":
|
||
|
|
return len(argv) == 5 and argv[4] in ("1", "3")
|
||
|
|
if op == "type":
|
||
|
|
return len(argv) == 7 and argv[4] == "--clearmodifiers" and argv[5] == "--"
|
||
|
|
if op == "click":
|
||
|
|
return (
|
||
|
|
len(argv) == 7
|
||
|
|
and argv[4] == "--repeat"
|
||
|
|
and argv[5].isdigit()
|
||
|
|
and 1 <= int(argv[5]) <= 20
|
||
|
|
and argv[6] in ("4", "5")
|
||
|
|
)
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def allowed_control_argv(argv, display):
|
||
|
|
"""Only supervisor-shaped argv for the locked display."""
|
||
|
|
if not isinstance(argv, list) or not (3 <= len(argv) <= MAX_ARGV):
|
||
|
|
return False
|
||
|
|
if any(not isinstance(value, str) or len(value) > MAX_ARG_LEN or "\0" in value for value in argv):
|
||
|
|
return False
|
||
|
|
if argv[0] != "env" or argv[1] != f"DISPLAY={display}":
|
||
|
|
return False
|
||
|
|
command = argv[2]
|
||
|
|
if command == "xdotool":
|
||
|
|
return allowed_xdotool_argv(argv)
|
||
|
|
if command == "xdg-open":
|
||
|
|
return len(argv) == 4
|
||
|
|
if "/" in command or command not in KNOWN_LAUNCH:
|
||
|
|
return False
|
||
|
|
return len(argv) in (3, 4)
|
||
|
|
|
||
|
|
|
||
|
|
def is_long_lived_control(argv):
|
||
|
|
"""Apps and openers that must not be waited on under display_lock."""
|
||
|
|
command = argv[2]
|
||
|
|
return command == "xdg-open" or command in KNOWN_LAUNCH
|
||
|
|
|
||
|
|
|
||
|
|
def run_control_argv(argv, display):
|
||
|
|
"""Run a fallback control command without holding the lock forever."""
|
||
|
|
env = {**os.environ, "DISPLAY": display}
|
||
|
|
if is_long_lived_control(argv):
|
||
|
|
child = subprocess.Popen(argv, env=env, start_new_session=True)
|
||
|
|
try:
|
||
|
|
code = child.wait(timeout=LAUNCH_SPAWN_POLL_SEC)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
threading.Thread(target=child.wait, daemon=True).start()
|
||
|
|
return
|
||
|
|
if code:
|
||
|
|
raise RuntimeError("computer action failed")
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
result = subprocess.run(argv, env=env, timeout=CONTROL_TIMEOUT_SEC)
|
||
|
|
except subprocess.TimeoutExpired as error:
|
||
|
|
raise RuntimeError("computer action timed out") from error
|
||
|
|
if result.returncode:
|
||
|
|
raise RuntimeError("computer action failed")
|
||
|
|
|
||
|
|
|
||
|
|
def capture(display):
|
||
|
|
env = {**os.environ, "DISPLAY": display}
|
||
|
|
|
||
|
|
def output(argv, fallback=""):
|
||
|
|
return subprocess.run(argv, env=env, capture_output=True, text=True).stdout.strip() or fallback
|
||
|
|
|
||
|
|
geometry = output(["xdotool", "getdisplaygeometry"], "1280 800").split()
|
||
|
|
cursor = output(["xdotool", "getmouselocation", "--shell"])
|
||
|
|
window = output(["xdotool", "getactivewindow"])
|
||
|
|
title = output(["xdotool", "getwindowname", window]) if window else ""
|
||
|
|
image = None
|
||
|
|
width = height = damage = None
|
||
|
|
with NATIVE_LOCK:
|
||
|
|
source = native_capture(display)
|
||
|
|
if source:
|
||
|
|
try:
|
||
|
|
encoded, width, height, damage = source.copy()
|
||
|
|
image = subprocess.CompletedProcess([], 0, encoded, b"")
|
||
|
|
except RuntimeError:
|
||
|
|
drop_native_capture(display)
|
||
|
|
if image is None:
|
||
|
|
image = subprocess.run(
|
||
|
|
["import", "-define", "png:compression-level=3", "-window", "root", "png:-"],
|
||
|
|
env=env,
|
||
|
|
capture_output=True,
|
||
|
|
)
|
||
|
|
width, height, damage = (int(geometry[0]), int(geometry[1]), None)
|
||
|
|
if image.returncode:
|
||
|
|
raise RuntimeError(image.stderr.decode("utf-8", "replace") or "screen capture failed")
|
||
|
|
fields = dict(line.split("=", 1) for line in cursor.splitlines() if "=" in line)
|
||
|
|
return {
|
||
|
|
"image": base64.b64encode(image.stdout).decode("ascii"),
|
||
|
|
"mimeType": "image/png",
|
||
|
|
"width": width,
|
||
|
|
"height": height,
|
||
|
|
**({"cursor": {"x": int(fields["X"]), "y": int(fields["Y"])}} if "X" in fields and "Y" in fields else {}),
|
||
|
|
**({"activeWindow": {"id": window, **({"title": title} if title else {})}} if window else {}),
|
||
|
|
**({"damage": damage} if damage else {}),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class Handler(BaseHTTPRequestHandler):
|
||
|
|
def log_message(self, *_args):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def do_POST(self):
|
||
|
|
if self.path != "/v1/desktop" or not TOKEN or not hmac.compare_digest(
|
||
|
|
self.headers.get("Authorization", "").removeprefix("Bearer "), TOKEN
|
||
|
|
):
|
||
|
|
self.send_error(401)
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
length = int(self.headers.get("Content-Length", "0"))
|
||
|
|
if length < 0 or length > MAX_BODY_BYTES:
|
||
|
|
raise RuntimeError("request body too large")
|
||
|
|
body = json.loads(self.rfile.read(length))
|
||
|
|
display = body.get("display", ":1")
|
||
|
|
if not isinstance(display, str) or not re.fullmatch(r":[0-9]+", display):
|
||
|
|
raise RuntimeError("invalid display")
|
||
|
|
with display_lock(display):
|
||
|
|
for step in body.get("steps", []):
|
||
|
|
if "waitMs" in step:
|
||
|
|
time.sleep(max(0, min(int(step["waitMs"]), 5000)) / 1000)
|
||
|
|
continue
|
||
|
|
argv = step.get("argv")
|
||
|
|
if not allowed_control_argv(argv, display):
|
||
|
|
raise RuntimeError("unsupported computer action")
|
||
|
|
with NATIVE_LOCK:
|
||
|
|
source = native_capture(display)
|
||
|
|
handled = source.act(argv) if source else 0
|
||
|
|
if handled < 0:
|
||
|
|
drop_native_capture(display)
|
||
|
|
handled = 0
|
||
|
|
if not handled:
|
||
|
|
run_control_argv(argv, display)
|
||
|
|
settle_ms = max(0, min(int(body.get("settleMs", 0)), 5000))
|
||
|
|
if settle_ms:
|
||
|
|
time.sleep(settle_ms / 1000)
|
||
|
|
response = {"completed": len(body.get("steps", []))}
|
||
|
|
if body.get("observe", True):
|
||
|
|
response["observation"] = capture(display)
|
||
|
|
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8")
|
||
|
|
self.send_response(200)
|
||
|
|
self.send_header("Content-Type", "application/json")
|
||
|
|
self.send_header("Content-Length", str(len(encoded)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(encoded)
|
||
|
|
except Exception as error:
|
||
|
|
encoded = json.dumps({"error": str(error)}).encode("utf-8")
|
||
|
|
self.send_response(500)
|
||
|
|
self.send_header("Content-Type", "application/json")
|
||
|
|
self.send_header("Content-Length", str(len(encoded)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(encoded)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
ThreadingHTTPServer(("0.0.0.0", 7070), Handler).serve_forever()
|