UK
games/xencenter-xenadmin-xcp-jumphp / src / Vnc / RfbClient.php · commit fb1b7c9
<?phpnamespace App\Vnc;use Evenement\EventEmitter;/** * A VNC (RFB 3.3 / 3.7 / 3.8) client in PHP, over any stream with on() / write() / close(): the console of a VM as XenCenter * shows it. XAPI gives it through its HTTP server: `CONNECT /console?ref=<console>&session_id=<session> HTTP/1.0`, an answer * "200 OK", then the RFB protocol (security "None": the XAPI session is the authentication). * * Asks 32-bit pixels B, G, R, 0 (Framebuffer), Raw + CopyRect + DesktopSize, an incremental update as soon as one is drawn. * Events: state (string), resize (w, h), update, bell, error (string), close. */final class RfbClient extends EventEmitter{    public const CONNECTING = 'connecting';    public const HANDSHAKE = 'handshake';    public const READY = 'ready';    public const CLOSED = 'closed';    public string $state = self::CONNECTING;    public string $error = '';    public string $name = '';    /** where it connects ("192.168.1.11:443"): named in the errors */    public string $target = '';    public Framebuffer $fb;    public int $bytesIn = 0;    /** the server takes physical keys (QEMU Extended Key Event): the VM's own layout makes the characters (Scancodes) */    public bool $extendedKeys = false;    private string $buffer = '';    /** what is read next: http, version, security33, securityTypes, securityResult, serverInit, message */    private string $expect;    private int $minor = 8;    private int $buttons = 0;    /** @var array{0: int, 1: int}|null the last pointer position sent */    private ?array $at = null;    /** @var array{x: int, y: int, w: int, h: int, encoding: int}|null the rectangle being read */    private ?array $rect = null;    private int $rectsLeft = 0;    /** $connectRequest: sent first, its HTTP answer read before RFB (XAPI's console); '' = RFB at once (a plain VNC server) */    public function __construct(private object $stream, private string $connectRequest = '')    {        $this->fb = new Framebuffer();        $this->expect = $connectRequest !== '' ? 'http' : 'version';        $stream->on('open', function (): void {            if ($this->connectRequest !== '') {                $this->stream->write($this->connectRequest);            }        });        $stream->on('data', function (string $bytes): void {            $this->bytesIn += strlen($bytes);            $this->buffer .= $bytes;            try {                $this->parse();            } catch (\Throwable $e) {                $this->fail('VNC: ' . $e->getMessage());            }        });        $stream->on('error', fn (string $message) => $this->fail($message));        $stream->on('close', function (?string $why = null): void {            if ($this->state !== self::CLOSED) {                $this->state === self::READY ? $this->finish($why ?? 'The console closed.') : $this->fail($why ?? 'The console closed before it was ready.');            }        });    }    /** The CONNECT request of XAPI's console (location: the console record's location, ref: its ref). */    public static function connectRequest(string $location, string $consoleRef, string $session): string    {        $path = (string)(parse_url($location, PHP_URL_PATH) ?: '/console');        $host = (string)(parse_url($location, PHP_URL_HOST) ?: 'localhost');        return "CONNECT $path?ref=" . rawurlencode($consoleRef) . '&session_id=' . rawurlencode($session) . " HTTP/1.0\r\nHost: $host\r\n\r\n";    }    public function close(): void    {        if ($this->state !== self::CLOSED) {            $this->finish(null);            $this->stream->close();        }    }    private function setState(string $state): void    {        $this->state = $state;        $this->emit('state', [$state]);    }    private function fail(string $message): void    {        if ($this->state === self::CLOSED) {            return;        }        $this->error = $this->target !== '' && !str_contains($message, $this->target) ? $this->target . ': ' . $message : $message;        $this->emit('error', [$this->error]);        $this->finish($message);        $this->stream->close();    }    private function finish(?string $why): void    {        $this->state = self::CLOSED;        $this->emit('state', [self::CLOSED]);        $this->emit('close', [$why]);    }    // input -------------------------------------------------------------------------------------------------------------    public function key(int $keysym, bool $down): void    {        if ($this->state === self::READY && $keysym > 0) {            $this->stream->write(pack('CCnN', 4, $down ? 1 : 0, 0, $keysym));        }    }    /** A physical key (QEMU Extended Key Event): $scancode an SDL scancode, $keysym what it types here (0 = let the VM say). */    public function physicalKey(int $scancode, bool $down, int $keysym = 0): void    {        $xt = Scancodes::XT[$scancode] ?? null;        if ($this->state === self::READY && $this->extendedKeys && $xt !== null) {            $this->stream->write(pack('CCnNN', 255, 0, $down ? 1 : 0, $keysym, Scancodes::rfb($xt)));        }    }    /** A key pressed and let go (with modifiers held around it). @param list<int> $modifiers */    public function tap(int $keysym, array $modifiers = []): void    {        foreach ($modifiers as $m) {            $this->key($m, true);        }        $this->key($keysym, true);        $this->key($keysym, false);        foreach (array_reverse($modifiers) as $m) {            $this->key($m, false);        }    }    public function ctrlAltDel(): void    {        $this->tap(Keysym::DELETE, [Keysym::CONTROL, Keysym::ALT]);    }    /** The pointer at a framebuffer pixel, with buttons (1 left, 2 middle, 4 right, 8 / 16 wheel). */    public function pointer(int $x, int $y, ?int $buttons = null): void    {        if ($this->state !== self::READY) {            return;        }        $x = max(0, min($this->fb->width - 1, $x));        $y = max(0, min($this->fb->height - 1, $y));        $buttons ??= $this->buttons;        if ($this->at === [$x, $y] && $buttons === $this->buttons) {            return;        }        $this->buttons = $buttons;        $this->at = [$x, $y];        $this->stream->write(pack('CCnn', 5, $buttons, $x, $y));    }    public function wheel(int $x, int $y, bool $up): void    {        $base = $this->buttons & ~24;        $this->pointer($x, $y, $base | ($up ? 8 : 16));        $this->pointer($x, $y, $base);    }    public function requestUpdate(bool $incremental = true): void    {        if ($this->state === self::READY && $this->fb->width > 0) {            $this->stream->write(pack('CCnnnn', 3, $incremental ? 1 : 0, 0, 0, $this->fb->width, $this->fb->height));        }    }    // the protocol ------------------------------------------------------------------------------------------------------    private function take(int $n): ?string    {        if (strlen($this->buffer) < $n) {            return null;        }        $out = substr($this->buffer, 0, $n);        $this->buffer = (string)substr($this->buffer, $n);        return $out;    }    private function parse(): void    {        while ($this->state !== self::CLOSED) {            $before = strlen($this->buffer) . $this->expect . $this->rectsLeft;            match ($this->expect) {                'http' => $this->readHttp(),                'version' => $this->readVersion(),                'security33' => $this->readSecurity33(),                'securityTypes' => $this->readSecurityTypes(),                'securityResult' => $this->readSecurityResult(),                'serverInit' => $this->readServerInit(),                default => $this->readMessage(),            };            if ($before === strlen($this->buffer) . $this->expect . $this->rectsLeft) {                return;          // waiting for more bytes            }        }    }    private function readHttp(): void    {        $end = strpos($this->buffer, "\r\n\r\n");        if ($end === false) {            if (strlen($this->buffer) > 8192) {                throw new \RuntimeException('no HTTP answer');            }            return;        }        $head = substr($this->buffer, 0, $end);        $this->buffer = (string)substr($this->buffer, $end + 4);        if (!preg_match('#^HTTP/1\.\d 200#', $head)) {            throw new \RuntimeException('the server refused the console: ' . strtok($head, "\r\n"));        }        $this->setState(self::HANDSHAKE);        $this->expect = 'version';    }    private function readVersion(): void    {        $version = $this->take(12);        if ($version === null) {            return;        }        if (!preg_match('/^RFB (\d{3})\.(\d{3})\n$/', $version, $m)) {            throw new \RuntimeException('not a VNC server');        }        $this->minor = (int)$m[1] > 3 || (int)$m[2] >= 8 ? 8 : ((int)$m[2] >= 7 ? 7 : 3);        $this->stream->write(sprintf("RFB 003.%03d\n", $this->minor));        $this->expect = $this->minor >= 7 ? 'securityTypes' : 'security33';        if ($this->state === self::CONNECTING) {            $this->setState(self::HANDSHAKE);        }    }    private function readSecurity33(): void    {        $raw = $this->take(4);        if ($raw === null) {            return;        }        $type = unpack('N', $raw)[1];        if ($type === 0) {            throw new \RuntimeException('the server refused the connection');        }        if ($type !== 1) {            throw new \RuntimeException("security type $type is not supported (only None)");        }        $this->clientInit();    }    private function readSecurityTypes(): void    {        if (strlen($this->buffer) < 1) {            return;        }        $n = ord($this->buffer[0]);        if ($n === 0) {            if (strlen($this->buffer) < 5) {                return;            }            $len = unpack('N', substr($this->buffer, 1, 4))[1];            throw new \RuntimeException('the server refused: ' . substr($this->buffer, 5, $len));        }        $raw = $this->take(1 + $n);        if ($raw === null) {            return;        }        $types = array_map('ord', str_split(substr($raw, 1)));        if (!in_array(1, $types, true)) {            throw new \RuntimeException('the server wants a VNC password (security types ' . implode(', ', $types) . ')');        }        $this->stream->write(chr(1));        if ($this->minor >= 8) {            $this->expect = 'securityResult';        } else {            $this->clientInit();       // 3.7: no result for None        }    }    private function readSecurityResult(): void    {        $raw = $this->take(4);        if ($raw === null) {            return;        }        if (unpack('N', $raw)[1] !== 0) {            throw new \RuntimeException('authentication refused');        }        $this->clientInit();    }    private function clientInit(): void    {        $this->stream->write(chr(1));            // shared: others may watch the same console (XenCenter does)        $this->expect = 'serverInit';    }    private function readServerInit(): void    {        if (strlen($this->buffer) < 24) {            return;        }        $len = unpack('N', substr($this->buffer, 20, 4))[1];        $raw = $this->take(24 + $len);        if ($raw === null) {            return;        }        $size = unpack('nw/nh', $raw);        $this->name = substr($raw, 24, $len);        $this->fb->resize($size['w'], $size['h']);        // 32 bits, depth 24, little endian, true colour, 255 each, red << 16, green << 8, blue: bytes B G R 0        $this->stream->write(pack('Cx3CCCCnnnCCCx3', 0, 32, 24, 0, 1, 255, 255, 255, 16, 8, 0));        // CopyRect, Raw, DesktopSize, QEMU Extended Key Event (the server says yes with a -258 rectangle: then keys go as physical keys)        $encodings = [1, 0, -223, -258];        $this->stream->write(pack('CxnN*', 2, count($encodings), ...$encodings));        $this->expect = 'message';        $this->setState(self::READY);        $this->emit('resize', [$size['w'], $size['h']]);        $this->requestUpdate(false);    }    private function readMessage(): void    {        if ($this->rectsLeft > 0) {            $this->readRect();            return;        }        if ($this->buffer === '') {            return;        }        $type = ord($this->buffer[0]);        switch ($type) {            case 0:          // FramebufferUpdate                $head = $this->take(4);                if ($head === null) {                    return;                }                $this->rectsLeft = unpack('n', substr($head, 2, 2))[1];                if ($this->rectsLeft === 0) {                    $this->updated();                }                return;            case 1:          // SetColourMapEntries: not used with true colour                if (strlen($this->buffer) < 6) {                    return;                }                $n = unpack('n', substr($this->buffer, 4, 2))[1];                $this->take(6 + 6 * $n);                return;            case 2:                $this->take(1);                $this->emit('bell');                return;            case 3:          // ServerCutText                if (strlen($this->buffer) < 8) {                    return;                }                $len = unpack('N', substr($this->buffer, 4, 4))[1];                $this->take(8 + $len);                return;            default:                throw new \RuntimeException("unknown message $type");        }    }    private function readRect(): void    {        if ($this->rect === null) {            $head = $this->take(12);            if ($head === null) {                return;            }            $r = unpack('nx/ny/nw/nh/Nencoding', $head);            // a signed 32-bit number: 64-bit PHP reads it unsigned, the browser's 32-bit PHP already signed            $encoding = PHP_INT_SIZE > 4 && $r['encoding'] > 0x7fffffff ? $r['encoding'] - 0x100000000 : $r['encoding'];            $this->rect = ['x' => $r['x'], 'y' => $r['y'], 'w' => $r['w'], 'h' => $r['h'], 'encoding' => (int)$encoding];        }        $r = $this->rect;        switch ($r['encoding']) {            case 0:                $data = $this->take($r['w'] * $r['h'] * 4);                if ($data === null) {                    return;                }                $this->fb->put($r['x'], $r['y'], $r['w'], $r['h'], $data);                break;            case 1:                $src = $this->take(4);                if ($src === null) {                    return;                }                $s = unpack('nx/ny', $src);                $this->fb->copy($s['x'], $s['y'], $r['x'], $r['y'], $r['w'], $r['h']);                break;            case -258:                $this->extendedKeys = true;        // QEMU: physical keys from now on (Scancodes)                break;            case -223:                $this->fb->resize($r['w'], $r['h']);                $this->emit('resize', [$r['w'], $r['h']]);                break;            default:                throw new \RuntimeException('unsupported encoding ' . $r['encoding']);        }        $this->rect = null;        $this->rectsLeft--;        if ($this->rectsLeft === 0) {            $this->updated();        }    }    private function updated(): void    {        $this->emit('update');        $this->requestUpdate(true);    }}