194 lines
8.3 KiB
Python
194 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Box supervisor. Exec preserves the PID expected by kiosk window activation.
|
|
|
|
Metadata travels over pinned-host-key SSH with a per-host forced-command key.
|
|
The private state directory is not a public socket/port. Native cursor resources
|
|
are created and freed by Moonlight's SDL main thread, never by this process.
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import shutil
|
|
import signal
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
|
|
HEADER = struct.Struct('<8sQQQIIIIiiII')
|
|
MAX_LINE = 100000
|
|
STATE_TIMEOUT = 2.0
|
|
|
|
|
|
def verify_client(path, expected):
|
|
if (not isinstance(expected, str) or len(expected) != 64 or
|
|
hashlib.sha256(Path(path).read_bytes()).hexdigest() != expected):
|
|
raise ValueError('native cursor client build changed; verify build before enabling profile')
|
|
|
|
|
|
def validate(state, lease, size, previous):
|
|
if type(state.get('version')) is not int or state.get('version') != 1 or state.get('lease') != lease or state.get('type') != 'cursor':
|
|
raise ValueError('cursor protocol/session mismatch')
|
|
fields = ('seq', 'ack', 'w', 'h', 'hx', 'hy', 'x', 'y', 'sw', 'sh')
|
|
if any(type(state.get(k)) is not int for k in fields):
|
|
raise ValueError('noninteger field')
|
|
if not previous < state['seq'] < 2**63:
|
|
raise ValueError('stale sequence')
|
|
if not 0 <= state['ack'] < 2**63:
|
|
raise ValueError('invalid input acknowledgement')
|
|
w, h = state['w'], state['h']
|
|
if not (0 < w <= 128 and 0 < h <= 128 and 0 <= state['hx'] < w and 0 <= state['hy'] < h):
|
|
raise ValueError('cursor bounds')
|
|
if [state['sw'], state['sh']] != size:
|
|
raise ValueError('display changed')
|
|
if not (0 <= state['x'] < size[0] and 0 <= state['y'] < size[1]):
|
|
raise ValueError('cursor position outside single display')
|
|
pixels = base64.b64decode(state['rgba'], validate=True)
|
|
if len(pixels) != w * h * 4:
|
|
raise ValueError('pixel length')
|
|
return HEADER.pack(b'VDICUR1\0', int(lease[:16], 16), state['seq'],
|
|
time.monotonic_ns(), w, h, state['hx'], state['hy'],
|
|
state['x'], state['y'], *size) + pixels + struct.pack('<Q', state['ack'])
|
|
|
|
|
|
def write_state(directory, payload):
|
|
tmp = directory / 'state.tmp'
|
|
with tmp.open('wb') as f:
|
|
f.write(payload)
|
|
tmp.replace(directory / 'state')
|
|
|
|
|
|
def supervise(profile, size, lease, directory, ready_fd, parent):
|
|
ssh = None
|
|
ready = False
|
|
try:
|
|
cmd = ['ssh', '-T', '-i', profile['key'], '-o', 'IdentitiesOnly=yes',
|
|
'-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
|
|
'-o', 'UserKnownHostsFile=' + profile['known_hosts'],
|
|
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=1',
|
|
'-o', 'ServerAliveCountMax=2', profile['user'] + '@' + profile['host']]
|
|
ssh = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
|
ssh.stdin.write((json.dumps(dict(version=1, lease=lease, size=size)) + '\n').encode())
|
|
ssh.stdin.flush()
|
|
started = last = last_ping = time.monotonic()
|
|
seq, buffer, acknowledged = 0, b'', False
|
|
last_input = 0
|
|
while True:
|
|
# A kiosk SIGKILL of the exec'd client must still tear down the lease.
|
|
if os.getppid() != parent:
|
|
break
|
|
now = time.monotonic()
|
|
if now - last_ping >= .25:
|
|
ssh.stdin.write(b'ping\n')
|
|
ssh.stdin.flush()
|
|
last_ping = now
|
|
input_path = directory / 'state.input'
|
|
if ready and input_path.exists():
|
|
data = input_path.read_bytes()
|
|
if len(data) != 16:
|
|
raise ValueError('invalid local input record')
|
|
input_seq, px, py = struct.unpack('<Qii', data)
|
|
if input_seq > last_input:
|
|
if not (input_seq < 2**63 and 0 <= px < size[0] and 0 <= py < size[1]):
|
|
raise ValueError('local input bounds')
|
|
ssh.stdin.write((json.dumps(dict(input=input_seq, x=px, y=py))+'\n').encode())
|
|
ssh.stdin.flush()
|
|
last_input = input_seq
|
|
if (ready and now - last > STATE_TIMEOUT) or (not ready and now - started > 50):
|
|
raise RuntimeError('native cursor heartbeat timed out (%.3fs)' % (now - last))
|
|
if select.select([ssh.stdout], [], [], .025)[0]:
|
|
chunk = os.read(ssh.stdout.fileno(), 65536)
|
|
if not chunk:
|
|
raise RuntimeError('native cursor SSH ended')
|
|
buffer += chunk
|
|
if len(buffer) > MAX_LINE * 2:
|
|
raise ValueError('cursor receive buffer limit')
|
|
while b'\n' in buffer:
|
|
line, buffer = buffer.split(b'\n', 1)
|
|
if len(line) > MAX_LINE:
|
|
raise ValueError('cursor packet limit')
|
|
state = json.loads(line)
|
|
if not acknowledged:
|
|
if state != dict(type='ready', version=1, lease=lease):
|
|
raise ValueError('cursor handshake rejected')
|
|
acknowledged = True
|
|
continue
|
|
payload = validate(state, lease, size, seq)
|
|
seq = state['seq']
|
|
write_state(directory, payload)
|
|
last = time.monotonic()
|
|
if not ready:
|
|
os.write(ready_fd, b'ready\n')
|
|
os.close(ready_fd)
|
|
ready = True
|
|
print('VDI native cursor: authenticated cursor-free session ready',
|
|
file=sys.stderr, flush=True)
|
|
return 0
|
|
except Exception as error:
|
|
print('VDI native cursor: ' + str(error), file=sys.stderr, flush=True)
|
|
if ready and os.getppid() == parent:
|
|
os.kill(parent, signal.SIGTERM)
|
|
return 78
|
|
finally:
|
|
if ssh:
|
|
try:
|
|
ssh.stdin.write(b'stop\n')
|
|
ssh.stdin.flush()
|
|
ssh.stdin.close()
|
|
ssh.wait(timeout=12)
|
|
except (BrokenPipeError, subprocess.TimeoutExpired, OSError):
|
|
ssh.terminate()
|
|
try:
|
|
ssh.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
ssh.kill()
|
|
shutil.rmtree(directory)
|
|
|
|
|
|
def launch(argv):
|
|
client, args = argv[0], argv[1:]
|
|
if not (len(args) >= 2 and args[0] == 'stream'):
|
|
os.execv(client, argv)
|
|
host = args[1]
|
|
if any(c not in '0123456789.' for c in host):
|
|
os.execv(client, argv)
|
|
config = Path.home() / '.config/vdi-terminal/native-cursor' / (host + '.json')
|
|
if os.environ.get('VDI_NATIVE_CURSOR') == '0' or not config.exists():
|
|
os.execv(client, argv)
|
|
profile = json.loads(config.read_text())
|
|
# An old or separately rebuilt client must never enter cursor-free video
|
|
# without the SDL receiver. Verify BEFORE acquiring any remote lease.
|
|
verify_client(client, profile.get('client_sha256'))
|
|
if profile['host'] != host or '--absolute-mouse' not in args:
|
|
raise ValueError('native cursor needs matching absolute desktop mode')
|
|
resolution = args[args.index('--resolution') + 1]
|
|
size = list(map(int, resolution.split('x')))
|
|
if size != profile['size']:
|
|
raise ValueError('native cursor needs configured single-screen size')
|
|
lease = uuid.uuid4().hex
|
|
directory = Path(tempfile.mkdtemp(prefix='vdi-native-cursor-',
|
|
dir=os.environ.get('XDG_RUNTIME_DIR', '/tmp')))
|
|
rfd, wfd = os.pipe()
|
|
parent = os.getpid()
|
|
worker = os.fork()
|
|
if worker == 0:
|
|
os.close(rfd)
|
|
os._exit(supervise(profile, size, lease, directory, wfd, parent))
|
|
os.close(wfd)
|
|
if not select.select([rfd], [], [], 55)[0] or os.read(rfd, 32) != b'ready\n':
|
|
# No normal-mode launch while a cursor-free producer might still live.
|
|
raise RuntimeError('native cursor preflight failed; reconnect after recovery')
|
|
os.close(rfd)
|
|
os.environ['VDI_NATIVE_CURSOR_STATE'] = str(directory / 'state')
|
|
os.environ['VDI_NATIVE_CURSOR_EPOCH'] = str(int(lease[:16], 16))
|
|
os.execv(client, argv)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
launch(sys.argv[1:])
|