vdi-cloud/terminal/native-cursor/host.py

300 lines
13 KiB
Python

#!/usr/bin/env python3
"""Authenticated single-seat Linux cursor lease; run by a restricted SSH key.
The guard owns the Sunshine lifecycle. Cursor-free capture starts BEFORE the
new video connection; ending the lease stops that capture process before
restoring normal capture. There is deliberately no mid-stream mode switch.
"""
import base64
import ctypes as C
import fcntl
import hashlib
import json
import os
from pathlib import Path
import select
import signal
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parent
RUN = Path('/run/user/%d/vdi-native-cursor' % os.getuid())
DROP = RUN.parent / 'systemd/user/vdi-sunshine.service.d/90-native-cursor.conf'
SERVICE = 'vdi-sunshine.service'
GUARD = 'vdi-native-cursor-guard.service'
TTL = 4.0
def systemctl(*args):
return subprocess.run(['systemctl', '--user'] + list(args), check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=20).stdout.decode().strip()
def recover():
# Stop the cursor-free producer FIRST. A new normal producer cannot resume
# an old Moonlight connection, so lost SSH never creates a double cursor.
if DROP.exists():
systemctl('stop', SERVICE)
DROP.unlink()
systemctl('daemon-reload')
systemctl('start', SERVICE)
for name in ('lease', 'ready', 'receipt'):
(RUN / name).unlink(missing_ok=True)
def guard():
lease = (RUN / 'lease').read_text().strip()
if len(lease) != 32 or any(c not in '0123456789abcdef' for c in lease):
raise ValueError('invalid lease')
manifest = json.loads((ROOT / 'manifest.json').read_text())
for name, expected in manifest.items():
if name not in ('sunshine', 'capture-no-cursor.so') or hashlib.sha256(
(ROOT / name).read_bytes()).hexdigest() != expected:
raise RuntimeError('pinned capture build changed')
kms_config = ROOT / 'kms.json'
scanout = None
def checked_scanout(timeout=3):
deadline = time.monotonic() + timeout
last_error = None
while True:
try:
return scanout.check()
except RuntimeError as exc:
last_error = exc
if time.monotonic() >= deadline:
raise last_error
time.sleep(.1)
if kms_config.exists():
from kms import VirtioScanout
scanout = VirtioScanout(json.loads(kms_config.read_text()))
# Wake an idle output before inspecting its active scanout. This does
# not unlock the session or disable the user's screen lock.
subprocess.run(['/usr/bin/xset', 'dpms', 'force', 'on'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
if hashlib.sha256(Path('/usr/bin/sunshine').read_bytes()).hexdigest() != manifest['sunshine']:
raise RuntimeError('pinned system capture build changed')
systemctl('stop', SERVICE)
DROP.parent.mkdir(parents=True, exist_ok=True)
if scanout:
# File capabilities remain on the original, verified system binary.
# Command-line override is lease-local; recover restores X11 capture.
DROP.write_text('[Service]\nExecStart=\nExecStart=/usr/bin/sunshine %s capture=kms\n' %
(Path.home() / '.config/sunshine/sunshine.conf'))
else:
DROP.write_text('[Service]\nEnvironment="LD_PRELOAD=%s" "VDI_CURSOR_LEASE=%s" '
'"VDI_CURSOR_RECEIPT=%s"\n' %
(ROOT / 'capture-no-cursor.so', lease, RUN / 'receipt') +
'ExecStart=\nExecStart=%s %s\n' %
(ROOT / 'sunshine', Path.home() / '.config/sunshine/sunshine.conf'))
systemctl('daemon-reload')
# The normal X11 producer can leave the DRM plane idle while it is being
# stopped. Install the recoverable drop-in first, then validate the
# stopped display before launching KMS. ExecStopPost restores X11 if this
# fail-closed check rejects the current output.
if scanout:
subprocess.run(['/usr/bin/xset', 'dpms', 'force', 'on'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
checked_scanout()
systemctl('start', SERVICE)
# dlsym receipt proves this exact process resolved the adapter entrypoint.
deadline = time.monotonic() + 12
while not scanout and not (RUN / 'receipt').exists():
if time.monotonic() > deadline:
raise RuntimeError('capture adapter not resolved')
time.sleep(.05)
pid = int(systemctl('show', SERVICE, '-p', 'MainPID', '--value'))
if scanout:
checked_scanout()
if b'capture=kms\0' not in Path('/proc/%d/cmdline' % pid).read_bytes():
raise RuntimeError('KMS capture override not active')
elif str(ROOT / 'capture-no-cursor.so') not in Path('/proc/%d/maps' % pid).read_text():
raise RuntimeError('capture adapter not mapped')
(RUN / 'ready').write_text(lease)
while (RUN / 'lease').exists():
if time.time() - (RUN / 'lease').stat().st_mtime > TTL:
break
if int(systemctl('show', SERVICE, '-p', 'MainPID', '--value')) != pid:
break # Never accept an encoder restart inside the same lease.
if scanout:
checked_scanout(1)
time.sleep(.15)
class Cursor(C.Structure):
_fields_ = [('x', C.c_short), ('y', C.c_short), ('w', C.c_ushort),
('h', C.c_ushort), ('hx', C.c_ushort), ('hy', C.c_ushort),
('serial', C.c_ulong), ('pixels', C.POINTER(C.c_ulong)),
('atom', C.c_ulong), ('name', C.c_char_p)]
class XCursor:
def __init__(self):
# Absolute original library path; never inherit the capture adapter.
self.x = C.CDLL('libX11.so.6')
self.f = C.CDLL('/lib/x86_64-linux-gnu/libXfixes.so.3')
self.x.XOpenDisplay.argtypes = [C.c_char_p]
self.x.XOpenDisplay.restype = C.c_void_p
self.x.XDefaultScreen.argtypes = [C.c_void_p]
self.x.XDefaultRootWindow.argtypes = [C.c_void_p]
self.x.XDefaultRootWindow.restype = C.c_ulong
self.x.XGetGeometry.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong),
C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_uint),
C.POINTER(C.c_uint), C.POINTER(C.c_uint), C.POINTER(C.c_uint)]
for name in ('XDisplayWidth', 'XDisplayHeight'):
getattr(self.x, name).argtypes = [C.c_void_p, C.c_int]
self.x.XFree.argtypes = [C.c_void_p]
self.f.XFixesGetCursorImage.argtypes = [C.c_void_p]
self.f.XFixesGetCursorImage.restype = C.POINTER(Cursor)
self.display = self.x.XOpenDisplay(None)
if not self.display:
raise RuntimeError('no authenticated X11 display')
self.screen = self.x.XDefaultScreen(self.display)
self.root = self.x.XDefaultRootWindow(self.display)
self.size = self.dimensions()
def dimensions(self):
root = C.c_ulong()
x, y = C.c_int(), C.c_int()
w, h, border, depth = [C.c_uint() for _ in range(4)]
if not self.x.XGetGeometry(self.display, self.root, C.byref(root),
C.byref(x), C.byref(y), C.byref(w), C.byref(h),
C.byref(border), C.byref(depth)):
raise RuntimeError('root geometry unavailable')
return w.value, h.value
def read(self):
if self.dimensions() != self.size:
raise RuntimeError('display changed; reconnect required')
ptr = self.f.XFixesGetCursorImage(self.display)
if not ptr:
raise RuntimeError('cursor unavailable')
try:
c = ptr.contents
if not (0 <= c.w <= 128 and 0 <= c.h <= 128):
raise ValueError('unsupported cursor bounds')
rgba = bytearray()
for i in range(c.w * c.h):
p = c.pixels[i]
a = (p >> 24) & 255
# XRender pixels are premultiplied; SDL expects straight RGBA.
rgb = [min(255, (((p >> s) & 255) * 255 + a // 2) // a)
if a else 0 for s in (16, 8, 0)]
rgba.extend(rgb + [a])
w, h, hx, hy = c.w, c.h, c.hx, c.hy
if not any(rgba[3::4]):
# GTK's blank cursor can be 1x1 with a hotspot of (1,1).
# Canonicalize invisible pixels; never clip a visible hotspot.
w, h, hx, hy, rgba = 1, 1, 0, 0, bytearray(4)
elif not (hx < w and hy < h):
raise ValueError('visible cursor hotspot outside image')
return dict(w=w, h=h, hx=hx, hy=hy, x=c.x, y=c.y,
sw=self.size[0], sh=self.size[1], shape=int(c.serial),
rgba=base64.b64encode(rgba).decode())
finally:
self.x.XFree(ptr)
def stream():
RUN.mkdir(mode=0o700, parents=True, exist_ok=True)
with (RUN / 'lock').open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
# Version/capabilities come through the authenticated SSH stdin.
if not select.select([sys.stdin], [], [], 5)[0]:
raise RuntimeError('missing hello')
hello = json.loads(sys.stdin.buffer.readline(2048))
lease = hello.get('lease', '')
if hello.get('version') != 1 or len(lease) != 32 or any(
c not in '0123456789abcdef' for c in lease):
raise ValueError('bad hello')
x = XCursor()
if list(x.size) != hello.get('size'):
raise ValueError('source size differs')
# Fail before touching the service if the native shape is unsupported.
x.read()
systemctl('stop', GUARD)
(RUN / 'lease').write_text(lease)
systemctl('start', GUARD)
deadline = time.monotonic() + 40
try:
while not (RUN / 'ready').exists():
if time.monotonic() > deadline:
raise RuntimeError('guard not ready')
os.utime(RUN / 'lease', None)
time.sleep(.05)
if (RUN / 'ready').read_text() != lease:
raise RuntimeError('stale guard')
print(json.dumps(dict(type='ready', version=1, lease=lease)), flush=True)
seq = 0
last_shape = None
last_sent = 0
heartbeat = time.monotonic()
acknowledged_input = 0
pending_input = None
while True:
now = time.monotonic()
if select.select([sys.stdin], [], [], 0)[0]:
line = sys.stdin.buffer.readline(2048)
if not line or line.strip() == b'stop':
break
if line.strip() != b'ping':
request = json.loads(line)
if (set(request) != {'input', 'x', 'y'} or
any(type(request[k]) is not int for k in request) or
not acknowledged_input < request['input'] < 2**63 or
not 0 <= request['x'] < x.size[0] or
not 0 <= request['y'] < x.size[1]):
raise ValueError('bad input acknowledgement request')
pending_input = request
heartbeat = now
if now - heartbeat > TTL:
raise RuntimeError('client lease expired')
os.utime(RUN / 'lease', None)
if not (RUN / 'ready').exists():
raise RuntimeError('capture guard ended')
read_started = time.monotonic()
state = x.read()
read_seconds = time.monotonic() - read_started
if read_seconds > .1:
print('VDI native cursor: X11 read blocked %.3fs' % read_seconds,
file=sys.stderr, flush=True)
if pending_input and (abs(state['x'] - pending_input['x']) <= 2 and
abs(state['y'] - pending_input['y']) <= 2):
acknowledged_input = pending_input['input']
pending_input = None
state['ack'] = acknowledged_input
identity = (state['shape'], state['rgba'])
if identity != last_shape or now - last_sent >= .05:
seq += 1
state.update(type='cursor', version=1, lease=lease, seq=seq)
print(json.dumps(state, separators=(',', ':')), flush=True)
last_shape, last_sent = identity, now
time.sleep(.008)
finally:
(RUN / 'lease').unlink(missing_ok=True)
systemctl('stop', GUARD)
def main():
os.environ['DISPLAY'] = ':0'
os.environ['XDG_RUNTIME_DIR'] = '/run/user/%d' % os.getuid()
os.environ['DBUS_SESSION_BUS_ADDRESS'] = 'unix:path=%s/bus' % os.environ['XDG_RUNTIME_DIR']
gdm = Path(os.environ['XDG_RUNTIME_DIR']) / 'gdm/Xauthority'
os.environ['XAUTHORITY'] = str(gdm if gdm.exists() else Path.home() / '.Xauthority')
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(sig, lambda *_: sys.exit(0))
mode = sys.argv[1] if len(sys.argv) > 1 else 'stream'
if mode == 'recover':
recover()
elif mode == 'guard':
guard()
elif mode == 'stream':
stream()
else:
raise ValueError('unknown mode')
if __name__ == '__main__':
main()