diff --git a/configs/kvmd/vncpasswd b/configs/kvmd/vncpasswd index 28c2a19d..6c1967a0 100644 --- a/configs/kvmd/vncpasswd +++ b/configs/kvmd/vncpasswd @@ -1,12 +1,9 @@ -# 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. +# This file contains passwords for the legacy VNCAuth, one per line. +# The passwords are NOT encrypted. # -# Never use the same passwords for VNC and IPMI users. This default configuration is shown here -# for example only. +# WARNING! The VNCAuth method is NOT secure and should not be used at all. +# But we support it for compatibility with some clients. # -# 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. +# NEVER use the same passwords for KVMD, IPMI and VNCAuth users. -# pa$$phr@se -> admin:password -admin -> admin:admin +admin diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 86b92522..61ef859b 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -364,6 +364,7 @@ def _get_config_scheme() -> dict: "usc": { "users": Option([ "kvmd-ipmi", + "kvmd-vnc", ], type=valid_users_list), # PiKVM username has a same regex as a UNIX username "groups": Option([], type=valid_users_list), # groupname has a same regex as a username }, @@ -798,8 +799,8 @@ def _get_config_scheme() -> dict: "auth": { "vncauth": { - "enabled": Option(False, type=valid_bool), - "file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="path"), + "enabled": Option(False, type=valid_bool, unpack_as="vncpass_enabled"), + "file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="vncpass_path"), }, "vencrypt": { "enabled": Option(True, type=valid_bool, unpack_as="vencrypt_enabled"), diff --git a/kvmd/apps/vnc/__init__.py b/kvmd/apps/vnc/__init__.py index 101312cb..eb7a1330 100644 --- a/kvmd/apps/vnc/__init__.py +++ b/kvmd/apps/vnc/__init__.py @@ -30,7 +30,6 @@ from ... import htclient from .. import init -from .vncauth import VncAuthManager from .server import VncServer @@ -76,8 +75,8 @@ def main(argv: (list[str] | None)=None) -> None: kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()), streamers=streamers, - vnc_auth_manager=VncAuthManager(**config.auth.vncauth._unpack()), **config.server.keepalive._unpack(), + **config.auth.vncauth._unpack(), **config.auth.vencrypt._unpack(), ).run() diff --git a/kvmd/apps/vnc/rfb/__init__.py b/kvmd/apps/vnc/rfb/__init__.py index 64350341..cbcd2946 100644 --- a/kvmd/apps/vnc/rfb/__init__.py +++ b/kvmd/apps/vnc/rfb/__init__.py @@ -67,7 +67,8 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute name: str, scroll_rate: int, allow_cut_after: float, - vnc_passwds: list[str], + + vncpasses: set[str], vencrypt: bool, none_auth_only: bool, ) -> None: @@ -84,7 +85,8 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute self.__name = name self.__scroll_rate = scroll_rate self.__allow_cut_after = allow_cut_after - self.__vnc_passwds = vnc_passwds + + self.__vncpasses = vncpasses self.__vencrypt = vencrypt self.__none_auth_only = none_auth_only @@ -145,10 +147,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute async def _authorize_userpass(self, user: str, passwd: str) -> bool: raise NotImplementedError - async def _on_authorized_vnc_passwd(self, passwd: str) -> str: + async def _on_authorized_vncpass(self) -> None: raise NotImplementedError - async def _on_authorized_none(self) -> bool: + async def _authorize_none(self) -> bool: raise NotImplementedError # ===== @@ -260,7 +262,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute sec_types[19] = ("VeNCrypt", self.__handshake_security_vencrypt) if self.__none_auth_only: sec_types[1] = ("None", self.__handshake_security_none) - elif self.__vnc_passwds: + elif self.__vncpasses: sec_types[2] = ("VNCAuth", self.__handshake_security_vnc_auth) if not sec_types: @@ -306,7 +308,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute if self.__x509_cert_path: auth_types[262] = ("VeNCrypt/X509Plain", 2, self.__handshake_security_vencrypt_userpass) auth_types[259] = ("VeNCrypt/TLSPlain", 1, self.__handshake_security_vencrypt_userpass) - if self.__vnc_passwds: + if self.__vncpasses: # Некоторые клиенты не умеют работать с нешифрованными соединениями внутри VeNCrypt: # - https://github.com/LibVNC/libvncserver/issues/458 # - https://bugzilla.redhat.com/show_bug.cgi?id=692048 @@ -356,7 +358,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute ) async def __handshake_security_none(self) -> None: - allow = await self._on_authorized_none() + allow = await self._authorize_none() await self.__handshake_security_send_result( allow=allow, allow_msg="NoneAuth access granted", @@ -368,20 +370,19 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute challenge = rfb_make_challenge() await self._write_struct("VNCAuth challenge request", "", challenge) - user = "" + allow = False response = (await self._read_struct("VNCAuth challenge response", "16s"))[0] - for passwd in self.__vnc_passwds: + for passwd in self.__vncpasses: 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: - assert user == user.strip() + await self._on_authorized_vncpass() + allow = True break await self.__handshake_security_send_result( - allow=bool(user), - allow_msg=f"VNCAuth access granted for user {user!r}", - deny_msg="VNCAuth access denied (user not found)", + allow=allow, + allow_msg="VNCAuth access granted", + deny_msg="VNCAuth access denied (passwd not found)", deny_reason="Invalid password", ) diff --git a/kvmd/apps/vnc/server.py b/kvmd/apps/vnc/server.py index d330a4ed..6e7f595e 100644 --- a/kvmd/apps/vnc/server.py +++ b/kvmd/apps/vnc/server.py @@ -27,6 +27,7 @@ import dataclasses import contextlib import aiohttp +import async_lru from ...logging import get_logger @@ -55,9 +56,6 @@ from .rfb import RfbClient from .rfb.stream import rfb_format_remote from .rfb.errors import RfbError -from .vncauth import VncAuthKvmdCredentials -from .vncauth import VncAuthManager - from .render import make_text_jpeg @@ -89,14 +87,13 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes kvmd: KvmdClient, streamers: list[BaseStreamerClient], - vnc_credentials: dict[str, VncAuthKvmdCredentials], + vncpasses: set[str], vencrypt: bool, none_auth_only: bool, + shared_params: _SharedParams, ) -> None: - self.__vnc_credentials = vnc_credentials - super().__init__( reader=reader, writer=writer, @@ -106,7 +103,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes x509_key_path=x509_key_path, scroll_rate=scroll_rate, allow_cut_after=allow_cut_after, - vnc_passwds=list(vnc_credentials), + vncpasses=vncpasses, vencrypt=vencrypt, none_auth_only=none_auth_only, **dataclasses.asdict(shared_params), @@ -321,19 +318,17 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes # ===== async def _authorize_userpass(self, user: str, passwd: str) -> bool: - self.__kvmd_session = self.__kvmd.make_session(user, passwd) - if (await self.__kvmd_session.auth.check()): + self.__kvmd_session = self.__kvmd.make_session() + if (await self.__kvmd_session.auth.check(user, passwd)): self.__stage1_authorized.set_passed() 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_authorized_vncpass(self) -> None: + self.__kvmd_session = self.__kvmd.make_session() + self.__stage1_authorized.set_passed() - async def _on_authorized_none(self) -> bool: + async def _authorize_none(self) -> bool: return (await self._authorize_userpass("", "")) # ===== @@ -461,6 +456,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes x509_cert_path: str, x509_key_path: str, + vncpass_enabled: bool, + vncpass_path: str, vencrypt_enabled: bool, desired_fps: int, @@ -471,7 +468,6 @@ class VncServer: # pylint: disable=too-many-instance-attributes kvmd: KvmdClient, streamers: list[BaseStreamerClient], - vnc_auth_manager: VncAuthManager, ) -> None: self.__host = network.get_listen_host(host) @@ -481,7 +477,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes keymap_name = os.path.basename(keymap_path) symmap = build_symmap(keymap_path) - self.__vnc_auth_manager = vnc_auth_manager + self.__vncpass_enabled = vncpass_enabled + self.__vncpass_path = vncpass_path shared_params = _SharedParams() @@ -508,8 +505,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, timeout) # type: ignore try: - async with kvmd.make_session("", "") as kvmd_session: - none_auth_only = await kvmd_session.auth.check() + async with kvmd.make_session() as kvmd_session: + none_auth_only = await kvmd_session.auth.check("", "") except (aiohttp.ClientError, asyncio.TimeoutError) as ex: logger.error("%s [entry]: Can't check KVMD auth mode: %s", remote, tools.efmt(ex)) return @@ -529,9 +526,9 @@ class VncServer: # pylint: disable=too-many-instance-attributes allow_cut_after=allow_cut_after, kvmd=kvmd, streamers=streamers, - vnc_credentials=(await self.__vnc_auth_manager.read_credentials())[0], - none_auth_only=none_auth_only, + vncpasses=(await self.__read_vncpasses()), vencrypt=vencrypt_enabled, + none_auth_only=none_auth_only, shared_params=shared_params, ).run() except Exception: @@ -542,9 +539,6 @@ class VncServer: # pylint: disable=too-many-instance-attributes self.__handle_client = handle_client async def __inner_run(self) -> None: - if not (await self.__vnc_auth_manager.read_credentials())[1]: - raise SystemExit(1) - get_logger(0).info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port) (family, _, _, _, addr) = socket.getaddrinfo(self.__host, self.__port, type=socket.SOCK_STREAM)[0] with contextlib.closing(socket.socket(family, socket.SOCK_STREAM)) as sock: @@ -561,6 +555,21 @@ class VncServer: # pylint: disable=too-many-instance-attributes async with server: await server.serve_forever() + @async_lru.alru_cache(maxsize=1, ttl=1) + async def __read_vncpasses(self) -> set[str]: + if self.__vncpass_enabled: + try: + vncpasses: set[str] = set() + for (_, line) in tools.passwds_splitted(await aiotools.read_file(self.__vncpass_path)): + if " -> " in line: # Compatibility with old ipmipasswd file format + line = line.split(" -> ", 1)[0] + if len(line.strip()) > 0: + vncpasses.add(line) + return vncpasses + except Exception: + get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file") + return set() + def run(self) -> None: aiotools.run(self.__inner_run()) get_logger().info("Bye-bye") diff --git a/kvmd/apps/vnc/vncauth.py b/kvmd/apps/vnc/vncauth.py deleted file mode 100644 index 46c1a77d..00000000 --- a/kvmd/apps/vnc/vncauth.py +++ /dev/null @@ -1,86 +0,0 @@ -# ========================================================================== # -# # -# KVMD - The main PiKVM daemon. # -# # -# Copyright (C) 2020 Maxim Devaev # -# # -# 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 . # -# # -# ========================================================================== # - - -import dataclasses - -from ...logging import get_logger - -from ... import aiotools - - -# ===== -class VncAuthError(Exception): - def __init__(self, path: str, lineno: int, msg: str) -> None: - super().__init__(f"Syntax error at {path}:{lineno}: {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 ex: - get_logger(0).error(str(ex)) - 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]: - lines = (await aiotools.read_file(self.__path)).split("\n") - credentials: dict[str, VncAuthKvmdCredentials] = {} - for (lineno, line) in enumerate(lines): - if len(line.strip()) == 0 or line.lstrip().startswith("#"): - continue - - if " -> " not in line: - raise VncAuthError(self.__path, lineno, "Missing ' -> ' operator") - - (vnc_passwd, kvmd_userpass) = map(str.lstrip, line.split(" -> ", 1)) - if ":" not in kvmd_userpass: - raise VncAuthError(self.__path, lineno, "Missing ':' operator in KVMD credentials (right part)") - - (kvmd_user, kvmd_passwd) = kvmd_userpass.split(":") - kvmd_user = kvmd_user.strip() - if len(kvmd_user) == 0: - raise VncAuthError(self.__path, lineno, "Empty KVMD user (right part)") - - if vnc_passwd in credentials: - raise VncAuthError(self.__path, lineno, "Duplicating VNC password (left part)") - - credentials[vnc_passwd] = VncAuthKvmdCredentials(kvmd_user, kvmd_passwd) - return credentials diff --git a/kvmd/clients/kvmd.py b/kvmd/clients/kvmd.py index 7558af3c..1fd89e1b 100644 --- a/kvmd/clients/kvmd.py +++ b/kvmd/clients/kvmd.py @@ -56,16 +56,22 @@ class _BaseApiPart: class _AuthApiPart(_BaseApiPart): - async def check(self) -> bool: + async def check(self, user: str, passwd: str) -> bool: session = self._ensure_http_session() try: - async with session.get("/auth/check") as resp: + async with session.get("/auth/check", headers={ + "X-KVMD-User": user, + "X-KVMD-Passwd": passwd, + }) as resp: + htclient.raise_not_200(resp) - return True + return (resp.status == 200) # Just for my paranoia + except aiohttp.ClientResponseError as ex: if ex.status in [400, 401, 403]: return False raise + raise RuntimeError("We should't be here") class _StreamerApiPart(_BaseApiPart): @@ -216,11 +222,5 @@ class KvmdClientSession(BaseHttpClientSession): class KvmdClient(BaseHttpClient): - def make_session(self, user: str="", passwd: str="") -> KvmdClientSession: - headers: (dict[str, str] | None) = None - if user: - headers = { - "X-KVMD-User": user, - "X-KVMD-Passwd": passwd, - } - return KvmdClientSession(lambda: self._make_http_session(headers)) + def make_session(self) -> KvmdClientSession: + return KvmdClientSession(self._make_http_session)