#!/usr/bin/env python3
"""GHOSTremote Ubuntu/X11 opt-in input bridge. Loopback only; never a remote shell.

IMPORTANT: Session-wide remote input is powerful. Grant only when you can see
and trust the person controlling the other device. Use Ctrl+C to revoke.
Wayland intentionally fails closed for OS input (screen + text remain available).
"""
import ctypes
import ctypes.util
import http.server
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import time
from urllib.parse import urlsplit, parse_qs
from collections import deque

ORIGINS = {value.strip() for value in os.environ.get('GHOSTREMOTE_ALLOWED_ORIGINS', 'https://personal.remote.cpx.agency').split(',') if value.strip()}
PORT = 8767
NONCE = secrets.token_hex(32)
ALLOWED_CODE = None
AUTHORIZED_UNTIL = 0.0
ALLOWED_EPOCH = None
ALLOWED_CONTROLLER_SIDE = None
EVENT_TIMES = deque()


def is_x11():
    return os.environ.get('XDG_SESSION_TYPE', '').lower() == 'x11' and bool(os.environ.get('DISPLAY'))


class XInput:
    def __init__(self):
        if not is_x11():
            raise RuntimeError('Wayland or headless session: native input disabled')
        a = ctypes.util.find_library('X11')
        b = ctypes.util.find_library('Xtst')
        if not a or not b:
            raise RuntimeError('X11/XTest system libraries unavailable')
        self.x11 = ctypes.CDLL(a)
        self.xtst = ctypes.CDLL(b)
        self.x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
        self.x11.XOpenDisplay.restype = ctypes.c_void_p
        self.display = self.x11.XOpenDisplay(None)
        if not self.display:
            raise RuntimeError('X11 display could not open')
        self.x11.XDefaultScreen.argtypes = [ctypes.c_void_p]
        self.x11.XDefaultScreen.restype = ctypes.c_int
        self.x11.XDisplayWidth.argtypes = [ctypes.c_void_p, ctypes.c_int]
        self.x11.XDisplayWidth.restype = ctypes.c_int
        self.x11.XDisplayHeight.argtypes = [ctypes.c_void_p, ctypes.c_int]
        self.x11.XDisplayHeight.restype = ctypes.c_int
        self.x11.XStringToKeysym.argtypes = [ctypes.c_char_p]
        self.x11.XStringToKeysym.restype = ctypes.c_ulong
        self.x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
        self.x11.XKeysymToKeycode.restype = ctypes.c_uint
        self.x11.XFlush.argtypes = [ctypes.c_void_p]
        self.xtst.XTestFakeMotionEvent.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_ulong]
        self.xtst.XTestFakeButtonEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong]
        self.xtst.XTestFakeKeyEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong]

    def _keycode(self, name):
        symbol = self.x11.XStringToKeysym(name.encode('ascii'))
        code = self.x11.XKeysymToKeycode(self.display, symbol)
        if not code:
            raise ValueError('Unsupported key')
        return code

    def _move(self, x, y):
        screen = self.x11.XDefaultScreen(self.display)
        w = self.x11.XDisplayWidth(self.display, screen)
        h = self.x11.XDisplayHeight(self.display, screen)
        if w <= 0 or h <= 0:
            raise ValueError('No valid X11 screen')
        self.xtst.XTestFakeMotionEvent(self.display, screen, int(x * (w-1)), int(y * (h-1)), 0)

    def pointer(self, x, y, action, button=0):
        self._move(x, y)
        if action == 'down':
            self.xtst.XTestFakeButtonEvent(self.display, button, 1, 0)
        elif action == 'up':
            self.xtst.XTestFakeButtonEvent(self.display, button, 0, 0)
        elif action == 'click':
            self.xtst.XTestFakeButtonEvent(self.display, button, 1, 0)
            self.xtst.XTestFakeButtonEvent(self.display, button, 0, 0)
        elif action != 'move':
            raise ValueError('Unsupported pointer action')
        self.x11.XFlush(self.display)

    def scroll(self, x, y, delta_x, delta_y):
        self._move(x, y)
        for button, count in ((4 if delta_y < 0 else 5, abs(delta_y)),
                              (6 if delta_x < 0 else 7, abs(delta_x))):
            for _ in range(min(3, count)):
                self.xtst.XTestFakeButtonEvent(self.display, button, 1, 0)
                self.xtst.XTestFakeButtonEvent(self.display, button, 0, 0)
        self.x11.XFlush(self.display)

    def key(self, key):
        allowed = {'Return','Escape','Tab','BackSpace','Left','Right','Up','Down','space',
                   'Home','End','Page_Up','Page_Down','Insert','Delete',
                   'F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12'}
        if key not in allowed:
            raise ValueError('Key not allowlisted')
        code = self._keycode(key)
        self.xtst.XTestFakeKeyEvent(self.display, code, 1, 0)
        self.xtst.XTestFakeKeyEvent(self.display, code, 0, 0)
        self.x11.XFlush(self.display)

    def release_all(self):
        for button in (1, 2, 3):
            self.xtst.XTestFakeButtonEvent(self.display, button, 0, 0)
        self.x11.XFlush(self.display)

    def type_text(self, value):
        if not isinstance(value, str) or not 1 <= len(value) <= 64 or any(ord(ch) < 32 or ord(ch) > 126 for ch in value):
            raise ValueError('Text must be 1-64 printable ASCII characters')
        shifted = {'~':('grave',True),'!':('1',True),'@':('2',True),'#':('3',True),'


try:
    X11 = XInput()
    NATIVE_REASON = 'X11 input available but requires local approval'
except (RuntimeError, OSError) as exc:
    X11 = None
    NATIVE_REASON = str(exc)


def explicit_permission(code, controller_side, epoch):
    title = 'GHOSTremote PERSONAL: approve remote input'
    description = (f'Allow the paired {controller_side.upper()} device to control this Ubuntu X11 desktop for 15 minutes?\n'
                   f'Pairing code: {code}  |  Role epoch: {epoch}\n\n'
                   'The controller may move the pointer, click, scroll and type in open applications. '
                   'Use the local Revoke button or stop this agent to terminate control immediately.')
    if shutil.which('zenity'):
        try:
            p = subprocess.run(['zenity', '--question', '--title='+title, '--width=520', '--text='+description],
                               timeout=50, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            return p.returncode == 0
        except (OSError, subprocess.TimeoutExpired):
            return False
    try:
        import tkinter as tk
        from tkinter import messagebox
        root = tk.Tk()
        root.withdraw()
        answer = bool(messagebox.askyesno(title, description, parent=root))
        root.destroy()
        return answer
    except Exception:
        # Fail closed. No hidden/unattended approval fallback.
        return False


class Handler(http.server.BaseHTTPRequestHandler):
    server_version = 'GHOSTremoteUbuntu/0.2'

    def headers_out(self, code, value):
        self.send_response(code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Cache-Control', 'no-store')
        self.send_header('X-Content-Type-Options', 'nosniff')
        origin = self.headers.get('Origin', '')
        if self.allowed_web_origin(origin):
            self.send_header('Access-Control-Allow-Origin', origin)
            self.send_header('Vary', 'Origin')
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
            self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-GHOST-Nonce')
            self.send_header('Access-Control-Allow-Private-Network', 'true')
        self.end_headers()
        self.wfile.write(json.dumps(value).encode('utf8'))

    @staticmethod
    def allowed_web_origin(origin):
        if origin in ORIGINS:
            return True
        return bool(re.fullmatch(r'https://[a-z0-9-]+\.ghostremote-personal\.pages\.dev', origin))

    def valid_origin(self):
        host = self.headers.get('Host', '')
        origin = self.headers.get('Origin', '')
        return host in (f'127.0.0.1:{PORT}', f'localhost:{PORT}') and self.allowed_web_origin(origin)

    def do_OPTIONS(self):
        if not self.valid_origin():
            self.headers_out(403, {'ok': False})
            return
        self.headers_out(204, {})

    def do_GET(self):
        if urlsplit(self.path).path != '/v1/status' or not self.valid_origin():
            self.headers_out(403, {'ok': False})
            return
        query = parse_qs(urlsplit(self.path).query)
        requested_code = (query.get('code') or [''])[0]
        try:
            requested_epoch = int((query.get('epoch') or ['-1'])[0])
        except ValueError:
            requested_epoch = -1
        requested_controller = (query.get('controller_side') or [''])[0]
        authorized = bool(ALLOWED_CODE == requested_code and ALLOWED_EPOCH == requested_epoch and
                          ALLOWED_CONTROLLER_SIDE == requested_controller and time.monotonic() < AUTHORIZED_UNTIL)
        self.headers_out(200, {'ok': True, 'available': bool(X11), 'platform': os.environ.get('XDG_SESSION_TYPE', 'unknown'),
                               'reason': NATIVE_REASON, 'nonce': NONCE, 'authorized': authorized})

    def do_POST(self):
        global ALLOWED_CODE, AUTHORIZED_UNTIL, ALLOWED_EPOCH, ALLOWED_CONTROLLER_SIDE
        if not self.valid_origin() or self.headers.get('X-GHOST-Nonce') != NONCE:
            self.headers_out(403, {'ok': False, 'error': 'origin_or_nonce_denied'})
            return
        try:
            length = int(self.headers.get('Content-Length', '0'))
            if length < 2 or length > 4096:
                raise ValueError('request_size')
            data = json.loads(self.rfile.read(length))
        except (ValueError, json.JSONDecodeError):
            self.headers_out(400, {'ok': False, 'error': 'invalid_request'})
            return
        code = data.get('code')
        if not isinstance(code, str) or not re.fullmatch(r'\d{6}', code):
            self.headers_out(400, {'ok': False, 'error': 'invalid_code'})
            return
        if self.path == '/v1/authorize':
            controller_side = data.get('controller_side')
            epoch = data.get('epoch')
            if controller_side not in ('host', 'guest') or type(epoch) is not int or epoch < 0:
                self.headers_out(400, {'ok': False, 'error': 'invalid_session_scope'})
                return
            # A new authorization request invalidates any previous local grant first.
            ALLOWED_CODE, AUTHORIZED_UNTIL, ALLOWED_EPOCH, ALLOWED_CONTROLLER_SIDE = None, 0.0, None, None
            if X11:
                X11.release_all()
            authorized = X11 is not None and explicit_permission(code, controller_side, epoch)
            if authorized:
                ALLOWED_CODE, AUTHORIZED_UNTIL = code, time.monotonic() + 900
                ALLOWED_EPOCH, ALLOWED_CONTROLLER_SIDE = epoch, controller_side
            self.headers_out(200, {'ok': True, 'authorized': authorized})
            return
        if self.path == '/v1/revoke':
            epoch = data.get('epoch')
            if ALLOWED_CODE == code and ALLOWED_EPOCH == epoch:
                ALLOWED_CODE, AUTHORIZED_UNTIL, ALLOWED_EPOCH, ALLOWED_CONTROLLER_SIDE = None, 0.0, None, None
                if X11:
                    X11.release_all()
            self.headers_out(200, {'ok': True, 'revoked': True})
            return
        if self.path != '/v1/input':
            self.headers_out(404, {'ok': False})
            return
        if (not X11 or not ALLOWED_CODE or code != ALLOWED_CODE or time.monotonic() > AUTHORIZED_UNTIL or
                data.get('epoch') != ALLOWED_EPOCH or data.get('senderSide') != ALLOWED_CONTROLLER_SIDE):
            self.headers_out(403, {'ok': False, 'error': 'not_locally_authorized'})
            return
        tick = time.monotonic()
        while EVENT_TIMES and tick - EVENT_TIMES[0] > 1:
            EVENT_TIMES.popleft()
        if len(EVENT_TIMES) >= 60:
            self.headers_out(429, {'ok': False, 'error': 'input_rate_limited'})
            return
        EVENT_TIMES.append(tick)
        try:
            if data.get('t') == 'pointer':
                x, y = data.get('x'), data.get('y')
                action, button = data.get('action', 'click'), data.get('button', 0)
                if type(x) not in (float, int) or type(y) not in (float, int) or not 0 <= x <= 1 or not 0 <= y <= 1:
                    raise ValueError('invalid_pointer')
                if action not in ('move', 'down', 'up', 'click') or (action == 'move' and button != 0) or (action != 'move' and button not in (1, 3)):
                    raise ValueError('invalid_pointer_action')
                X11.pointer(x, y, action, button)
            elif data.get('t') == 'scroll':
                x, y = data.get('x'), data.get('y')
                dx, dy = data.get('deltaX'), data.get('deltaY')
                if type(x) not in (float, int) or type(y) not in (float, int) or not 0 <= x <= 1 or not 0 <= y <= 1:
                    raise ValueError('invalid_scroll_position')
                if type(dx) is not int or type(dy) is not int or abs(dx) > 3 or abs(dy) > 3 or (dx == 0 and dy == 0):
                    raise ValueError('invalid_scroll_delta')
                X11.scroll(x, y, dx, dy)
            elif data.get('t') == 'key':
                X11.key(data.get('key'))
            elif data.get('t') == 'type':
                X11.type_text(data.get('value'))
            else:
                raise ValueError('unsupported_input')
        except (ValueError, OSError):
            self.headers_out(400, {'ok': False, 'error': 'invalid_or_unsupported_input'})
            return
        self.headers_out(200, {'ok': True})

    def log_message(self, format, *args):
        # Do not log pairing codes, text content or input events.
        return


if __name__ == '__main__':
    print('GHOSTremote PERSONAL input bridge | localhost only | Ctrl+C revokes all control')
    print('Mode:', NATIVE_REASON)
    print('This is not a shell, disk writer or unattended remote management service.')
    server = http.server.ThreadingHTTPServer(('127.0.0.1', PORT), Handler)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print('Local stop requested; revoking remote input.')
    finally:
        if X11:
            X11.release_all()
        server.server_close()
        ALLOWED_CODE = None
        AUTHORIZED_UNTIL = 0.0
:('4',True),
                   '%':('5',True),'^':('6',True),'&':('7',True),'*':('8',True),'(':('9',True),')':('0',True),
                   '_':('minus',True),'+':('equal',True),'{':('bracketleft',True),'}':('bracketright',True),
                   '|':('backslash',True),':':('semicolon',True),'"':('apostrophe',True),'<':('comma',True),
                   '>':('period',True),'?':('slash',True)}
        plain = {chr(96):'grave','-':'minus','=':'equal','[':'bracketleft',']':'bracketright','\\':'backslash',';':'semicolon',"'":'apostrophe',',':'comma','.':'period','/':'slash',' ':'space'}
                 ';':'semicolon',"'":'apostrophe',',':'comma','.':'period','/':'slash',' ':'space'}
        shift_code = self._keycode('Shift_L')
        for ch in value:
            use_shift = False
            if 'A' <= ch <= 'Z':
                name, use_shift = ch.lower(), True
            elif ch in shifted:
                name, use_shift = shifted[ch]
            elif ch in plain:
                name = plain[ch]
            else:
                name = ch
            key_code = self._keycode(name)
            if use_shift:
                self.xtst.XTestFakeKeyEvent(self.display, shift_code, 1, 0)
            self.xtst.XTestFakeKeyEvent(self.display, key_code, 1, 0)
            self.xtst.XTestFakeKeyEvent(self.display, key_code, 0, 0)
            if use_shift:
                self.xtst.XTestFakeKeyEvent(self.display, shift_code, 0, 0)
        self.x11.XFlush(self.display)


try:
    X11 = XInput()
    NATIVE_REASON = 'X11 input available but requires local approval'
except (RuntimeError, OSError) as exc:
    X11 = None
    NATIVE_REASON = str(exc)


def explicit_permission(code):
    title = 'GHOSTremote: approve phone mouse/keyboard'
    description = (f'Allow a paired phone to control this Ubuntu X11 desktop for 15 minutes?\n'
                   f'Pairing code: {code}\n\n'
                   'The controller may click/type in any open application. Stop the local agent to revoke immediately.')
    if shutil.which('zenity'):
        try:
            p = subprocess.run(['zenity', '--question', '--title='+title, '--width=520', '--text='+description],
                               timeout=50, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            return p.returncode == 0
        except (OSError, subprocess.TimeoutExpired):
            return False
    try:
        import tkinter as tk
        from tkinter import messagebox
        root = tk.Tk()
        root.withdraw()
        answer = bool(messagebox.askyesno(title, description, parent=root))
        root.destroy()
        return answer
    except Exception:
        # Fail closed. No hidden/unattended approval fallback.
        return False


class Handler(http.server.BaseHTTPRequestHandler):
    server_version = 'GHOSTremoteUbuntu/0.2'

    def headers_out(self, code, value):
        self.send_response(code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Cache-Control', 'no-store')
        self.send_header('X-Content-Type-Options', 'nosniff')
        if self.headers.get('Origin') == ORIGIN:
            self.send_header('Access-Control-Allow-Origin', ORIGIN)
            self.send_header('Vary', 'Origin')
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
            self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-GHOST-Nonce')
            self.send_header('Access-Control-Allow-Private-Network', 'true')
        self.end_headers()
        self.wfile.write(json.dumps(value).encode('utf8'))

    def valid_origin(self):
        host = self.headers.get('Host', '')
        return host in (f'127.0.0.1:{PORT}', f'localhost:{PORT}') and self.headers.get('Origin') == ORIGIN

    def do_OPTIONS(self):
        if not self.valid_origin():
            self.headers_out(403, {'ok': False})
            return
        self.headers_out(204, {})

    def do_GET(self):
        if self.path != '/v1/status' or not self.valid_origin():
            self.headers_out(403, {'ok': False})
            return
        self.headers_out(200, {'ok': True, 'available': bool(X11), 'platform': os.environ.get('XDG_SESSION_TYPE', 'unknown'),
                               'reason': NATIVE_REASON, 'nonce': NONCE,
                               'authorized': bool(ALLOWED_CODE and time.monotonic() < AUTHORIZED_UNTIL)})

    def do_POST(self):
        global ALLOWED_CODE, AUTHORIZED_UNTIL
        if not self.valid_origin() or self.headers.get('X-GHOST-Nonce') != NONCE:
            self.headers_out(403, {'ok': False, 'error': 'origin_or_nonce_denied'})
            return
        try:
            length = int(self.headers.get('Content-Length', '0'))
            if length < 2 or length > 1000:
                raise ValueError('request_size')
            data = json.loads(self.rfile.read(length))
        except (ValueError, json.JSONDecodeError):
            self.headers_out(400, {'ok': False, 'error': 'invalid_request'})
            return
        code = data.get('code')
        if not isinstance(code, str) or not re.fullmatch(r'\d{6}', code):
            self.headers_out(400, {'ok': False, 'error': 'invalid_code'})
            return
        if self.path == '/v1/authorize':
            authorized = X11 is not None and explicit_permission(code)
            if authorized:
                ALLOWED_CODE, AUTHORIZED_UNTIL = code, time.monotonic() + 900
            self.headers_out(200, {'ok': True, 'authorized': authorized})
            return
        if self.path != '/v1/input':
            self.headers_out(404, {'ok': False})
            return
        if not X11 or not ALLOWED_CODE or code != ALLOWED_CODE or time.monotonic() > AUTHORIZED_UNTIL:
            self.headers_out(403, {'ok': False, 'error': 'not_locally_authorized'})
            return
        tick = time.monotonic()
        while EVENT_TIMES and tick - EVENT_TIMES[0] > 1:
            EVENT_TIMES.popleft()
        if len(EVENT_TIMES) >= 60:
            self.headers_out(429, {'ok': False, 'error': 'input_rate_limited'})
            return
        EVENT_TIMES.append(tick)
        try:
            if data.get('t') == 'pointer':
                x, y, button = data.get('x'), data.get('y'), data.get('button', 0)
                if type(x) not in (float, int) or type(y) not in (float, int) or not 0 <= x <= 1 or not 0 <= y <= 1 or button not in (0, 1, 3):
                    raise ValueError('invalid_pointer')
                X11.pointer(x, y, button)
            elif data.get('t') == 'key':
                X11.key(data.get('key'))
            else:
                raise ValueError('unsupported_input')
        except (ValueError, OSError):
            self.headers_out(400, {'ok': False, 'error': 'invalid_or_unsupported_input'})
            return
        self.headers_out(200, {'ok': True})

    def log_message(self, format, *args):
        # Do not log pairing codes, text content or input events.
        return


if __name__ == '__main__':
    print('GHOSTremote input bridge | localhost only | Ctrl+C revokes all control')
    print('Mode:', NATIVE_REASON)
    print('This is not a shell, disk writer or unattended remote management service.')
    http.server.ThreadingHTTPServer(('127.0.0.1', PORT), Handler).serve_forever()
