summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--PKGBUILD2
-rw-r--r--configs/kvmd/vncpasswd12
-rw-r--r--kvmd/apps/__init__.py7
-rw-r--r--kvmd/apps/vnc/__init__.py2
-rw-r--r--kvmd/apps/vnc/rfb/__init__.py132
-rw-r--r--kvmd/apps/vnc/rfb/crypto.py53
-rw-r--r--kvmd/apps/vnc/server.py104
-rw-r--r--kvmd/apps/vnc/vncauth.py89
-rw-r--r--testenv/v1-vga-rpi3.override.yaml4
-rw-r--r--testenv/v2-hdmi-rpi4.override.yaml4
10 files changed, 324 insertions, 85 deletions
diff --git a/PKGBUILD b/PKGBUILD
index d20d8a9a..3fc78339 100644
--- a/PKGBUILD
+++ b/PKGBUILD
@@ -57,7 +57,7 @@ source=("$url/archive/v$pkgver.tar.gz")
md5sums=(SKIP)
backup=(
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
)
diff --git a/configs/kvmd/vncpasswd b/configs/kvmd/vncpasswd
new file mode 100644
index 00000000..28c2a19d
--- /dev/null
+++ b/configs/kvmd/vncpasswd
@@ -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
diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py
index 9af30205..82ca1fa7 100644
--- a/kvmd/apps/__init__.py
+++ b/kvmd/apps/__init__.py
@@ -345,5 +345,12 @@ def _get_config_scheme() -> Dict:
"unix": Option("", type=valid_abs_path, only_if="!port", unpack_as="unix_path"),
"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"),
+ },
+ },
},
}
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
diff --git a/testenv/v1-vga-rpi3.override.yaml b/testenv/v1-vga-rpi3.override.yaml
index dc028381..718112f7 100644
--- a/testenv/v1-vga-rpi3.override.yaml
+++ b/testenv/v1-vga-rpi3.override.yaml
@@ -26,3 +26,7 @@ kvmd:
vnc:
keymap: /usr/share/kvmd/keymaps/ru
+
+ auth:
+ vncauth:
+ enabled: true
diff --git a/testenv/v2-hdmi-rpi4.override.yaml b/testenv/v2-hdmi-rpi4.override.yaml
index ceb51b4a..b5003a55 100644
--- a/testenv/v2-hdmi-rpi4.override.yaml
+++ b/testenv/v2-hdmi-rpi4.override.yaml
@@ -34,3 +34,7 @@ kvmd:
vnc:
keymap: /usr/share/kvmd/keymaps/ru
+
+ auth:
+ vncauth:
+ enabled: true