<?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); }}