<?phpnamespace App\Vnc;use Evenement\EventEmitter;/** * The console of a VM of the demo pool: a VNC server in memory (the stream side of a connection: on / write / close, polled), * answering XAPI's CONNECT then RFB 3.8, drawing a text console (80 x 25 characters, GD's 8 x 16 font) where one can log in and * type a few commands. What the tests and the demo use; the real consoles come from XAPI. */final class FakeVnc extends EventEmitter{ public const WIDTH = 640; public const HEIGHT = 400; private const COLS = 80; private const ROWS = 25; private string $in = ''; private string $stage = 'http'; private bool $opened = false; private bool $closed = false; /** @var list<string> the text on the screen */ private array $lines = []; private string $typing = ''; private string $mode = 'login'; private string $user = ''; private string $host; /** @var array<int, bool> screen rows to send again */ private array $changed = []; private bool $wantsUpdate = false; private bool $full = true; private float $blink = 0; private bool $cursorOn = true; /** @var list<array{0: int, 1: int}> pointer clicks received (tests) */ public array $clicks = []; /** @var list<int> keys received (tests) */ public array $keys = []; /** * Like QEMU (XCP-ng's consoles): with $extendedKeys the server takes physical keys (QEMU Extended Key Event, encoding -258); * $layout is the VM's own keyboard layout ('us', 'fr'). A plain key event (a keysym) is turned into the key of a US keyboard, * then read with the VM's layout, as QEMU does: on an AZERTY VM an "a" sent as a keysym comes out "q". Tests set them. */ public static bool $extendedKeys = false; public static string $layout = 'us'; private bool $ackExtended = false; private bool $shift = false; /** XT code => [the character, with Shift] of a layout (the keys the tests use) */ private const LAYOUTS = [ 'us' => [0x10 => 'qQ', 0x11 => 'wW', 0x12 => 'eE', 0x13 => 'rR', 0x14 => 'tT', 0x15 => 'yY', 0x16 => 'uU', 0x17 => 'iI', 0x18 => 'oO', 0x19 => 'pP', 0x1E => 'aA', 0x1F => 'sS', 0x20 => 'dD', 0x21 => 'fF', 0x22 => 'gG', 0x23 => 'hH', 0x24 => 'jJ', 0x25 => 'kK', 0x26 => 'lL', 0x27 => ';:', 0x2C => 'zZ', 0x2D => 'xX', 0x2E => 'cC', 0x2F => 'vV', 0x30 => 'bB', 0x31 => 'nN', 0x32 => 'mM', 0x39 => ' ', 0x02 => '1!', 0x03 => '2@', 0x04 => '3#', 0x05 => '4$', 0x06 => '5%', 0x07 => '6^', 0x08 => '7&', 0x09 => '8*', 0x0A => '9(', 0x0B => '0)'], 'fr' => [0x10 => 'aA', 0x11 => 'zZ', 0x12 => 'eE', 0x13 => 'rR', 0x14 => 'tT', 0x15 => 'yY', 0x16 => 'uU', 0x17 => 'iI', 0x18 => 'oO', 0x19 => 'pP', 0x1E => 'qQ', 0x1F => 'sS', 0x20 => 'dD', 0x21 => 'fF', 0x22 => 'gG', 0x23 => 'hH', 0x24 => 'jJ', 0x25 => 'kK', 0x26 => 'lL', 0x27 => 'mM', 0x2C => 'wW', 0x2D => 'xX', 0x2E => 'cC', 0x2F => 'vV', 0x30 => 'bB', 0x31 => 'nN', 0x32 => ',?', 0x39 => ' ', 0x02 => '&1', 0x04 => '"3', 0x05 => "'4", 0x06 => '(5', 0x07 => '-6', 0x0A => '_8', 0x0B => '@0'], ]; public function __construct(private string $vmName, private string $os = 'Debian GNU/Linux 12', private bool $refuse = false) { $this->host = strtolower((string)preg_replace('/[^a-z0-9-]/i', '', $vmName)) ?: 'vm'; $this->lines = ['', "$os {$this->host} hvc0", '', "{$this->host} login: "]; } public function write(string $bytes): bool { if ($this->closed) { return false; } $this->in .= $bytes; return true; } public function close(): void { if (!$this->closed) { $this->closed = true; $this->emit('close', [null]); } } public function isClosed(): bool { return $this->closed; } public function poll(float $dt = 0.016): void { if ($this->closed) { return; } if (!$this->opened) { $this->opened = true; $this->emit('open', []); return; } $this->blink += $dt; if ($this->blink > 0.5 && $this->stage === 'normal') { $this->blink = 0; $this->cursorOn = !$this->cursorOn; $this->changed[count($this->lines) - 1 - $this->top()] = true; } $out = ''; while (($step = $this->step()) !== null) { $out .= $step; } if ($this->stage === 'normal' && $this->wantsUpdate && ($this->full || $this->changed !== [])) { $out .= $this->update(); } if ($out !== '') { $this->emit('data', [$out]); } } /** One client message answered (null: waiting for bytes). */ private function step(): ?string { switch ($this->stage) { case 'http': $end = strpos($this->in, "\r\n\r\n"); if ($end === false) { return null; } $this->in = (string)substr($this->in, $end + 4); if ($this->refuse) { $this->stage = 'done'; return "HTTP/1.1 404 Not Found\r\n\r\n"; } $this->stage = 'version'; return "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\n\r\nRFB 003.008\n"; case 'version': if (strlen($this->in) < 12) { return null; } $this->in = (string)substr($this->in, 12); $this->stage = 'security'; return "\x01\x01"; // one security type: None case 'security': if (strlen($this->in) < 1) { return null; } $this->in = (string)substr($this->in, 1); $this->stage = 'init'; return pack('N', 0); case 'init': if (strlen($this->in) < 1) { return null; } $this->in = (string)substr($this->in, 1); $this->stage = 'normal'; return pack('nn', self::WIDTH, self::HEIGHT) . pack('CCCCnnnCCCx3', 32, 24, 0, 1, 255, 255, 255, 16, 8, 0) . pack('N', strlen($this->vmName)) . $this->vmName; case 'normal': return $this->message(); } return null; } private function message(): ?string { if ($this->in === '') { return null; } $sizes = [0 => 20, 3 => 10, 4 => 8, 5 => 6, 255 => 12]; $type = ord($this->in[0]); if ($type === 2) { if (strlen($this->in) < 4) { return null; } $size = 4 + 4 * unpack('n', substr($this->in, 2, 2))[1]; } elseif ($type === 6) { if (strlen($this->in) < 8) { return null; } $size = 8 + unpack('N', substr($this->in, 4, 4))[1]; } else { $size = $sizes[$type] ?? 1; } if (strlen($this->in) < $size) { return null; } $msg = substr($this->in, 0, $size); $this->in = (string)substr($this->in, $size); if ($type === 2 && self::$extendedKeys) { foreach (array_values(unpack('N*', substr($msg, 4)) ?: []) as $e) { if ($e === -258 || $e === 0x100000000 - 258) { $this->ackExtended = true; // answered by a -258 rectangle in the next update } } } elseif ($type === 255) { $k = unpack('Csub/ndown/Nkeysym/Ncode', substr($msg, 1)); $this->physical($k['code'], (bool)$k['down']); } elseif ($type === 3) { $this->wantsUpdate = true; if (ord($msg[1]) === 0) { $this->full = true; } } elseif ($type === 4) { $k = unpack('Cdown/x2/Nkey', substr($msg, 1)); if ($k['down']) { $this->keys[] = $k['key']; $this->type($this->throughUsKeyboard($k['key'])); } } elseif ($type === 5) { $p = unpack('Cmask/nx/ny', substr($msg, 1)); if ($p['mask'] & 1) { $this->clicks[] = [$p['x'], $p['y']]; } } return ''; } /** A keysym as QEMU types it: the key of a US keyboard that makes it, read with the VM's layout. */ private function throughUsKeyboard(int $keysym): int { if (self::$layout === 'us' || $keysym < 32 || $keysym >= 127) { return $keysym; } foreach (self::LAYOUTS['us'] as $xt => $chars) { $at = strpos($chars, chr($keysym)); if ($at !== false) { return ord(self::LAYOUTS[self::$layout][$xt][$at] ?? chr($keysym)); } } return $keysym; } /** A physical key (QEMU Extended Key Event): the VM's layout makes the character. */ private function physical(int $code, bool $down): void { if ($code === 0x2A || $code === 0x36) { $this->shift = $down; return; } if (!$down) { return; } $this->keys[] = $code; $char = match ($code) { 0x1C => Keysym::RETURN, 0x0E => Keysym::BACKSPACE, default => null }; if ($char === null && isset(self::LAYOUTS[self::$layout][$code])) { $char = ord(self::LAYOUTS[self::$layout][$code][$this->shift ? 1 : 0]); } if ($char !== null) { $this->type($char); } } private function type(int $key): void { $last = count($this->lines) - 1; if ($key === Keysym::RETURN) { $this->enter($this->typing); $this->typing = ''; return; } if ($key === Keysym::BACKSPACE) { if ($this->typing === '') { return; } $this->typing = substr($this->typing, 0, -1); if ($this->mode !== 'password') { $this->lines[$last] = substr($this->lines[$last], 0, -1); } } elseif ($key >= 32 && $key < 127) { $this->typing .= chr($key); if ($this->mode !== 'password') { $this->lines[$last] .= chr($key); } } else { return; } $this->changed[$last - $this->top()] = true; } private function enter(string $text): void { $host = $this->host; $out = []; if ($this->mode === 'login') { $this->user = $text !== '' ? $text : 'root'; $this->mode = 'password'; $out[] = 'Password: '; } elseif ($this->mode === 'password') { $this->mode = 'shell'; $out = ['Linux ' . $host . ' 6.1.0-25-amd64 #1 SMP PREEMPT_DYNAMIC x86_64', '', 'The programs included with the ' . $this->os . ' system are free software.', 'Last login: ' . gmdate('D M j H:i:s Y') . ' on hvc0', $this->prompt()]; } else { $cmd = trim($text); if ($cmd === 'clear') { $this->lines = []; $out = [$this->prompt()]; } elseif ($cmd === 'exit' || $cmd === 'logout') { $this->mode = 'login'; $out = ['logout', '', "$host login: "]; } else { $out = match (true) { $cmd === '' => [], $cmd === 'uptime' => [' ' . gmdate('H:i:s') . ' up 1 day, 10:12, 1 user, load average: 0.42, 0.37, 0.31'], $cmd === 'uname -a' => ['Linux ' . $host . ' 6.1.0-25-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.106-3 (2024-08-26) x86_64 GNU/Linux'], $cmd === 'ls' => ['backup.sh data notes.txt'], $cmd === 'whoami' => [$this->user], $cmd === 'free -h' => [' total used free shared buff/cache available', 'Mem: 31Gi 18Gi 2.1Gi 312Mi 11Gi 12Gi', 'Swap: 976Mi 0B 976Mi'], default => ['-bash: ' . explode(' ', $cmd)[0] . ': command not found'], }; $out[] = $this->prompt(); } } foreach ($out as $line) { $this->lines[] = $line; } $this->full = true; } private function prompt(): string { return $this->user . '@' . $this->host . ':~' . ($this->user === 'root' ? '# ' : '$ '); } private function top(): int { return max(0, count($this->lines) - self::ROWS); } /** A FramebufferUpdate: the whole screen, or the text rows that changed. */ private function update(): string { $this->wantsUpdate = false; $rows = $this->full ? range(0, self::ROWS - 1) : array_keys($this->changed); $this->full = false; $this->changed = []; $out = pack('Cxn', 0, count($rows) + ($this->ackExtended ? 1 : 0)); if ($this->ackExtended) { $this->ackExtended = false; $out .= pack('nnnnN', 0, 0, 0, 0, -258); // QEMU Extended Key Event: yes } foreach ($rows as $row) { $out .= pack('nnnnN', 0, $row * 16, self::WIDTH, 16, 0) . $this->renderRow($row); } return $out; } /** One text row (640 x 16) as B G R 0 pixels. */ private function renderRow(int $row): string { $index = $this->top() + $row; $text = $this->lines[$index] ?? ''; $image = imagecreatetruecolor(self::WIDTH, 16); imagefill($image, 0, 0, imagecolorallocate($image, 0, 0, 0)); $grey = imagecolorallocate($image, 204, 204, 204); imagestring($image, 4, 0, 0, substr($text, 0, self::COLS), $grey); if ($index === count($this->lines) - 1 && $this->cursorOn) { $x = min(self::COLS - 1, strlen($text)) * 8; imagefilledrectangle($image, $x, 13, $x + 7, 15, $grey); } $pixels = ''; $black = "\0\0\0\0"; $light = "\xcc\xcc\xcc\0"; for ($y = 0; $y < 16; $y++) { for ($x = 0; $x < self::WIDTH; $x++) { $pixels .= imagecolorat($image, $x, $y) === 0 ? $black : $light; } } return $pixels; }}