diff options
Diffstat (limited to 'kvmd/apps/vnc')
-rw-r--r-- | kvmd/apps/vnc/__init__.py | 2 | ||||
-rw-r--r-- | kvmd/apps/vnc/rfb/__init__.py | 132 | ||||
-rw-r--r-- | kvmd/apps/vnc/rfb/crypto.py | 53 | ||||
-rw-r--r-- | kvmd/apps/vnc/server.py | 104 | ||||
-rw-r--r-- | kvmd/apps/vnc/vncauth.py | 89 |
5 files changed, 296 insertions, 84 deletions
diff --git a/kvmd/apps/vnc/__init__.py b/kvmd/apps/vnc/__init__.py index 568a34d7..8d863bcc 100644 --- a/kvmd/apps/vnc/__init__.py +++ b/kvmd/apps/vnc/__init__.py @@ -27,6 +27,7 @@ from .. import init from .kvmd import KvmdClient from .streamer import StreamerClient +from .vncauth import VncAuthManager from .server import VncServer from .keysym import build_symmap @@ -43,6 +44,7 @@ def main(argv: Optional[List[str]]=None) -> None: VncServer( kvmd=KvmdClient(**config.kvmd._unpack()), streamer=StreamerClient(**config.streamer._unpack()), + vnc_auth_manager=VncAuthManager(**config.auth.vncauth._unpack()), desired_fps=config.desired_fps, symmap=build_symmap(config.keymap), **config.server._unpack(), diff --git a/kvmd/apps/vnc/rfb/__init__.py b/kvmd/apps/vnc/rfb/__init__.py index 8dfb6fe2..698b5084 100644 --- a/kvmd/apps/vnc/rfb/__init__.py +++ b/kvmd/apps/vnc/rfb/__init__.py @@ -22,7 +22,10 @@ import asyncio +from typing import Tuple +from typing import List from typing import Dict +from typing import Callable from typing import Coroutine from ....logging import get_logger @@ -35,6 +38,9 @@ from .errors import RfbConnectionError from .encodings import RfbEncodings from .encodings import RfbClientEncodings +from .crypto import rfb_make_challenge +from .crypto import rfb_encrypt_challenge + from .stream import RfbClientStream @@ -52,14 +58,17 @@ class RfbClient(RfbClientStream): width: int, height: int, name: str, + vnc_passwds: List[str], ) -> None: super().__init__(reader, writer) self._width = width self._height = height - self._name = name + self.__name = name + self.__vnc_passwds = vnc_passwds + self.__rfb_version = 0 self._encodings = RfbClientEncodings(frozenset()) self._lock = asyncio.Lock() @@ -90,14 +99,14 @@ class RfbClient(RfbClientStream): 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)) + logger.error("[%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_version() + await self.__handshake_security() await self.__handshake_init() await self.__main_loop() finally: @@ -105,7 +114,10 @@ class RfbClient(RfbClientStream): # ===== - async def _authorize(self, user: str, passwd: str) -> bool: + async def _authorize_userpass(self, user: str, passwd: str) -> bool: + raise NotImplementedError + + async def _on_authorized_vnc_passwd(self, passwd: str) -> str: raise NotImplementedError async def _on_key_event(self, code: int, state: bool) -> None: @@ -148,7 +160,7 @@ class RfbClient(RfbClientStream): assert self._encodings.has_rename await self._write_fb_update(0, 0, RfbEncodings.RENAME, drain=False) await self._write_reason(name) - self._name = name + self.__name = name async def _send_leds_state(self, caps: bool, scroll: bool, num: bool) -> None: assert self._encodings.has_leds_state @@ -157,7 +169,7 @@ class RfbClient(RfbClientStream): # ===== - async def __handshake_version(self) -> int: + async def __handshake_version(self) -> None: # 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 @@ -176,36 +188,34 @@ class RfbClient(RfbClientStream): version = int(response[-2]) except ValueError: raise RfbError(f"Invalid version response: {response!r}") - return (3 if version == 5 else version) + self.__rfb_version = (3 if version == 5 else version) + get_logger(0).info("[main] Client %s: Using RFB version 3.%d", self._remote, self.__rfb_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})") - - # ----- - + async def __handshake_security(self) -> None: + sec_types: Dict[int, Tuple[str, Callable]] = {} + if self.__rfb_version > 3: + sec_types[19] = ("VeNCrypt", self.__handshake_security_vencrypt) + if self.__vnc_passwds: + sec_types[2] = ("VNCAuth", self.__handshake_security_vnc_auth) + if not sec_types: + msg = "The client uses a very old protocol 3.3 and VNCAuth is disabled" + await self._write_struct("L", 0, drain=False) # Refuse old clients using the invalid security type + await self._write_reason(msg) + raise RfbError(msg) + + await self._write_struct("B" + "B" * len(sec_types), len(sec_types), *sec_types) # Keep dict priority + + sec_type = await self._read_number("B") + if sec_type not in sec_types: + raise RfbError(f"Invalid security type: {sec_type}") + + (sec_name, handler) = sec_types[sec_type] + get_logger(0).info("[main] Client %s: Using %s security type", self._remote, sec_name) + await handler() + + async def __handshake_security_vencrypt(self) -> None: await self._write_struct("BB", 0, 2) # VeNCrypt 0.2 vencrypt_version = "%d.%d" % (await self._read_struct("BB")) @@ -215,29 +225,59 @@ class RfbClient(RfbClientStream): await self._write_struct("B", 0) - # ----- + auth_types = {256: ("VeNCrypt/Plain", self.__handshake_security_vencrypt_userpass)} + if self.__vnc_passwds: + # Vinagre не умеет работать с VNC Auth через VeNCrypt, но это его проблемы, + # так как он своеобразно трактует рекомендации VeNCrypt. + # Подробнее: https://bugzilla.redhat.com/show_bug.cgi?id=692048 + # Hint: используйте любой другой нормальный VNC-клиент. + auth_types[2] = ("VeNCrypt/VNCAuth", self.__handshake_security_vnc_auth) - plain = 256 - await self._write_struct("B L", 1, plain) # One auth subtype, plain + await self._write_struct("B" + "L" * len(auth_types), len(auth_types), *auth_types) auth_type = await self._read_number("L") - if auth_type != plain: - raise RfbError(f"Invalid auth type: {auth_type}; expected Plain({plain})") + if auth_type not in auth_types: + raise RfbError(f"Invalid VeNCrypt auth type: {auth_type}") - # ----- + (auth_name, handler) = auth_types[auth_type] + get_logger(0).info("[main] Client %s: Using %s auth type", self._remote, auth_name) + await handler() + async def __handshake_security_vencrypt_userpass(self) -> None: (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)): + ok = await self._authorize_userpass(user, passwd) + + await self.__handshake_security_send_result(ok, user) + + async def __handshake_security_vnc_auth(self) -> None: + challenge = rfb_make_challenge() + await self._write_struct("", challenge) + + (ok, user) = (False, "") + response = (await self._read_struct("16s"))[0] + for passwd in self.__vnc_passwds: + passwd_bytes = passwd.encode("utf-8", errors="ignore") + if rfb_encrypt_challenge(challenge, passwd_bytes) == response: + user = await self._on_authorized_vnc_passwd(passwd) + if user: + ok = True + break + + await self.__handshake_security_send_result(ok, user) + + async def __handshake_security_send_result(self, ok: bool, user: str) -> None: + if ok: + assert user 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}") + await self._write_struct("L", 1, drain=(self.__rfb_version < 8)) + if self.__rfb_version >= 8: + await self._write_reason("Invalid username or password" if user else "Invalid password") + raise RfbError(f"Access denied for user {user!r}" if user else "Access denied") # ===== @@ -259,7 +299,7 @@ class RfbClient(RfbClientStream): 0, # Blue shift drain=False, ) - await self._write_reason(self._name) + await self._write_reason(self.__name) # ===== diff --git a/kvmd/apps/vnc/rfb/crypto.py b/kvmd/apps/vnc/rfb/crypto.py new file mode 100644 index 00000000..7b364cb1 --- /dev/null +++ b/kvmd/apps/vnc/rfb/crypto.py @@ -0,0 +1,53 @@ +# ========================================================================== # +# # +# 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 os + +from typing import List + +import passlib.crypto.des + + +# ===== +def rfb_make_challenge() -> bytes: + return os.urandom(16) + + +def rfb_encrypt_challenge(challenge: bytes, passwd: bytes) -> bytes: + assert len(challenge) == 16 + key = _make_key(passwd) + return ( + passlib.crypto.des.des_encrypt_block(key, challenge[:8]) + + passlib.crypto.des.des_encrypt_block(key, challenge[8:]) + ) + + +def _make_key(passwd: bytes) -> bytes: + passwd = (passwd + b"\0" * 8)[:8] + key: List[int] = [] + for ch in passwd: + btgt = 0 + for index in range(8): + if ch & (1 << index): + btgt = btgt | (1 << 7 - index) + key.append(btgt) + return bytes(key) diff --git a/kvmd/apps/vnc/server.py b/kvmd/apps/vnc/server.py index e9872b90..084f9b0f 100644 --- a/kvmd/apps/vnc/server.py +++ b/kvmd/apps/vnc/server.py @@ -39,6 +39,9 @@ from ... import aiotools from .rfb import RfbClient from .rfb.errors import RfbError +from .vncauth import VncAuthKvmdCredentials +from .vncauth import VncAuthManager + from .kvmd import KvmdClient from .streamer import StreamerError @@ -66,11 +69,14 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes desired_fps: int, symmap: Dict[int, str], + vnc_credentials: Dict[str, VncAuthKvmdCredentials], shared_params: _SharedParams, ) -> None: - super().__init__(reader, writer, **dataclasses.asdict(shared_params)) + self.__vnc_credentials = vnc_credentials + + super().__init__(reader, writer, vnc_passwds=list(vnc_credentials), **dataclasses.asdict(shared_params)) self.__kvmd = kvmd self.__streamer = streamer @@ -208,12 +214,18 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes # ===== - async def _authorize(self, user: str, passwd: str) -> bool: + async def _authorize_userpass(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_authorized_vnc_passwd(self, passwd: str) -> str: + kc = self.__vnc_credentials[passwd] + if (await self._authorize_userpass(kc.user, kc.passwd)): + return kc.user + return "" + async def _on_key_event(self, code: int, state: bool) -> None: if (web_name := self.__symmap.get(code)) is not None: # noqa: E203,E231 await self.__ws_writer_queue.put({ @@ -258,7 +270,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes # ===== -class VncServer: +class VncServer: # pylint: disable=too-many-instance-attributes def __init__( self, host: str, @@ -267,6 +279,7 @@ class VncServer: kvmd: KvmdClient, streamer: StreamerClient, + vnc_auth_manager: VncAuthManager, desired_fps: int, symmap: Dict[int, str], @@ -276,43 +289,58 @@ class VncServer: self.__port = port self.__max_clients = max_clients - self.__client_kwargs = { - "kvmd": kvmd, - "streamer": streamer, - "desired_fps": desired_fps, - "symmap": symmap, - "shared_params": _SharedParams(), - } - - def run(self) -> None: - logger = get_logger(0) - logger.info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port) + self.__kvmd = kvmd + self.__streamer = streamer + self.__vnc_auth_manager = vnc_auth_manager - with contextlib.closing(socket.socket(socket.AF_INET6, socket.SOCK_STREAM)) as sock: - sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False) - sock.bind((self.__host, self.__port)) + self.__desired_fps = desired_fps + self.__symmap = symmap - 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, - )) + self.__shared_params = _SharedParams() - 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") + def run(self) -> None: + logger = get_logger(0) + loop = asyncio.get_event_loop() + try: + if not loop.run_until_complete(self.__vnc_auth_manager.read_credentials())[1]: + raise SystemExit(1) + + logger.info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port) + + with contextlib.closing(socket.socket(socket.AF_INET6, socket.SOCK_STREAM)) as sock: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False) + sock.bind((self.__host, self.__port)) + + 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()) + finally: + 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.__client_kwargs).run() # type: ignore + await _Client( + reader=reader, + writer=writer, + kvmd=self.__kvmd, + streamer=self.__streamer, + desired_fps=self.__desired_fps, + symmap=self.__symmap, + vnc_credentials=(await self.__vnc_auth_manager.read_credentials())[0], + shared_params=self.__shared_params, + ).run() # type: ignore diff --git a/kvmd/apps/vnc/vncauth.py b/kvmd/apps/vnc/vncauth.py new file mode 100644 index 00000000..2a66ee1d --- /dev/null +++ b/kvmd/apps/vnc/vncauth.py @@ -0,0 +1,89 @@ +# ========================================================================== # +# # +# 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 Tuple +from typing import Dict + +import aiofiles + +from ...logging import get_logger + + +# ===== +class VncAuthError(Exception): + def __init__(self, msg: str) -> None: + super().__init__(f"Incorrect VNCAuth passwd file: {msg}") + + +# ===== [email protected](frozen=True) +class VncAuthKvmdCredentials: + user: str + passwd: str + + +class VncAuthManager: + def __init__( + self, + path: str, + enabled: bool, + ) -> None: + + self.__path = path + self.__enabled = enabled + + async def read_credentials(self) -> Tuple[Dict[str, VncAuthKvmdCredentials], bool]: + if self.__enabled: + try: + return (await self.__inner_read_credentials(), True) + except VncAuthError as err: + get_logger(0).error(str(err)) + except Exception: + get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file") + return ({}, (not self.__enabled)) + + async def __inner_read_credentials(self) -> Dict[str, VncAuthKvmdCredentials]: + async with aiofiles.open(self.__path) as vc_file: + lines = (await vc_file.read()).split("\n") + + credentials: Dict[str, VncAuthKvmdCredentials] = {} + for (number, line) in enumerate(lines): + if len(line.strip()) == 0 or line.lstrip().startswith("#"): + continue + + if " -> " not in line: + raise VncAuthError(f"Missing ' -> ' operator at line #{number}") + + (vnc_passwd, kvmd_userpass) = map(str.lstrip, line.split(" -> ", 1)) + if ":" not in kvmd_userpass: + raise VncAuthError(f"Missing ':' operator in KVMD credentials (right part) at line #{number}") + + (kvmd_user, kvmd_passwd) = kvmd_userpass.split(":") + kvmd_user = kvmd_user.strip() + + if vnc_passwd in credentials: + raise VncAuthError(f"Found duplicating VNC password (left part) at line #{number}") + + credentials[vnc_passwd] = VncAuthKvmdCredentials(kvmd_user, kvmd_passwd) + return credentials |