Accedi
IT
games/xencenter-xenadmin-xcp-jumphp / src / Vnc / FakeVnc.php · commit fb1b7c9
<?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;    }}