This commit is contained in:
Devaev Maxim
2020-03-20 03:07:27 +03:00
parent ab6264bd5e
commit d5ae32b132
70 changed files with 28455 additions and 102 deletions

View File

@@ -320,4 +320,29 @@ def _get_config_scheme() -> Dict:
"file": Option("/etc/kvmd/ipmipasswd", type=valid_abs_file, unpack_as="path"),
},
},
"vnc": {
"keymap": Option("", type=valid_abs_path),
"server": {
"host": Option("::", type=valid_ip_or_host),
"port": Option(5900, type=valid_port),
# TODO: timeout
"max_clients": Option(10, type=(lambda arg: valid_number(arg, min=1))),
},
"kvmd": {
"host": Option("localhost", type=valid_ip_or_host),
"port": Option(0, type=valid_port),
"unix": Option("", type=valid_abs_path, only_if="!port", unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
"streamer": {
"host": Option("localhost", type=valid_ip_or_host),
"port": Option(0, type=valid_port),
"unix": Option("", type=valid_abs_path, only_if="!port", unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
},
}

48
kvmd/apps/vnc/__init__.py Normal file
View File

@@ -0,0 +1,48 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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/>. #
# #
# ========================================================================== #
from typing import List
from typing import Optional
from .. import init
from .kvmd import KvmdClient
from .streamer import StreamerClient
from .server import VncServer
from .keysym import build_symmap
# =====
def main(argv: Optional[List[str]]=None) -> None:
config = init(
prog="kvmd-vnc",
description="VNC to KVMD proxy",
argv=argv,
)[2].vnc
# pylint: disable=protected-access
VncServer(
kvmd=KvmdClient(**config.kvmd._unpack()),
streamer=StreamerClient(**config.streamer._unpack()),
symmap=build_symmap(config.keymap),
**config.server._unpack(),
).run()

24
kvmd/apps/vnc/__main__.py Normal file
View File

@@ -0,0 +1,24 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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/>. #
# #
# ========================================================================== #
from . import main
main()

Binary file not shown.

97
kvmd/apps/vnc/keysym.py Normal file
View File

@@ -0,0 +1,97 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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 pkgutil
import functools
from typing import Dict
import Xlib.keysymdef
from ...logging import get_logger
from ... import keymap
# =====
def build_symmap(path: str) -> Dict[int, str]:
# https://github.com/qemu/qemu/blob/95a9457fd44ad97c518858a4e1586a5498f9773c/ui/keymaps.c
symmap: Dict[int, str] = {}
for (x11_code, at1_code) in keymap.X11_TO_AT1.items():
symmap[x11_code] = keymap.AT1_TO_WEB[at1_code]
for (x11_code, at1_code) in _read_keyboard_layout(path).items():
if (web_name := keymap.AT1_TO_WEB.get(at1_code)) is not None: # noqa: E203,E231
# mypy bug
symmap[x11_code] = web_name # type: ignore
return symmap
# =====
@functools.lru_cache()
def _get_keysyms() -> Dict[str, int]:
keysyms: Dict[str, int] = {}
for (loader, module_name, _) in pkgutil.walk_packages(Xlib.keysymdef.__path__):
module = loader.find_module(module_name).load_module(module_name)
for keysym_name in dir(module):
if keysym_name.startswith("XK_"):
short_name = keysym_name[3:]
if short_name.startswith("XF86_"):
short_name = "XF86" + short_name[5:]
# assert short_name not in keysyms, short_name
keysyms[short_name] = int(getattr(module, keysym_name))
return keysyms
def _resolve_keysym(name: str) -> int:
code = _get_keysyms().get(name)
if code is not None:
return code
if len(name) == 5 and name[0] == "U": # Try unicode Uxxxx
try:
return int(name[1:], 16)
except ValueError:
pass
return 0
def _read_keyboard_layout(path: str) -> Dict[int, int]: # Keysym to evdev (at1)
logger = get_logger(0)
logger.info("Reading keyboard layout %s ...", path)
with open(path) as layout_file:
lines = list(map(str.strip, layout_file.read().split("\n")))
layout: Dict[int, int] = {}
for (number, line) in enumerate(lines):
if len(line) == 0 or line.startswith(("#", "map ", "include ")):
continue
parts = line.split()
if len(parts) >= 2:
if (code := _resolve_keysym(parts[0])) != 0: # noqa: E203,E231
try:
layout[code] = int(parts[1], 16)
except ValueError as err:
logger.error("Can't parse layout line #%d: %s", number, str(err))
return layout

110
kvmd/apps/vnc/kvmd.py Normal file
View File

@@ -0,0 +1,110 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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 contextlib
from typing import Dict
import aiohttp
from ...logging import get_logger
from ... import __version__
# =====
class KvmdClient:
def __init__(
self,
host: str,
port: int,
unix_path: str,
timeout: float,
) -> None:
assert port or unix_path
self.__host = host
self.__port = port
self.__unix_path = unix_path
self.__timeout = timeout
# =====
async def authorize(self, user: str, passwd: str) -> bool:
try:
async with self.__make_session(user, passwd) as session:
async with session.get(
url=f"http://{self.__host}:{self.__port}/auth/check",
timeout=self.__timeout,
) as response:
response.raise_for_status()
if response.status == 200:
return True
raise RuntimeError(f"Invalid OK response: {response.status} {await response.text()}")
except aiohttp.ClientResponseError as err:
if err.status in [401, 403]:
return False
get_logger(0).exception("Can't check user access")
except Exception:
get_logger(0).exception("Can't check user access")
return False
@contextlib.asynccontextmanager
async def ws(self, user: str, passwd: str) -> aiohttp.ClientWebSocketResponse: # pylint: disable=invalid-name
async with self.__make_session(user, passwd) as session:
async with session.ws_connect(
url=f"http://{self.__host}:{self.__port}/ws",
timeout=self.__timeout,
) as ws:
yield ws
async def set_streamer_params(self, user: str, passwd: str, quality: int=-1, desired_fps: int=-1) -> None:
params = {
key: value
for (key, value) in [
("quality", quality),
("desired_fps", desired_fps),
]
if value >= 0
}
if params:
async with self.__make_session(user, passwd) as session:
async with session.post(
url=f"http://{self.__host}:{self.__port}/streamer/set_params",
timeout=self.__timeout,
params=params,
) as response:
response.raise_for_status()
# =====
def __make_session(self, user: str, passwd: str) -> aiohttp.ClientSession:
kwargs: Dict = {
"headers": {
"X-KVMD-User": user,
"X-KVMD-Passwd": passwd,
"User-Agent": f"KVMD-VNC/{__version__}",
},
}
if self.__unix_path:
kwargs["connector"] = aiohttp.UnixConnector(path=self.__unix_path)
return aiohttp.ClientSession(**kwargs)

53
kvmd/apps/vnc/render.py Normal file
View File

@@ -0,0 +1,53 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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 sys
import os
import io
import functools
from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont
from ... import aiotools
# =====
async def make_text_jpeg(width: int, height: int, quality: int, text: str) -> bytes:
return (await aiotools.run_async(_inner_make_text_jpeg, width, height, quality, text))
@functools.lru_cache(maxsize=10)
def _inner_make_text_jpeg(width: int, height: int, quality: int, text: str) -> bytes:
image = Image.new("RGB", (width, height), color=(0, 0, 0))
draw = ImageDraw.Draw(image)
draw.multiline_text((20, 20), text, font=_get_font(), fill=(255, 255, 255))
bio = io.BytesIO()
image.save(bio, format="jpeg", quality=quality)
return bio.getvalue()
@functools.lru_cache()
def _get_font() -> ImageFont.FreeTypeFont:
path = os.path.join(os.path.dirname(sys.modules[__name__].__file__), "fonts", "Azbuka04.ttf")
return ImageFont.truetype(path, size=20)

437
kvmd/apps/vnc/rfb.py Normal file
View File

@@ -0,0 +1,437 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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],
))
@dataclasses.dataclass(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:
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)))
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": (32 if buttons & 0x40 else (-32 if buttons & 0x20 else 0)),
"y": (32 if buttons & 0x10 else (-32 if buttons & 0x8 else 0)),
},
move={
"x": round(to_x / self._width * 65535 + -32768),
"y": round(to_y / self._width * 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,
)

307
kvmd/apps/vnc/server.py Normal file
View File

@@ -0,0 +1,307 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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 asyncio.queues
import socket
import dataclasses
import json
from typing import Dict
from typing import Optional
import aiohttp
from ...logging import get_logger
from ... import aiotools
from .rfb import RfbClient
from .kvmd import KvmdClient
from .streamer import StreamerError
from .streamer import StreamerClient
from .render import make_text_jpeg
# =====
@dataclasses.dataclass()
class _SharedParams:
width: int = dataclasses.field(default=800)
height: int = dataclasses.field(default=600)
name: str = dataclasses.field(default="Pi-KVM")
class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
def __init__(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
kvmd: KvmdClient,
streamer: StreamerClient,
symmap: Dict[int, str],
shared_params: _SharedParams,
) -> None:
super().__init__(reader, writer, **dataclasses.asdict(shared_params))
self.__kvmd = kvmd
self.__streamer = streamer
self.__symmap = symmap
self.__shared_params = shared_params
self.__authorized = asyncio.Future() # type: ignore
self.__ws_connected = asyncio.Future() # type: ignore
self.__ws_writer_queue: asyncio.queues.Queue = asyncio.Queue()
self.__fb_requested = False
self.__fb_stub_text = ""
self.__fb_stub_quality = 0
# Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события.
# Все это нужно только чтобы не посылать лишние жсоны в сокет KVMD
self.__mouse_buttons: Dict[str, Optional[bool]] = {"left": None, "right": None, "middle": None}
self.__mouse_move = {"x": -1, "y": -1}
# =====
async def run(self) -> None:
await self._run(
kvmd=self.__kvmd_task_loop(),
streamer=self.__streamer_task_loop(),
)
# =====
async def __kvmd_task_loop(self) -> None:
logger = get_logger(0)
await self.__authorized
(user, passwd) = self.__authorized.result()
async with self.__kvmd.ws(user, passwd) as ws:
logger.info("[kvmd] Client %s: Connected to KVMD websocket", self._remote)
self.__ws_connected.set_result(None)
receive_task: Optional[asyncio.Task] = None
writer_task: Optional[asyncio.Task] = None
try:
while True:
if receive_task is None:
receive_task = asyncio.create_task(ws.receive())
if writer_task is None:
writer_task = asyncio.create_task(self.__ws_writer_queue.get())
done = (await aiotools.wait_first(receive_task, writer_task))[0]
if receive_task in done:
msg = receive_task.result()
if msg.type == aiohttp.WSMsgType.TEXT:
await self.__process_ws_event(json.loads(msg.data))
else:
raise RuntimeError(f"Unknown WS message type: {msg!r}")
receive_task = None
if writer_task in done:
await ws.send_str(json.dumps(writer_task.result()))
writer_task = None
finally:
if receive_task:
receive_task.cancel()
if writer_task:
writer_task.cancel()
async def __process_ws_event(self, event: Dict) -> None:
if event["event_type"] == "info_state":
host = event["event"]["meta"].get("server", {}).get("host")
if isinstance(host, str):
name = f"Pi-KVM: {host}"
async with self._lock:
if self._encodings.has_rename:
await self._send_rename(name)
self.__shared_params.name = name
elif event["event_type"] == "hid_state":
async with self._lock:
if self._encodings.has_leds_state:
await self._send_leds_state(**event["event"]["keyboard"]["leds"])
# =====
async def __streamer_task_loop(self) -> None:
logger = get_logger(0)
await self.__ws_connected
while True:
try:
streaming = False
async for (online, width, height, jpeg) in self.__streamer.read():
if not streaming:
logger.info("[streamer] Client %s: Streaming ...", self._remote)
streaming = True
if online:
await self.__send_fb_real(width, height, jpeg)
else:
await self.__send_fb_stub("No signal")
except StreamerError as err:
logger.info("[streamer] Client %s: Waiting for stream: %s", self._remote, str(err))
await self.__send_fb_stub("Waiting for stream ...")
await asyncio.sleep(1)
async def __send_fb_real(self, width: int, height: int, jpeg: bytes) -> None:
async with self._lock:
if self.__fb_requested:
if (self._width, self._height) != (width, height):
self.__shared_params.width = width
self.__shared_params.height = height
if not self._encodings.has_resize:
msg = f"Resoultion changed: {self._width}x{self._height} -> {width}x{height}\nPlease reconnect"
await self.__send_fb_stub(msg, no_lock=True)
return
await self._send_resize(width, height)
await self._send_fb(jpeg)
self.__fb_stub_text = ""
self.__fb_stub_quality = 0
self.__fb_requested = False
async def __send_fb_stub(self, text: str, no_lock: bool=False) -> None:
if not no_lock:
await self._lock.acquire()
try:
if self.__fb_requested and (self.__fb_stub_text != text or self.__fb_stub_quality != self._encodings.tight_jpeg_quality):
await self._send_fb(await make_text_jpeg(self._width, self._height, self._encodings.tight_jpeg_quality, text))
self.__fb_stub_text = text
self.__fb_stub_quality = self._encodings.tight_jpeg_quality
self.__fb_requested = False
finally:
if not no_lock:
self._lock.release()
# =====
async def _authorize(self, user: str, passwd: str) -> bool:
if (await self.__kvmd.authorize(user, passwd)):
self.__authorized.set_result((user, passwd))
return True
return False
async def _on_key_event(self, code: int, state: bool) -> None:
print("KeyEvent", code, state, self.__symmap.get(code)) # TODO
async def _on_pointer_event(self, buttons: Dict[str, bool], wheel: Dict[str, int], move: Dict[str, int]) -> None:
for (button, state) in buttons.items():
if self.__mouse_buttons[button] != state:
await self.__ws_writer_queue.put({
"event_type": "mouse_button",
"event": {"button": button, "state": state},
})
self.__mouse_buttons[button] = state
if wheel["x"] or wheel["y"]:
await self.__ws_writer_queue.put({
"event_type": "mouse_wheel",
"event": {"delta": wheel},
})
if self.__mouse_move != move:
await self.__ws_writer_queue.put({
"event_type": "mouse_move",
"event": {"to": move},
})
self.__mouse_move = move
async def _on_cut_event(self, text: str) -> None:
print("CutEvent", text) # TODO
async def _on_set_encodings(self) -> None:
assert self.__authorized.done()
(user, passwd) = self.__authorized.result()
(quality, desired_fps) = (self._encodings.tight_jpeg_quality, 30)
get_logger(0).info("[main] Client %s: Applying streamer params: quality=%d%%; desired_fps=%d ...",
self._remote, quality, desired_fps)
await self.__kvmd.set_streamer_params(user, passwd, quality=quality, desired_fps=desired_fps)
async def _on_fb_update_request(self) -> None:
self.__fb_requested = True
# =====
class VncServer:
def __init__(
self,
host: str,
port: int,
max_clients: int,
kvmd: KvmdClient,
streamer: StreamerClient,
symmap: Dict[int, str],
) -> None:
self.__host = host
self.__port = port
self.__max_clients = max_clients
self.__kvmd = kvmd
self.__streamer = streamer
self.__symmap = symmap
self.__shared_params = _SharedParams()
def run(self) -> None:
logger = get_logger(0)
logger.info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port)
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False)
sock.bind((self.__host, self.__port))
loop = asyncio.get_event_loop()
server = loop.run_until_complete(asyncio.start_server(
client_connected_cb=self.__handle_client,
sock=sock,
backlog=self.__max_clients,
loop=loop,
))
try:
loop.run_forever()
except (SystemExit, KeyboardInterrupt):
pass
finally:
server.close()
loop.run_until_complete(server.wait_closed())
tasks = asyncio.Task.all_tasks()
for task in tasks:
task.cancel()
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
loop.close()
logger.info("Bye-bye")
async def __handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
await _Client(reader, writer, self.__kvmd, self.__streamer, self.__symmap, self.__shared_params).run()

83
kvmd/apps/vnc/streamer.py Normal file
View File

@@ -0,0 +1,83 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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/>. #
# #
# ========================================================================== #
from typing import Tuple
from typing import Dict
from typing import AsyncGenerator
import aiohttp
from ... import __version__
# =====
class StreamerError(Exception):
pass
# =====
class StreamerClient:
def __init__(
self,
host: str,
port: int,
unix_path: str,
timeout: float,
) -> None:
assert port or unix_path
self.__host = host
self.__port = port
self.__unix_path = unix_path
self.__timeout = timeout
async def read(self) -> AsyncGenerator[Tuple[bool, int, int, bytes], None]:
try:
async with self.__make_session() as session:
async with session.get(
url=f"http://{self.__host}:{self.__port}/stream",
params={"extra_headers": "1"},
headers={"User-Agent": f"KVMD-VNC/{__version__}"},
) as response:
response.raise_for_status()
reader = aiohttp.MultipartReader.from_response(response)
while True:
frame = await reader.next()
yield (
(frame.headers["X-UStreamer-Online"] == "true"),
int(frame.headers["X-UStreamer-Width"]),
int(frame.headers["X-UStreamer-Height"]),
bytes(await frame.read()),
)
except Exception as err: # Тут бывают и ассерты, и KeyError, и прочая херня из-за корявых исключений в MultipartReader
raise StreamerError(f"{type(err).__name__}: {str(err)}")
def __make_session(self) -> aiohttp.ClientSession:
kwargs: Dict = {
"timeout": aiohttp.ClientTimeout(
connect=self.__timeout,
sock_read=self.__timeout,
),
}
if self.__unix_path:
kwargs["connector"] = aiohttp.UnixConnector(path=self.__unix_path)
return aiohttp.ClientSession(**kwargs)