This commit is contained in:
Devaev Maxim 2020-03-26 06:26:56 +03:00
parent 94b779c586
commit 8fd2a597bb
10 changed files with 317 additions and 78 deletions

View File

@ -57,7 +57,7 @@ source=("$url/archive/v$pkgver.tar.gz")
md5sums=(SKIP) md5sums=(SKIP)
backup=( backup=(
etc/kvmd/{override,logging,auth,meta}.yaml etc/kvmd/{override,logging,auth,meta}.yaml
etc/kvmd/{ht,ipmi}passwd etc/kvmd/{ht,ipmi,vnc}passwd
etc/kvmd/nginx/{kvmd.ctx-{http,server},loc-{login,nocache,proxy,websocket},mime-types,ssl,nginx}.conf etc/kvmd/nginx/{kvmd.ctx-{http,server},loc-{login,nocache,proxy,websocket},mime-types,ssl,nginx}.conf
) )

12
configs/kvmd/vncpasswd Normal file
View File

@ -0,0 +1,12 @@
# This file describes the credentials for VNCAuth. The left part before arrow is a passphrase
# for VNCAuth. The right part is username and password with which the user can access to KVMD API.
# The arrow is used as a separator and shows the relationship of user registrations on the system.
#
# Never use the same passwords for VNC and IPMI users. This default configuration is shown here
# for example only.
#
# If this file does not contain any entries, VNCAuth will be disabled and you will only be able
# to login in using your KVMD username and password using VeNCrypt methods.
# pa$$phr@se -> admin:password
admin -> admin:admin

View File

@ -345,5 +345,12 @@ def _get_config_scheme() -> Dict:
"unix": Option("", type=valid_abs_path, only_if="!port", unpack_as="unix_path"), "unix": Option("", type=valid_abs_path, only_if="!port", unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01), "timeout": Option(5.0, type=valid_float_f01),
}, },
"auth": {
"vncauth": {
"enabled": Option(False, type=valid_bool),
"file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="path"),
},
},
}, },
} }

View File

@ -27,6 +27,7 @@ from .. import init
from .kvmd import KvmdClient from .kvmd import KvmdClient
from .streamer import StreamerClient from .streamer import StreamerClient
from .vncauth import VncAuthManager
from .server import VncServer from .server import VncServer
from .keysym import build_symmap from .keysym import build_symmap
@ -43,6 +44,7 @@ def main(argv: Optional[List[str]]=None) -> None:
VncServer( VncServer(
kvmd=KvmdClient(**config.kvmd._unpack()), kvmd=KvmdClient(**config.kvmd._unpack()),
streamer=StreamerClient(**config.streamer._unpack()), streamer=StreamerClient(**config.streamer._unpack()),
vnc_auth_manager=VncAuthManager(**config.auth.vncauth._unpack()),
desired_fps=config.desired_fps, desired_fps=config.desired_fps,
symmap=build_symmap(config.keymap), symmap=build_symmap(config.keymap),
**config.server._unpack(), **config.server._unpack(),

View File

@ -22,7 +22,10 @@
import asyncio import asyncio
from typing import Tuple
from typing import List
from typing import Dict from typing import Dict
from typing import Callable
from typing import Coroutine from typing import Coroutine
from ....logging import get_logger from ....logging import get_logger
@ -35,6 +38,9 @@ from .errors import RfbConnectionError
from .encodings import RfbEncodings from .encodings import RfbEncodings
from .encodings import RfbClientEncodings from .encodings import RfbClientEncodings
from .crypto import rfb_make_challenge
from .crypto import rfb_encrypt_challenge
from .stream import RfbClientStream from .stream import RfbClientStream
@ -52,14 +58,17 @@ class RfbClient(RfbClientStream):
width: int, width: int,
height: int, height: int,
name: str, name: str,
vnc_passwds: List[str],
) -> None: ) -> None:
super().__init__(reader, writer) super().__init__(reader, writer)
self._width = width self._width = width
self._height = height self._height = height
self._name = name self.__name = name
self.__vnc_passwds = vnc_passwds
self.__rfb_version = 0
self._encodings = RfbClientEncodings(frozenset()) self._encodings = RfbClientEncodings(frozenset())
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@ -90,14 +99,14 @@ class RfbClient(RfbClientStream):
except RfbConnectionError as err: except RfbConnectionError as err:
logger.info("[%s] Client %s: Gone (%s): Disconnected", name, self._remote, str(err)) logger.info("[%s] Client %s: Gone (%s): Disconnected", name, self._remote, str(err))
except RfbError as 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: except Exception:
logger.exception("[%s] Unhandled exception with client %s: Disconnected", name, self._remote) logger.exception("[%s] Unhandled exception with client %s: Disconnected", name, self._remote)
async def __main_task_loop(self) -> None: async def __main_task_loop(self) -> None:
try: try:
rfb_version = await self.__handshake_version() await self.__handshake_version()
await self.__handshake_security(rfb_version) await self.__handshake_security()
await self.__handshake_init() await self.__handshake_init()
await self.__main_loop() await self.__main_loop()
finally: 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 raise NotImplementedError
async def _on_key_event(self, code: int, state: bool) -> None: async def _on_key_event(self, code: int, state: bool) -> None:
@ -148,7 +160,7 @@ class RfbClient(RfbClientStream):
assert self._encodings.has_rename assert self._encodings.has_rename
await self._write_fb_update(0, 0, RfbEncodings.RENAME, drain=False) await self._write_fb_update(0, 0, RfbEncodings.RENAME, drain=False)
await self._write_reason(name) await self._write_reason(name)
self._name = name self.__name = name
async def _send_leds_state(self, caps: bool, scroll: bool, num: bool) -> None: async def _send_leds_state(self, caps: bool, scroll: bool, num: bool) -> None:
assert self._encodings.has_leds_state 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. # 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 # Version 3.5 was wrongly reported by some clients, but it should be
# interpreted by all servers as 3.3 # interpreted by all servers as 3.3
@ -176,36 +188,34 @@ class RfbClient(RfbClientStream):
version = int(response[-2]) version = int(response[-2])
except ValueError: except ValueError:
raise RfbError(f"Invalid version response: {response!r}") 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: async def __handshake_security(self) -> None:
if rfb_version == 3: sec_types: Dict[int, Tuple[str, Callable]] = {}
await self.__handshake_security_v3(rfb_version) if self.__rfb_version > 3:
else: sec_types[19] = ("VeNCrypt", self.__handshake_security_vencrypt)
await self.__handshake_security_v7_plus(rfb_version) 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)
async def __handshake_security_v3(self, rfb_version: int) -> None: await self._write_struct("B" + "B" * len(sec_types), len(sec_types), *sec_types) # Keep dict priority
assert rfb_version == 3
await self._write_struct("L", 0, drain=False) # Refuse old clients using the invalid security type sec_type = await self._read_number("B")
msg = "The client uses a very old protocol 3.3; required 3.7 at least" if sec_type not in sec_types:
await self._write_reason(msg) raise RfbError(f"Invalid security type: {sec_type}")
raise RfbError(msg)
async def __handshake_security_v7_plus(self, rfb_version: int) -> None: (sec_name, handler) = sec_types[sec_type]
assert rfb_version >= 7 get_logger(0).info("[main] Client %s: Using %s security type", self._remote, sec_name)
await handler()
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_vencrypt(self) -> None:
await self._write_struct("BB", 0, 2) # VeNCrypt 0.2 await self._write_struct("BB", 0, 2) # VeNCrypt 0.2
vencrypt_version = "%d.%d" % (await self._read_struct("BB")) vencrypt_version = "%d.%d" % (await self._read_struct("BB"))
@ -215,29 +225,59 @@ class RfbClient(RfbClientStream):
await self._write_struct("B", 0) 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" * len(auth_types), len(auth_types), *auth_types)
await self._write_struct("B L", 1, plain) # One auth subtype, plain
auth_type = await self._read_number("L") auth_type = await self._read_number("L")
if auth_type != plain: if auth_type not in auth_types:
raise RfbError(f"Invalid auth type: {auth_type}; expected Plain({plain})") 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_length, passwd_length) = await self._read_struct("LL")
user = await self._read_text(user_length) user = await self._read_text(user_length)
passwd = await self._read_text(passwd_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) get_logger(0).info("[main] Client %s: Access granted for user %r", self._remote, user)
await self._write_struct("L", 0) await self._write_struct("L", 0)
else: else:
await self._write_struct("L", 1, drain=(rfb_version < 8)) await self._write_struct("L", 1, drain=(self.__rfb_version < 8))
if rfb_version >= 8: if self.__rfb_version >= 8:
await self._write_reason("Invalid username or password") await self._write_reason("Invalid username or password" if user else "Invalid password")
raise RfbError(f"Access denied for user {user!r}") raise RfbError(f"Access denied for user {user!r}" if user else "Access denied")
# ===== # =====
@ -259,7 +299,7 @@ class RfbClient(RfbClientStream):
0, # Blue shift 0, # Blue shift
drain=False, drain=False,
) )
await self._write_reason(self._name) await self._write_reason(self.__name)
# ===== # =====

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 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)

View File

@ -39,6 +39,9 @@ from ... import aiotools
from .rfb import RfbClient from .rfb import RfbClient
from .rfb.errors import RfbError from .rfb.errors import RfbError
from .vncauth import VncAuthKvmdCredentials
from .vncauth import VncAuthManager
from .kvmd import KvmdClient from .kvmd import KvmdClient
from .streamer import StreamerError from .streamer import StreamerError
@ -66,11 +69,14 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
desired_fps: int, desired_fps: int,
symmap: Dict[int, str], symmap: Dict[int, str],
vnc_credentials: Dict[str, VncAuthKvmdCredentials],
shared_params: _SharedParams, shared_params: _SharedParams,
) -> None: ) -> 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.__kvmd = kvmd
self.__streamer = streamer 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)): if (await self.__kvmd.authorize(user, passwd)):
self.__authorized.set_result((user, passwd)) self.__authorized.set_result((user, passwd))
return True return True
return False 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: async def _on_key_event(self, code: int, state: bool) -> None:
if (web_name := self.__symmap.get(code)) is not None: # noqa: E203,E231 if (web_name := self.__symmap.get(code)) is not None: # noqa: E203,E231
await self.__ws_writer_queue.put({ 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__( def __init__(
self, self,
host: str, host: str,
@ -267,6 +279,7 @@ class VncServer:
kvmd: KvmdClient, kvmd: KvmdClient,
streamer: StreamerClient, streamer: StreamerClient,
vnc_auth_manager: VncAuthManager,
desired_fps: int, desired_fps: int,
symmap: Dict[int, str], symmap: Dict[int, str],
@ -276,43 +289,58 @@ class VncServer:
self.__port = port self.__port = port
self.__max_clients = max_clients self.__max_clients = max_clients
self.__client_kwargs = { self.__kvmd = kvmd
"kvmd": kvmd, self.__streamer = streamer
"streamer": streamer, self.__vnc_auth_manager = vnc_auth_manager
"desired_fps": desired_fps,
"symmap": symmap, self.__desired_fps = desired_fps
"shared_params": _SharedParams(), self.__symmap = symmap
}
self.__shared_params = _SharedParams()
def run(self) -> None: def run(self) -> None:
logger = get_logger(0) logger = get_logger(0)
logger.info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port) loop = asyncio.get_event_loop()
try:
if not loop.run_until_complete(self.__vnc_auth_manager.read_credentials())[1]:
raise SystemExit(1)
with contextlib.closing(socket.socket(socket.AF_INET6, socket.SOCK_STREAM)) as sock: logger.info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False)
sock.bind((self.__host, self.__port))
loop = asyncio.get_event_loop() with contextlib.closing(socket.socket(socket.AF_INET6, socket.SOCK_STREAM)) as sock:
server = loop.run_until_complete(asyncio.start_server( sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False)
client_connected_cb=self.__handle_client, sock.bind((self.__host, self.__port))
sock=sock,
backlog=self.__max_clients,
loop=loop,
))
try: server = loop.run_until_complete(asyncio.start_server(
loop.run_forever() client_connected_cb=self.__handle_client,
except (SystemExit, KeyboardInterrupt): sock=sock,
pass backlog=self.__max_clients,
finally: loop=loop,
server.close() ))
loop.run_until_complete(server.wait_closed())
tasks = asyncio.Task.all_tasks() try:
for task in tasks: loop.run_forever()
task.cancel() except (SystemExit, KeyboardInterrupt):
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) pass
loop.close() finally:
logger.info("Bye-bye") 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: 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

89
kvmd/apps/vnc/vncauth.py Normal file
View File

@ -0,0 +1,89 @@
# ========================================================================== #
# #
# 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 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}")
# =====
@dataclasses.dataclass(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

View File

@ -26,3 +26,7 @@ kvmd:
vnc: vnc:
keymap: /usr/share/kvmd/keymaps/ru keymap: /usr/share/kvmd/keymaps/ru
auth:
vncauth:
enabled: true

View File

@ -34,3 +34,7 @@ kvmd:
vnc: vnc:
keymap: /usr/share/kvmd/keymaps/ru keymap: /usr/share/kvmd/keymaps/ru
auth:
vncauth:
enabled: true