unix socket auth

This commit is contained in:
Maxim Devaev 2025-04-13 00:55:33 +03:00
parent 16a1dbd9ed
commit 7e185d2ad9
7 changed files with 114 additions and 13 deletions

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -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",