Skip to content

Commit a8e71b1

Browse files
committed
unix socket auth
1 parent 16a1dbd commit a8e71b1

File tree

7 files changed

+103
-13
lines changed

7 files changed

+103
-13
lines changed

kvmd/apps/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,11 @@ def _get_config_scheme() -> dict:
360360
"enabled": Option(True, type=valid_bool),
361361
"expire": Option(0, type=valid_expire),
362362

363+
"usc": {
364+
"users": Option([], type=valid_users_list), # PiKVM username has a same regex as a UNIX username
365+
"groups": Option([], type=valid_users_list), # groupname has a same regex as a username
366+
},
367+
363368
"internal": {
364369
"type": Option("htpasswd"),
365370
"force_users": Option([], type=valid_users_list),

kvmd/apps/kvmd/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def main(argv: (list[str] | None)=None) -> None:
7777
auth_manager=AuthManager(
7878
enabled=config.auth.enabled,
7979
expire=config.auth.expire,
80+
usc_users=config.auth.usc.users,
81+
usc_groups=config.auth.usc.groups,
8082
unauth_paths=([] if config.prometheus.auth.enabled else ["/export/prometheus/metrics"]),
8183

8284
int_type=config.auth.internal.type,

kvmd/apps/kvmd/api/auth.py

+13-3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from ....htserver import exposed_http
3232
from ....htserver import make_json_response
3333
from ....htserver import set_request_auth_info
34+
from ....htserver import get_request_unix_credentials
3435

3536
from ....validators.auth import valid_user
3637
from ....validators.auth import valid_passwd
@@ -76,6 +77,14 @@ async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, re
7677
raise ForbiddenError()
7778
return
7879

80+
if exposed.allow_usc:
81+
creds = get_request_unix_credentials(req)
82+
if creds is not None:
83+
user = auth_manager.check_unix_credentials(creds) # type: ignore
84+
if user:
85+
set_request_auth_info(req, f"{user}[{creds.uid}] (unix)")
86+
return
87+
7988
raise UnauthorizedError()
8089

8190

@@ -85,7 +94,7 @@ def __init__(self, auth_manager: AuthManager) -> None:
8594

8695
# =====
8796

88-
@exposed_http("POST", "/auth/login", auth_required=False)
97+
@exposed_http("POST", "/auth/login", auth_required=False, allow_usc=False)
8998
async def __login_handler(self, req: Request) -> Response:
9099
if self.__auth_manager.is_auth_enabled():
91100
credentials = await req.post()
@@ -99,13 +108,14 @@ async def __login_handler(self, req: Request) -> Response:
99108
raise ForbiddenError()
100109
return make_json_response()
101110

102-
@exposed_http("POST", "/auth/logout")
111+
@exposed_http("POST", "/auth/logout", allow_usc=False)
103112
async def __logout_handler(self, req: Request) -> Response:
104113
if self.__auth_manager.is_auth_enabled():
105114
token = valid_auth_token(req.cookies.get(_COOKIE_AUTH_TOKEN, ""))
106115
self.__auth_manager.logout(token)
107116
return make_json_response()
108117

109-
@exposed_http("GET", "/auth/check")
118+
# XXX: This handle is used for access control so it should NEVER allow access by socket credentials
119+
@exposed_http("GET", "/auth/check", allow_usc=False)
110120
async def __check_handler(self, _: Request) -> Response:
111121
return make_json_response()

kvmd/apps/kvmd/auth.py

+40-3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
# ========================================================================== #
2121

2222

23+
import pwd
24+
import grp
2325
import dataclasses
2426
import time
2527
import datetime
@@ -35,6 +37,7 @@
3537
from ...plugins.auth import get_auth_service_class
3638

3739
from ...htserver import HttpExposed
40+
from ...htserver import RequestUnixCredentials
3841

3942

4043
# =====
@@ -49,11 +52,13 @@ def __post_init__(self) -> None:
4952
assert self.expire_ts >= 0
5053

5154

52-
class AuthManager: # pylint: disable=too-many-instance-attributes
55+
class AuthManager: # pylint: disable=too-many-arguments,too-many-instance-attributes
5356
def __init__(
5457
self,
5558
enabled: bool,
5659
expire: int,
60+
usc_users: list[str],
61+
usc_groups: list[str],
5762
unauth_paths: list[str],
5863

5964
int_type: str,
@@ -78,9 +83,15 @@ def __init__(
7883
logger.info("Maximum user session time is limited: %s",
7984
self.__format_seconds(expire))
8085

86+
self.__usc_uids = self.__load_usc_uids(usc_users, usc_groups)
87+
if self.__usc_uids:
88+
logger.info("Unauth UNIX socket access is allowed for users: %s",
89+
list(self.__usc_uids.values()))
90+
8191
self.__unauth_paths = frozenset(unauth_paths) # To speed up
82-
for path in self.__unauth_paths:
83-
logger.warning("Authorization is disabled for API %r", path)
92+
if self.__unauth_paths:
93+
logger.info("Authorization is disabled for APIs: %s",
94+
list(self.__unauth_paths))
8495

8596
self.__int_service: (BaseAuthService | None) = None
8697
if enabled:
@@ -244,3 +255,29 @@ async def cleanup(self) -> None:
244255
await self.__int_service.cleanup()
245256
if self.__ext_service:
246257
await self.__ext_service.cleanup()
258+
259+
# =====
260+
261+
def __load_usc_uids(self, users: list[str], groups: list[str]) -> dict[int, str]:
262+
uids: dict[int, str] = {}
263+
264+
pwds: dict[str, int] = {}
265+
for pw in pwd.getpwall():
266+
assert pw.pw_name == pw.pw_name.strip()
267+
assert pw.pw_name
268+
pwds[pw.pw_name] = pw.pw_uid
269+
if pw.pw_name in users:
270+
uids[pw.pw_uid] = pw.pw_name
271+
272+
for gr in grp.getgrall():
273+
if gr.gr_name in groups:
274+
for member in gr.gr_mem:
275+
if member in pwds:
276+
uid = pwds[member]
277+
uids[uid] = member
278+
279+
return uids
280+
281+
def check_unix_credentials(self, creds: RequestUnixCredentials) -> (str | None):
282+
assert self.__enabled
283+
return self.__usc_uids.get(creds.uid)

kvmd/htserver.py

+33-4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import os
2424
import socket
25+
import struct
2526
import asyncio
2627
import contextlib
2728
import dataclasses
@@ -83,21 +84,30 @@ class HttpExposed:
8384
method: str
8485
path: str
8586
auth_required: bool
87+
allow_usc: bool
8688
handler: Callable
8789

8890

8991
_HTTP_EXPOSED = "_http_exposed"
9092
_HTTP_METHOD = "_http_method"
9193
_HTTP_PATH = "_http_path"
9294
_HTTP_AUTH_REQUIRED = "_http_auth_required"
95+
_HTTP_ALLOW_USC = "_http_allow_usc"
9396

9497

95-
def exposed_http(http_method: str, path: str, auth_required: bool=True) -> Callable:
98+
def exposed_http(
99+
http_method: str,
100+
path: str,
101+
auth_required: bool=True,
102+
allow_usc: bool=True,
103+
) -> Callable:
104+
96105
def set_attrs(handler: Callable) -> Callable:
97106
setattr(handler, _HTTP_EXPOSED, True)
98107
setattr(handler, _HTTP_METHOD, http_method)
99108
setattr(handler, _HTTP_PATH, path)
100109
setattr(handler, _HTTP_AUTH_REQUIRED, auth_required)
110+
setattr(handler, _HTTP_ALLOW_USC, allow_usc)
101111
return handler
102112
return set_attrs
103113

@@ -108,6 +118,7 @@ def _get_exposed_http(obj: object) -> list[HttpExposed]:
108118
method=getattr(handler, _HTTP_METHOD),
109119
path=getattr(handler, _HTTP_PATH),
110120
auth_required=getattr(handler, _HTTP_AUTH_REQUIRED),
121+
allow_usc=getattr(handler, _HTTP_ALLOW_USC),
111122
handler=handler,
112123
)
113124
for handler in [getattr(obj, name) for name in dir(obj)]
@@ -270,6 +281,23 @@ def set_request_auth_info(req: BaseRequest, info: str) -> None:
270281
setattr(req, _REQUEST_AUTH_INFO, info)
271282

272283

284+
@dataclasses.dataclass(frozen=True)
285+
class RequestUnixCredentials:
286+
pid: int
287+
uid: int
288+
gid: int
289+
290+
291+
def get_request_unix_credentials(req: BaseRequest) -> (RequestUnixCredentials | None):
292+
if req.transport is None:
293+
return None
294+
sock = req.transport.get_extra_info("socket")
295+
if sock is None:
296+
return None
297+
data = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("iii"))
298+
return RequestUnixCredentials(*struct.unpack("iii", data))
299+
300+
273301
# =====
274302
@dataclasses.dataclass(frozen=True)
275303
class WsSession:
@@ -314,13 +342,14 @@ def run(
314342

315343
if unix_rm and os.path.exists(unix_path):
316344
os.remove(unix_path)
317-
server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
318-
server_socket.bind(unix_path)
345+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
346+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1)
347+
sock.bind(unix_path)
319348
if unix_mode:
320349
os.chmod(unix_path, unix_mode)
321350

322351
run_app(
323-
sock=server_socket,
352+
sock=sock,
324353
app=self.__make_app(),
325354
shutdown_timeout=1,
326355
access_log_format=access_log_format,

testenv/linters/vulture-wl.py

+3
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,6 @@
8383
StorageContext.read_atx_cp_delays
8484
StorageContext.read_atx_cpl_delays
8585
StorageContext.read_atx_cr_delays
86+
87+
RequestUnixCredentials.pid
88+
RequestUnixCredentials.gid

testenv/tests/apps/kvmd/test_auth.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040

4141

4242
# =====
43-
_E_AUTH = HttpExposed("GET", "/foo_auth", True, (lambda: None))
44-
_E_UNAUTH = HttpExposed("GET", "/bar_unauth", True, (lambda: None))
45-
_E_FREE = HttpExposed("GET", "/baz_free", False, (lambda: None))
43+
_E_AUTH = HttpExposed("GET", "/foo_auth", auth_required=True, allow_usc=True, handler=(lambda: None))
44+
_E_UNAUTH = HttpExposed("GET", "/bar_unauth", auth_required=True, allow_usc=True, handler=(lambda: None))
45+
_E_FREE = HttpExposed("GET", "/baz_free", auth_required=False, allow_usc=True, handler=(lambda: None))
4646

4747

4848
def _make_service_kwargs(path: str) -> dict:
@@ -62,6 +62,8 @@ async def _get_configured_manager(
6262
manager = AuthManager(
6363
enabled=True,
6464
expire=0,
65+
usc_users=[],
66+
usc_groups=[],
6567
unauth_paths=unauth_paths,
6668

6769
int_type="htpasswd",
@@ -262,6 +264,8 @@ async def test_ok__disabled() -> None:
262264
manager = AuthManager(
263265
enabled=False,
264266
expire=0,
267+
usc_users=[],
268+
usc_groups=[],
265269
unauth_paths=[],
266270

267271
int_type="foobar",

0 commit comments

Comments
 (0)