diff options
author | Devaev Maxim <[email protected]> | 2020-03-23 00:50:48 +0300 |
---|---|---|
committer | Devaev Maxim <[email protected]> | 2020-03-23 00:50:48 +0300 |
commit | 94b779c5868a20f46c1d78c56fd19e01285a4e25 (patch) | |
tree | de49298f962c0c376499b69c7e6db90757cc5cc1 | |
parent | 4e0f7e61a25f8e64a4ce73aff75b30f8ac771ff5 (diff) |
refactoring
-rw-r--r-- | kvmd/apps/vnc/rfb.py | 441 | ||||
-rw-r--r-- | kvmd/apps/vnc/rfb/__init__.py | 334 | ||||
-rw-r--r-- | kvmd/apps/vnc/rfb/encodings.py | 69 | ||||
-rw-r--r-- | kvmd/apps/vnc/rfb/errors.py | 29 | ||||
-rw-r--r-- | kvmd/apps/vnc/rfb/stream.py | 109 | ||||
-rw-r--r-- | kvmd/apps/vnc/server.py | 2 | ||||
-rwxr-xr-x | setup.py | 1 |
7 files changed, 543 insertions, 442 deletions
diff --git a/kvmd/apps/vnc/rfb.py b/kvmd/apps/vnc/rfb.py deleted file mode 100644 index 95366ed1..00000000 --- a/kvmd/apps/vnc/rfb.py +++ /dev/null @@ -1,441 +0,0 @@ -# ========================================================================== # -# # -# KVMD - The main Pi-KVM daemon. # -# # -# Copyright (C) 2020 Maxim Devaev <[email protected]> # -# # -# This program is free software: you can redistribute it and/or modify # -# it under the terms of the GNU General Public License as published by # -# the Free Software Foundation, either version 3 of the License, or # -# (at your option) any later version. # -# # -# This program is distributed in the hope that it will be useful, # -# but WITHOUT ANY WARRANTY; without even the implied warranty of # -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # -# GNU General Public License for more details. # -# # -# You should have received a copy of the GNU General Public License # -# along with this program. If not, see <https://www.gnu.org/licenses/>. # -# # -# ========================================================================== # - - -import asyncio -import struct -import dataclasses - -from typing import Tuple -from typing import Dict -from typing import FrozenSet -from typing import Coroutine -from typing import Any - -from ...logging import get_logger - -from ... import aiotools - - -# ===== -class RfbError(Exception): - pass - - -class RfbConnectionError(RfbError): - def __init__(self, err: Exception) -> None: - super().__init__(f"Gone ({type(err).__name__})") - - -# ===== -_ENCODING_RESIZE = -223 # DesktopSize Pseudo-encoding -_ENCODING_RENAME = -307 # DesktopName Pseudo-encoding -_ENCODING_LEDS_STATE = -261 # LED State Pseudo-encoding - -_ENCODING_TIGHT = 7 -_ENCODING_TIGHT_JPEG_QUALITIES = dict(zip( # JPEG Quality Level Pseudo-encoding - [-32, -31, -30, -29, -28, -27, -26, -25, -24, -23], - [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], -)) - - [email protected](frozen=True) -class _Encodings: - encodings: FrozenSet[int] - - has_resize: bool = dataclasses.field(default=False) - has_rename: bool = dataclasses.field(default=False) - has_leds_state: bool = dataclasses.field(default=False) - - has_tight: bool = dataclasses.field(default=False) - tight_jpeg_quality: int = dataclasses.field(default=0) - - def __post_init__(self) -> None: - self.__set("has_resize", (_ENCODING_RESIZE in self.encodings)) - self.__set("has_rename", (_ENCODING_RENAME in self.encodings)) - self.__set("has_leds_state", (_ENCODING_LEDS_STATE in self.encodings)) - - self.__set("has_tight", (_ENCODING_TIGHT in self.encodings)) - self.__set("tight_jpeg_quality", self.__get_tight_jpeg_quality()) - - def __set(self, key: str, value: Any) -> None: - object.__setattr__(self, key, value) - - def __get_tight_jpeg_quality(self) -> int: - if _ENCODING_TIGHT in self.encodings: - qualities = self.encodings.intersection(_ENCODING_TIGHT_JPEG_QUALITIES) - if qualities: - return _ENCODING_TIGHT_JPEG_QUALITIES[max(qualities)] - return 0 - - -class RfbClient: # pylint: disable=too-many-instance-attributes - # https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst - # https://www.toptal.com/java/implementing-remote-framebuffer-server-java - # https://github.com/TigerVNC/tigervnc - - def __init__( - self, - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - - width: int, - height: int, - name: str, - ) -> None: - - self.__reader = reader - self.__writer = writer - - self._remote = "[%s]:%d" % (self.__writer.transport.get_extra_info("peername")[:2]) - - self._width = width - self._height = height - self._name = name - - self._encodings = _Encodings(frozenset()) - - self._lock = asyncio.Lock() - - get_logger(0).info("Connected client: %s", self._remote) - - # ===== - - async def _run(self, **coros: Coroutine) -> None: - tasks = list(map(asyncio.create_task, [ - self.__wrapper(name, coro) - for (name, coro) in {"main": self.__main_task_loop(), **coros}.items() - ])) - try: - await aiotools.wait_first(*tasks) - finally: - for task in tasks: - task.cancel() - - async def __wrapper(self, name: str, coro: Coroutine) -> None: - logger = get_logger(0) - try: - await coro - raise RuntimeError("Subtask just finished without any exception") - except asyncio.CancelledError: - logger.info("[%s] Client %s: Cancelling ...", name, self._remote) - raise - except RfbError as err: - logger.info("[%s] Client %s: %s: Disconnected", name, self._remote, str(err)) - except Exception: - logger.exception("[%s] Unhandled exception with client %s: Disconnected", name, self._remote) - - async def __main_task_loop(self) -> None: - try: - rfb_version = await self.__handshake_version() - await self.__handshake_security(rfb_version) - await self.__handshake_init() - await self.__main_loop() - finally: - try: - self.__writer.close() - except Exception: - pass - - # ===== - - async def _authorize(self, user: str, passwd: str) -> bool: - raise NotImplementedError - - async def _on_key_event(self, code: int, state: bool) -> None: - raise NotImplementedError - - async def _on_pointer_event(self, buttons: Dict[str, bool], wheel: Dict[str, int], move: Dict[str, int]) -> None: - raise NotImplementedError - - async def _on_cut_event(self, text: str) -> None: - raise NotImplementedError - - async def _on_set_encodings(self) -> None: - raise NotImplementedError - - async def _on_fb_update_request(self) -> None: - raise NotImplementedError - - # ===== - - async def _send_fb(self, jpeg: bytes) -> None: - assert self._encodings.has_tight - assert self._encodings.tight_jpeg_quality > 0 - assert len(jpeg) <= 4194303, len(jpeg) - await self.__write_fb_update(self._width, self._height, _ENCODING_TIGHT, drain=False) - length = len(jpeg) - if length <= 127: - await self.__write_struct("", bytes([0b10011111, length & 0x7F]), jpeg) - elif length <= 16383: - await self.__write_struct("", bytes([0b10011111, length & 0x7F | 0x80, length >> 7 & 0x7F]), jpeg) - else: - await self.__write_struct("", bytes([0b10011111, length & 0x7F | 0x80, length >> 7 & 0x7F | 0x80, length >> 14 & 0xFF]), jpeg) - - async def _send_resize(self, width: int, height: int) -> None: - assert self._encodings.has_resize - await self.__write_fb_update(width, height, _ENCODING_RESIZE) - self._width = width - self._height = height - - async def _send_rename(self, name: str) -> None: - assert self._encodings.has_rename - await self.__write_fb_update(0, 0, _ENCODING_RENAME, drain=False) - await self.__write_reason(name) - self._name = name - - async def _send_leds_state(self, caps: bool, scroll: bool, num: bool) -> None: - assert self._encodings.has_leds_state - await self.__write_fb_update(0, 0, _ENCODING_LEDS_STATE, drain=False) - await self.__write_struct("B", 0x1 & scroll | 0x2 & num | 0x4 & caps) - - # ===== - - async def __handshake_version(self) -> int: - # The only published protocol versions at this time are 3.3, 3.7, 3.8. - # Version 3.5 was wrongly reported by some clients, but it should be - # interpreted by all servers as 3.3 - - await self.__write_struct("", b"RFB 003.008\n") - - response = await self.__read_text(12) - if ( - not response.startswith("RFB 003.00") - or not response.endswith("\n") - or response[-2] not in ["3", "5", "7", "8"] - ): - raise RfbError(f"Invalid version response: {response!r}") - - try: - version = int(response[-2]) - except ValueError: - raise RfbError(f"Invalid version response: {response!r}") - return (3 if version == 5 else version) - - # ===== - - async def __handshake_security(self, rfb_version: int) -> None: - if rfb_version == 3: - await self.__handshake_security_v3(rfb_version) - else: - await self.__handshake_security_v7_plus(rfb_version) - - async def __handshake_security_v3(self, rfb_version: int) -> None: - assert rfb_version == 3 - - await self.__write_struct("L", 0, drain=False) # Refuse old clients using the invalid security type - msg = "The client uses a very old protocol 3.3; required 3.7 at least" - await self.__write_reason(msg) - raise RfbError(msg) - - async def __handshake_security_v7_plus(self, rfb_version: int) -> None: - assert rfb_version >= 7 - - vencrypt = 19 - await self.__write_struct("B B", 1, vencrypt) # One security type, VeNCrypt - - security_type = await self.__read_number("B") - if security_type != vencrypt: - raise RfbError(f"Invalid security type: {security_type}; expected VeNCrypt({vencrypt})") - - # ----- - - await self.__write_struct("BB", 0, 2) # VeNCrypt 0.2 - - vencrypt_version = "%d.%d" % (await self.__read_struct("BB")) - if vencrypt_version != "0.2": - await self.__write_struct("B", 1) # Unsupported - raise RfbError(f"Unsupported VeNCrypt version: {vencrypt_version}") - - await self.__write_struct("B", 0) - - # ----- - - plain = 256 - await self.__write_struct("B L", 1, plain) # One auth subtype, plain - - auth_type = await self.__read_number("L") - if auth_type != plain: - raise RfbError(f"Invalid auth type: {auth_type}; expected Plain({plain})") - - # ----- - - (user_length, passwd_length) = await self.__read_struct("LL") - user = await self.__read_text(user_length) - passwd = await self.__read_text(passwd_length) - - if (await self._authorize(user, passwd)): - get_logger(0).info("[main] Client %s: Access granted for user %r", self._remote, user) - await self.__write_struct("L", 0) - else: - await self.__write_struct("L", 1, drain=(rfb_version < 8)) - if rfb_version >= 8: - await self.__write_reason("Invalid username or password") - raise RfbError(f"Access denied for user {user!r}") - - # ===== - - async def __handshake_init(self) -> None: - await self.__read_number("B") # Shared flag, ignored - - await self.__write_struct("HH", self._width, self._height, drain=False) - await self.__write_struct( - "BB?? HHH BBB xxx", - 32, # Bits per pixel - 24, # Depth - False, # Big endian - True, # True color - 255, # Red max - 255, # Green max - 255, # Blue max - 16, # Red shift - 8, # Green shift - 0, # Blue shift - drain=False, - ) - await self.__write_reason(self._name) - - # ===== - - async def __main_loop(self) -> None: - logger = get_logger(0) - - while True: - msg_type = await self.__read_number("B") - - async with self._lock: - if msg_type == 0: # SetPixelFormat - # JpegCompression may only be used when bits-per-pixel is either 16 or 32 - bits_per_pixel = (await self.__read_struct("xxx BB?? HHH BBB xxx"))[0] - if bits_per_pixel not in [16, 32]: - raise RfbError(f"Requested unsupported {bits_per_pixel=} for Tight JPEG; required 16 or 32") - - elif msg_type == 2: # SetEncodings - encodings_count = (await self.__read_struct("x H"))[0] - if encodings_count > 1024: - raise RfbError(f"Too many encodings: {encodings_count}") - self._encodings = _Encodings(frozenset(await self.__read_struct("l" * encodings_count))) - logger.info("[main] Client %s: Features: resize=%d; rename=%d; leds=%d", - self._remote, self._encodings.has_resize, self._encodings.has_rename, self._encodings.has_leds_state) - self.__check_tight_jpeg() - await self._on_set_encodings() - - elif msg_type == 3: # FramebufferUpdateRequest - self.__check_tight_jpeg() # If we don't receive SetEncodings from client - await self.__read_struct("? HH HH") # Ignore any arguments, just perform the full update - await self._on_fb_update_request() - - elif msg_type == 4: # KeyEvent - (state, code) = await self.__read_struct("? xx L") - await self._on_key_event(code, state) # type: ignore - - elif msg_type == 5: # PointerEvent - (buttons, to_x, to_y) = await self.__read_struct("B HH") - await self._on_pointer_event( - buttons={ - "left": bool(buttons & 0x1), - "right": bool(buttons & 0x4), - "middle": bool(buttons & 0x2), - }, - wheel={ - "x": (-4 if buttons & 0x40 else (4 if buttons & 0x20 else 0)), - "y": (-4 if buttons & 0x10 else (4 if buttons & 0x8 else 0)), - }, - move={ - "x": round(to_x / self._width * 65535 + -32768), - "y": round(to_y / self._height * 65535 + -32768), - }, - ) - - elif msg_type == 6: # ClientCutText - await self._on_cut_event(await self.__read_text((await self.__read_struct("xxx L"))[0])) - - else: - raise RfbError(f"Unknown message type: {msg_type}") - - def __check_tight_jpeg(self) -> None: - # JpegCompression may only be used when the client has advertized - # a quality level using the JPEG Quality Level Pseudo-encoding - if not self._encodings.has_tight or self._encodings.tight_jpeg_quality == 0: - raise RfbError(f"Tight JPEG encoding is not supported by client: {self._encodings}") - - # ===== - - async def __read_number(self, fmt: str) -> int: - assert len(fmt) == 1 - try: - if fmt == "B": - return (await self.__reader.readexactly(1))[0] - else: - fmt = f">{fmt}" - return struct.unpack(fmt, await self.__reader.readexactly(struct.calcsize(fmt)))[0] - except (ConnectionError, asyncio.IncompleteReadError) as err: - raise RfbConnectionError(err) - - async def __read_struct(self, fmt: str) -> Tuple[int, ...]: - assert len(fmt) > 1 - try: - fmt = f">{fmt}" - return struct.unpack(fmt, (await self.__reader.readexactly(struct.calcsize(fmt)))) - except (ConnectionError, asyncio.IncompleteReadError) as err: - raise RfbConnectionError(err) - - async def __read_text(self, length: int) -> str: - try: - return (await self.__reader.readexactly(length)).decode("utf-8", errors="ignore") - except (ConnectionError, asyncio.IncompleteReadError) as err: - raise RfbConnectionError(err) - - # ===== - - async def __write_struct(self, fmt: str, *values: Any, drain: bool=True) -> None: - try: - if not fmt: - for value in values: - self.__writer.write(value) - elif fmt == "B": - assert len(values) == 1 - self.__writer.write(bytes([values[0]])) - else: - self.__writer.write(struct.pack(f">{fmt}", *values)) - if drain: - await self.__writer.drain() - except ConnectionError as err: - raise RfbConnectionError(err) - - async def __write_reason(self, text: str, drain: bool=True) -> None: - encoded = text.encode("utf-8", errors="ignore") - await self.__write_struct("L", len(encoded), drain=False) - try: - self.__writer.write(encoded) - if drain: - await self.__writer.drain() - except ConnectionError as err: - raise RfbConnectionError(err) - - async def __write_fb_update(self, width: int, height: int, encoding: int, drain: bool=True) -> None: - await self.__write_struct( - "BxH HH HH l", - 0, # FB update - 1, # Number of rects - 0, 0, width, height, encoding, - drain=drain, - ) diff --git a/kvmd/apps/vnc/rfb/__init__.py b/kvmd/apps/vnc/rfb/__init__.py new file mode 100644 index 00000000..8dfb6fe2 --- /dev/null +++ b/kvmd/apps/vnc/rfb/__init__.py @@ -0,0 +1,334 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +# ========================================================================== # + + +import asyncio + +from typing import Dict +from typing import Coroutine + +from ....logging import get_logger + +from .... import aiotools + +from .errors import RfbError +from .errors import RfbConnectionError + +from .encodings import RfbEncodings +from .encodings import RfbClientEncodings + +from .stream import RfbClientStream + + +# ===== +class RfbClient(RfbClientStream): + # https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst + # https://www.toptal.com/java/implementing-remote-framebuffer-server-java + # https://github.com/TigerVNC/tigervnc + + def __init__( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + + width: int, + height: int, + name: str, + ) -> None: + + super().__init__(reader, writer) + + self._width = width + self._height = height + self._name = name + + self._encodings = RfbClientEncodings(frozenset()) + + self._lock = asyncio.Lock() + + get_logger(0).info("Connected client: %s", self._remote) + + # ===== + + async def _run(self, **coros: Coroutine) -> None: + tasks = list(map(asyncio.create_task, [ + self.__wrapper(name, coro) + for (name, coro) in {"main": self.__main_task_loop(), **coros}.items() + ])) + try: + await aiotools.wait_first(*tasks) + finally: + for task in tasks: + task.cancel() + + async def __wrapper(self, name: str, coro: Coroutine) -> None: + logger = get_logger(0) + try: + await coro + raise RuntimeError("Subtask just finished without any exception") + except asyncio.CancelledError: + logger.info("[%s] Client %s: Cancelling ...", name, self._remote) + raise + except RfbConnectionError as err: + logger.info("[%s] Client %s: Gone (%s): Disconnected", name, self._remote, str(err)) + except RfbError as err: + logger.info("[%s] Client %s: %s: Disconnected", name, self._remote, str(err)) + except Exception: + logger.exception("[%s] Unhandled exception with client %s: Disconnected", name, self._remote) + + async def __main_task_loop(self) -> None: + try: + rfb_version = await self.__handshake_version() + await self.__handshake_security(rfb_version) + await self.__handshake_init() + await self.__main_loop() + finally: + self._close() + + # ===== + + async def _authorize(self, user: str, passwd: str) -> bool: + raise NotImplementedError + + async def _on_key_event(self, code: int, state: bool) -> None: + raise NotImplementedError + + async def _on_pointer_event(self, buttons: Dict[str, bool], wheel: Dict[str, int], move: Dict[str, int]) -> None: + raise NotImplementedError + + async def _on_cut_event(self, text: str) -> None: + raise NotImplementedError + + async def _on_set_encodings(self) -> None: + raise NotImplementedError + + async def _on_fb_update_request(self) -> None: + raise NotImplementedError + + # ===== + + async def _send_fb(self, jpeg: bytes) -> None: + assert self._encodings.has_tight + assert self._encodings.tight_jpeg_quality > 0 + assert len(jpeg) <= 4194303, len(jpeg) + await self._write_fb_update(self._width, self._height, RfbEncodings.TIGHT, drain=False) + length = len(jpeg) + if length <= 127: + await self._write_struct("", bytes([0b10011111, length & 0x7F]), jpeg) + elif length <= 16383: + await self._write_struct("", bytes([0b10011111, length & 0x7F | 0x80, length >> 7 & 0x7F]), jpeg) + else: + await self._write_struct("", bytes([0b10011111, length & 0x7F | 0x80, length >> 7 & 0x7F | 0x80, length >> 14 & 0xFF]), jpeg) + + async def _send_resize(self, width: int, height: int) -> None: + assert self._encodings.has_resize + await self._write_fb_update(width, height, RfbEncodings.RESIZE) + self._width = width + self._height = height + + async def _send_rename(self, name: str) -> None: + assert self._encodings.has_rename + await self._write_fb_update(0, 0, RfbEncodings.RENAME, drain=False) + await self._write_reason(name) + self._name = name + + async def _send_leds_state(self, caps: bool, scroll: bool, num: bool) -> None: + assert self._encodings.has_leds_state + await self._write_fb_update(0, 0, RfbEncodings.LEDS_STATE, drain=False) + await self._write_struct("B", 0x1 & scroll | 0x2 & num | 0x4 & caps) + + # ===== + + async def __handshake_version(self) -> int: + # The only published protocol versions at this time are 3.3, 3.7, 3.8. + # Version 3.5 was wrongly reported by some clients, but it should be + # interpreted by all servers as 3.3 + + await self._write_struct("", b"RFB 003.008\n") + + response = await self._read_text(12) + if ( + not response.startswith("RFB 003.00") + or not response.endswith("\n") + or response[-2] not in ["3", "5", "7", "8"] + ): + raise RfbError(f"Invalid version response: {response!r}") + + try: + version = int(response[-2]) + except ValueError: + raise RfbError(f"Invalid version response: {response!r}") + return (3 if version == 5 else version) + + # ===== + + async def __handshake_security(self, rfb_version: int) -> None: + if rfb_version == 3: + await self.__handshake_security_v3(rfb_version) + else: + await self.__handshake_security_v7_plus(rfb_version) + + async def __handshake_security_v3(self, rfb_version: int) -> None: + assert rfb_version == 3 + + await self._write_struct("L", 0, drain=False) # Refuse old clients using the invalid security type + msg = "The client uses a very old protocol 3.3; required 3.7 at least" + await self._write_reason(msg) + raise RfbError(msg) + + async def __handshake_security_v7_plus(self, rfb_version: int) -> None: + assert rfb_version >= 7 + + vencrypt = 19 + await self._write_struct("B B", 1, vencrypt) # One security type, VeNCrypt + + security_type = await self._read_number("B") + if security_type != vencrypt: + raise RfbError(f"Invalid security type: {security_type}; expected VeNCrypt({vencrypt})") + + # ----- + + await self._write_struct("BB", 0, 2) # VeNCrypt 0.2 + + vencrypt_version = "%d.%d" % (await self._read_struct("BB")) + if vencrypt_version != "0.2": + await self._write_struct("B", 1) # Unsupported + raise RfbError(f"Unsupported VeNCrypt version: {vencrypt_version}") + + await self._write_struct("B", 0) + + # ----- + + plain = 256 + await self._write_struct("B L", 1, plain) # One auth subtype, plain + + auth_type = await self._read_number("L") + if auth_type != plain: + raise RfbError(f"Invalid auth type: {auth_type}; expected Plain({plain})") + + # ----- + + (user_length, passwd_length) = await self._read_struct("LL") + user = await self._read_text(user_length) + passwd = await self._read_text(passwd_length) + + if (await self._authorize(user, passwd)): + get_logger(0).info("[main] Client %s: Access granted for user %r", self._remote, user) + await self._write_struct("L", 0) + else: + await self._write_struct("L", 1, drain=(rfb_version < 8)) + if rfb_version >= 8: + await self._write_reason("Invalid username or password") + raise RfbError(f"Access denied for user {user!r}") + + # ===== + + async def __handshake_init(self) -> None: + await self._read_number("B") # Shared flag, ignored + + await self._write_struct("HH", self._width, self._height, drain=False) + await self._write_struct( + "BB?? HHH BBB xxx", + 32, # Bits per pixel + 24, # Depth + False, # Big endian + True, # True color + 255, # Red max + 255, # Green max + 255, # Blue max + 16, # Red shift + 8, # Green shift + 0, # Blue shift + drain=False, + ) + await self._write_reason(self._name) + + # ===== + + async def __main_loop(self) -> None: + handlers = { + 0: self.__handle_set_pixel_format, + 2: self.__handle_set_encodings, + 3: self.__handle_fb_update_request, + 4: self.__handle_key_event, + 5: self.__handle_pointer_event, + 6: self.__handle_client_cut_text, + } + while True: + msg_type = await self._read_number("B") + if (handler := handlers.get(msg_type)) is not None: # noqa: E203,E231 + await handler() # type: ignore # mypy bug + else: + raise RfbError(f"Unknown message type: {msg_type}") + + async def __handle_set_pixel_format(self) -> None: + # JpegCompression may only be used when bits-per-pixel is either 16 or 32 + bits_per_pixel = (await self._read_struct("xxx BB?? HHH BBB xxx"))[0] + if bits_per_pixel not in [16, 32]: + raise RfbError(f"Requested unsupported {bits_per_pixel=} for Tight JPEG; required 16 or 32") + + async def __handle_set_encodings(self) -> None: + encodings_count = (await self._read_struct("x H"))[0] + if encodings_count > 1024: + raise RfbError(f"Too many encodings: {encodings_count}") + self._encodings = RfbClientEncodings(frozenset(await self._read_struct("l" * encodings_count))) + get_logger(0).info("[main] Client %s: Features: resize=%d; rename=%d; leds=%d", + self._remote, self._encodings.has_resize, self._encodings.has_rename, self._encodings.has_leds_state) + self.__check_tight_jpeg() + await self._on_set_encodings() + + async def __handle_fb_update_request(self) -> None: + self.__check_tight_jpeg() # If we don't receive SetEncodings from client + await self._read_struct("? HH HH") # Ignore any arguments, just perform the full update + await self._on_fb_update_request() + + async def __handle_key_event(self) -> None: + (state, code) = await self._read_struct("? xx L") + await self._on_key_event(code, state) # type: ignore + + async def __handle_pointer_event(self) -> None: + (buttons, to_x, to_y) = await self._read_struct("B HH") + await self._on_pointer_event( + buttons={ + "left": bool(buttons & 0x1), + "right": bool(buttons & 0x4), + "middle": bool(buttons & 0x2), + }, + wheel={ + "x": (-4 if buttons & 0x40 else (4 if buttons & 0x20 else 0)), + "y": (-4 if buttons & 0x10 else (4 if buttons & 0x8 else 0)), + }, + move={ + "x": round(to_x / self._width * 65535 + -32768), + "y": round(to_y / self._height * 65535 + -32768), + }, + ) + + async def __handle_client_cut_text(self) -> None: + length = (await self._read_struct("xxx L"))[0] + text = await self._read_text(length) + await self._on_cut_event(text) + + def __check_tight_jpeg(self) -> None: + # JpegCompression may only be used when the client has advertized + # a quality level using the JPEG Quality Level Pseudo-encoding + if not self._encodings.has_tight or self._encodings.tight_jpeg_quality == 0: + raise RfbError(f"Tight JPEG encoding is not supported by client: {self._encodings}") diff --git a/kvmd/apps/vnc/rfb/encodings.py b/kvmd/apps/vnc/rfb/encodings.py new file mode 100644 index 00000000..cb1d4761 --- /dev/null +++ b/kvmd/apps/vnc/rfb/encodings.py @@ -0,0 +1,69 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +# ========================================================================== # + + +import dataclasses + +from typing import FrozenSet +from typing import Any + + +# ===== +class RfbEncodings: + RESIZE = -223 # DesktopSize Pseudo-encoding + RENAME = -307 # DesktopName Pseudo-encoding + LEDS_STATE = -261 # LED State Pseudo-encoding + + TIGHT = 7 + TIGHT_JPEG_QUALITIES = dict(zip( # JPEG Quality Level Pseudo-encoding + [-32, -31, -30, -29, -28, -27, -26, -25, -24, -23], + [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + )) + + [email protected](frozen=True) +class RfbClientEncodings: + encodings: FrozenSet[int] + + has_resize: bool = dataclasses.field(default=False) + has_rename: bool = dataclasses.field(default=False) + has_leds_state: bool = dataclasses.field(default=False) + + has_tight: bool = dataclasses.field(default=False) + tight_jpeg_quality: int = dataclasses.field(default=0) + + def __post_init__(self) -> None: + self.__set("has_resize", (RfbEncodings.RESIZE in self.encodings)) + self.__set("has_rename", (RfbEncodings.RENAME in self.encodings)) + self.__set("has_leds_state", (RfbEncodings.LEDS_STATE in self.encodings)) + + self.__set("has_tight", (RfbEncodings.TIGHT in self.encodings)) + self.__set("tight_jpeg_quality", self.__get_tight_jpeg_quality()) + + def __set(self, key: str, value: Any) -> None: + object.__setattr__(self, key, value) + + def __get_tight_jpeg_quality(self) -> int: + if RfbEncodings.TIGHT in self.encodings: + qualities = self.encodings.intersection(RfbEncodings.TIGHT_JPEG_QUALITIES) + if qualities: + return RfbEncodings.TIGHT_JPEG_QUALITIES[max(qualities)] + return 0 diff --git a/kvmd/apps/vnc/rfb/errors.py b/kvmd/apps/vnc/rfb/errors.py new file mode 100644 index 00000000..d53bf1a4 --- /dev/null +++ b/kvmd/apps/vnc/rfb/errors.py @@ -0,0 +1,29 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +# ========================================================================== # + + +class RfbError(Exception): + pass + + +class RfbConnectionError(RfbError): + def __init__(self, err: Exception) -> None: + super().__init__(type(err).__name__) diff --git a/kvmd/apps/vnc/rfb/stream.py b/kvmd/apps/vnc/rfb/stream.py new file mode 100644 index 00000000..843cfe54 --- /dev/null +++ b/kvmd/apps/vnc/rfb/stream.py @@ -0,0 +1,109 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +# ========================================================================== # + + +import asyncio +import struct + +from typing import Tuple +from typing import Any + +from .errors import RfbConnectionError + + +# ===== +class RfbClientStream: + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + self.__reader = reader + self.__writer = writer + + self._remote = "[%s]:%d" % (self.__writer.transport.get_extra_info("peername")[:2]) + + # ===== + + async def _read_number(self, fmt: str) -> int: + assert len(fmt) == 1 + try: + if fmt == "B": + return (await self.__reader.readexactly(1))[0] + else: + fmt = f">{fmt}" + return struct.unpack(fmt, await self.__reader.readexactly(struct.calcsize(fmt)))[0] + except (ConnectionError, asyncio.IncompleteReadError) as err: + raise RfbConnectionError(err) + + async def _read_struct(self, fmt: str) -> Tuple[int, ...]: + assert len(fmt) > 1 + try: + fmt = f">{fmt}" + return struct.unpack(fmt, (await self.__reader.readexactly(struct.calcsize(fmt)))) + except (ConnectionError, asyncio.IncompleteReadError) as err: + raise RfbConnectionError(err) + + async def _read_text(self, length: int) -> str: + try: + return (await self.__reader.readexactly(length)).decode("utf-8", errors="ignore") + except (ConnectionError, asyncio.IncompleteReadError) as err: + raise RfbConnectionError(err) + + # ===== + + async def _write_struct(self, fmt: str, *values: Any, drain: bool=True) -> None: + try: + if not fmt: + for value in values: + self.__writer.write(value) + elif fmt == "B": + assert len(values) == 1 + self.__writer.write(bytes([values[0]])) + else: + self.__writer.write(struct.pack(f">{fmt}", *values)) + if drain: + await self.__writer.drain() + except ConnectionError as err: + raise RfbConnectionError(err) + + async def _write_reason(self, text: str, drain: bool=True) -> None: + encoded = text.encode("utf-8", errors="ignore") + await self._write_struct("L", len(encoded), drain=False) + try: + self.__writer.write(encoded) + if drain: + await self.__writer.drain() + except ConnectionError as err: + raise RfbConnectionError(err) + + async def _write_fb_update(self, width: int, height: int, encoding: int, drain: bool=True) -> None: + await self._write_struct( + "BxH HH HH l", + 0, # FB update + 1, # Number of rects + 0, 0, width, height, encoding, + drain=drain, + ) + + # ===== + + def _close(self) -> None: + try: + self.__writer.close() + except Exception: + pass diff --git a/kvmd/apps/vnc/server.py b/kvmd/apps/vnc/server.py index b4aa9de7..e9872b90 100644 --- a/kvmd/apps/vnc/server.py +++ b/kvmd/apps/vnc/server.py @@ -36,8 +36,8 @@ from ...logging import get_logger from ... import aiotools -from .rfb import RfbError from .rfb import RfbClient +from .rfb.errors import RfbError from .kvmd import KvmdClient @@ -96,6 +96,7 @@ def main() -> None: "kvmd.apps.cleanup", "kvmd.apps.ipmi", "kvmd.apps.vnc", + "kvmd.apps.vnc.rfb", "kvmd.helpers", "kvmd.helpers.otgmsd", "kvmd.helpers.otgmsd.unlock", |