diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 20ccc635..92345c74 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -360,6 +360,11 @@ def _get_config_scheme() -> dict: "enabled": Option(True, type=valid_bool), "expire": Option(0, type=valid_expire), + "usc": { + "users": Option([], 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 + }, + "internal": { "type": Option("htpasswd"), "force_users": Option([], type=valid_users_list), diff --git a/kvmd/apps/kvmd/__init__.py b/kvmd/apps/kvmd/__init__.py index f2c27380..bb784e30 100644 --- a/kvmd/apps/kvmd/__init__.py +++ b/kvmd/apps/kvmd/__init__.py @@ -77,6 +77,8 @@ def main(argv: (list[str] | None)=None) -> None: auth_manager=AuthManager( enabled=config.auth.enabled, expire=config.auth.expire, + usc_users=config.auth.usc.users, + usc_groups=config.auth.usc.groups, unauth_paths=([] if config.prometheus.auth.enabled else ["/export/prometheus/metrics"]), int_type=config.auth.internal.type, diff --git a/kvmd/apps/kvmd/api/auth.py b/kvmd/apps/kvmd/api/auth.py index da4b0be9..b3928c50 100644 --- a/kvmd/apps/kvmd/api/auth.py +++ b/kvmd/apps/kvmd/api/auth.py @@ -31,6 +31,7 @@ from ....htserver import HttpExposed from ....htserver import exposed_http from ....htserver import make_json_response from ....htserver import set_request_auth_info +from ....htserver import get_request_unix_credentials from ....validators.auth import valid_user from ....validators.auth import valid_passwd @@ -76,6 +77,14 @@ async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, re raise ForbiddenError() return + if exposed.allow_usc: + creds = get_request_unix_credentials(req) + if creds is not None: + user = auth_manager.check_unix_credentials(creds) # type: ignore + if user: + set_request_auth_info(req, f"{user}[{creds.uid}] (unix)") + return + raise UnauthorizedError() @@ -85,7 +94,7 @@ class AuthApi: # ===== - @exposed_http("POST", "/auth/login", auth_required=False) + @exposed_http("POST", "/auth/login", auth_required=False, allow_usc=False) async def __login_handler(self, req: Request) -> Response: if self.__auth_manager.is_auth_enabled(): credentials = await req.post() @@ -99,13 +108,14 @@ class AuthApi: raise ForbiddenError() return make_json_response() - @exposed_http("POST", "/auth/logout") + @exposed_http("POST", "/auth/logout", allow_usc=False) async def __logout_handler(self, req: Request) -> Response: if self.__auth_manager.is_auth_enabled(): token = valid_auth_token(req.cookies.get(_COOKIE_AUTH_TOKEN, "")) self.__auth_manager.logout(token) return make_json_response() - @exposed_http("GET", "/auth/check") + # XXX: This handle is used for access control so it should NEVER allow access by socket credentials + @exposed_http("GET", "/auth/check", allow_usc=False) async def __check_handler(self, _: Request) -> Response: return make_json_response() diff --git a/kvmd/apps/kvmd/auth.py b/kvmd/apps/kvmd/auth.py index b5a7da28..f41a05e0 100644 --- a/kvmd/apps/kvmd/auth.py +++ b/kvmd/apps/kvmd/auth.py @@ -20,6 +20,8 @@ # ========================================================================== # +import pwd +import grp import dataclasses import time import datetime @@ -35,6 +37,7 @@ from ...plugins.auth import BaseAuthService from ...plugins.auth import get_auth_service_class from ...htserver import HttpExposed +from ...htserver import RequestUnixCredentials # ===== @@ -49,11 +52,13 @@ class _Session: assert self.expire_ts >= 0 -class AuthManager: # pylint: disable=too-many-instance-attributes +class AuthManager: # pylint: disable=too-many-arguments,too-many-instance-attributes def __init__( self, enabled: bool, expire: int, + usc_users: list[str], + usc_groups: list[str], unauth_paths: list[str], int_type: str, @@ -78,9 +83,15 @@ class AuthManager: # pylint: disable=too-many-instance-attributes logger.info("Maximum user session time is limited: %s", self.__format_seconds(expire)) + self.__usc_uids = self.__load_usc_uids(usc_users, usc_groups) + if self.__usc_uids: + logger.info("Unauth UNIX socket access is allowed for users: %s", + list(self.__usc_uids.values())) + self.__unauth_paths = frozenset(unauth_paths) # To speed up - for path in self.__unauth_paths: - logger.warning("Authorization is disabled for API %r", path) + if self.__unauth_paths: + logger.info("Authorization is disabled for APIs: %s", + list(self.__unauth_paths)) self.__int_service: (BaseAuthService | None) = None if enabled: @@ -244,3 +255,29 @@ class AuthManager: # pylint: disable=too-many-instance-attributes await self.__int_service.cleanup() if self.__ext_service: await self.__ext_service.cleanup() + + # ===== + + def __load_usc_uids(self, users: list[str], groups: list[str]) -> dict[int, str]: + uids: dict[int, str] = {} + + pwds: dict[str, int] = {} + for pw in pwd.getpwall(): + assert pw.pw_name == pw.pw_name.strip() + assert pw.pw_name + pwds[pw.pw_name] = pw.pw_uid + if pw.pw_name in users: + uids[pw.pw_uid] = pw.pw_name + + for gr in grp.getgrall(): + if gr.gr_name in groups: + for member in gr.gr_mem: + if member in pwds: + uid = pwds[member] + uids[uid] = member + + return uids + + def check_unix_credentials(self, creds: RequestUnixCredentials) -> (str | None): + assert self.__enabled + return self.__usc_uids.get(creds.uid) diff --git a/kvmd/htserver.py b/kvmd/htserver.py index 1ef3cc48..1e276de4 100644 --- a/kvmd/htserver.py +++ b/kvmd/htserver.py @@ -22,6 +22,7 @@ import os import socket +import struct import asyncio import contextlib import dataclasses @@ -83,6 +84,7 @@ class HttpExposed: method: str path: str auth_required: bool + allow_usc: bool handler: Callable @@ -90,14 +92,22 @@ _HTTP_EXPOSED = "_http_exposed" _HTTP_METHOD = "_http_method" _HTTP_PATH = "_http_path" _HTTP_AUTH_REQUIRED = "_http_auth_required" +_HTTP_ALLOW_USC = "_http_allow_usc" -def exposed_http(http_method: str, path: str, auth_required: bool=True) -> Callable: +def exposed_http( + http_method: str, + path: str, + auth_required: bool=True, + allow_usc: bool=True, +) -> Callable: + def set_attrs(handler: Callable) -> Callable: setattr(handler, _HTTP_EXPOSED, True) setattr(handler, _HTTP_METHOD, http_method) setattr(handler, _HTTP_PATH, path) setattr(handler, _HTTP_AUTH_REQUIRED, auth_required) + setattr(handler, _HTTP_ALLOW_USC, allow_usc) return handler return set_attrs @@ -108,6 +118,7 @@ def _get_exposed_http(obj: object) -> list[HttpExposed]: method=getattr(handler, _HTTP_METHOD), path=getattr(handler, _HTTP_PATH), auth_required=getattr(handler, _HTTP_AUTH_REQUIRED), + allow_usc=getattr(handler, _HTTP_ALLOW_USC), handler=handler, ) for handler in [getattr(obj, name) for name in dir(obj)] @@ -270,6 +281,34 @@ def set_request_auth_info(req: BaseRequest, info: str) -> None: setattr(req, _REQUEST_AUTH_INFO, info) +@dataclasses.dataclass(frozen=True) +class RequestUnixCredentials: + pid: int + uid: int + gid: int + + def __post_init__(self) -> None: + assert self.pid > 0 + assert self.uid >= 0 + assert self.gid >= 0 + + +def get_request_unix_credentials(req: BaseRequest) -> (RequestUnixCredentials | None): + if req.transport is None: + return None + sock = req.transport.get_extra_info("socket") + if sock is None: + return None + try: + data = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("iii")) + except Exception: + return None + (pid, uid, gid) = struct.unpack("iii", data) + if pid <= 0 or uid < 0 or gid < 0: + return None + return RequestUnixCredentials(pid=pid, uid=uid, gid=gid) + + # ===== @dataclasses.dataclass(frozen=True) class WsSession: @@ -314,13 +353,14 @@ class HttpServer: if unix_rm and os.path.exists(unix_path): os.remove(unix_path) - server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server_socket.bind(unix_path) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1) + sock.bind(unix_path) if unix_mode: os.chmod(unix_path, unix_mode) run_app( - sock=server_socket, + sock=sock, app=self.__make_app(), shutdown_timeout=1, access_log_format=access_log_format, diff --git a/testenv/linters/vulture-wl.py b/testenv/linters/vulture-wl.py index 3f37e4ac..c88681a5 100644 --- a/testenv/linters/vulture-wl.py +++ b/testenv/linters/vulture-wl.py @@ -83,3 +83,6 @@ StorageContext.read_port_names StorageContext.read_atx_cp_delays StorageContext.read_atx_cpl_delays StorageContext.read_atx_cr_delays + +RequestUnixCredentials.pid +RequestUnixCredentials.gid diff --git a/testenv/tests/apps/kvmd/test_auth.py b/testenv/tests/apps/kvmd/test_auth.py index de53fc32..b2d4f2df 100644 --- a/testenv/tests/apps/kvmd/test_auth.py +++ b/testenv/tests/apps/kvmd/test_auth.py @@ -40,9 +40,9 @@ from kvmd.crypto import KvmdHtpasswdFile # ===== -_E_AUTH = HttpExposed("GET", "/foo_auth", True, (lambda: None)) -_E_UNAUTH = HttpExposed("GET", "/bar_unauth", True, (lambda: None)) -_E_FREE = HttpExposed("GET", "/baz_free", False, (lambda: None)) +_E_AUTH = HttpExposed("GET", "/foo_auth", auth_required=True, allow_usc=True, handler=(lambda: None)) +_E_UNAUTH = HttpExposed("GET", "/bar_unauth", auth_required=True, allow_usc=True, handler=(lambda: None)) +_E_FREE = HttpExposed("GET", "/baz_free", auth_required=False, allow_usc=True, handler=(lambda: None)) def _make_service_kwargs(path: str) -> dict: @@ -62,6 +62,8 @@ async def _get_configured_manager( manager = AuthManager( enabled=True, expire=0, + usc_users=[], + usc_groups=[], unauth_paths=unauth_paths, int_type="htpasswd", @@ -262,6 +264,8 @@ async def test_ok__disabled() -> None: manager = AuthManager( enabled=False, expire=0, + usc_users=[], + usc_groups=[], unauth_paths=[], int_type="foobar",