Skip to content

Commit 84008be

Browse files
authored
feat: directly signal session action processes using CAP_KILL (#196)
Signed-off-by: Josh Usiskin <[email protected]>
1 parent fa8aa60 commit 84008be

File tree

13 files changed

+743
-73
lines changed

13 files changed

+743
-73
lines changed

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ addopts = [
148148
"--numprocesses=auto",
149149
"--timeout=30"
150150
]
151+
markers = [
152+
"requires_cap_kill: tests that require CAP_KILL Linux capability",
153+
]
151154

152155

153156
[tool.coverage.run]
@@ -173,6 +176,9 @@ fail_under = 79
173176
"src/openjd/sessions/_scripts/_windows/*.py",
174177
"src/openjd/sessions/_windows*.py"
175178
]
179+
"sys_platform != 'linux'" = [
180+
"src/openjd/sessions/_linux/*.py",
181+
]
176182

177183
[tool.coverage.coverage_conditional_plugin.rules]
178184
# This cannot be empty otherwise coverage-conditional-plugin crashes with:

scripts/run_sudo_tests.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,26 @@ if test "${BUILD_ONLY}" == "True"; then
6767
fi
6868

6969
docker run --name test_openjd_sudo --rm ${ARGS} "${CONTAINER_IMAGE_TAG}:latest"
70+
71+
if test "${USE_LDAP}" != "True"; then
72+
# Run capability tests
73+
# First with CAP_KILL in effective and permitted capability sets
74+
docker run --name test_openjd_sudo --user root --rm ${ARGS} "${CONTAINER_IMAGE_TAG}:latest" \
75+
capsh \
76+
--caps='cap_setuid,cap_setgid,cap_setpcap=ep cap_kill=eip' \
77+
--keep=1 \
78+
--user=hostuser \
79+
--addamb=cap_kill \
80+
-- \
81+
-c 'capsh --noamb --caps=cap_kill=ep -- -c "hatch run test --no-cov -m requires_cap_kill"'
82+
# Second with CAP_KILL in permitted capability set but not effective capability set
83+
# this tests that OpenJD will add CAP_KILL to the effective capability set if needed
84+
docker run --name test_openjd_sudo --user root --rm ${ARGS} "${CONTAINER_IMAGE_TAG}:latest" \
85+
capsh \
86+
--caps='cap_setuid,cap_setgid,cap_setpcap=ep cap_kill=eip' \
87+
--keep=1 \
88+
--user=hostuser \
89+
--addamb=cap_kill \
90+
-- \
91+
-c 'capsh --noamb --caps=cap_kill=p -- -c "hatch run test --no-cov -m requires_cap_kill"'
92+
fi
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
"""This module contains code for interacting with Linux capabilities. The module uses the ctypes
4+
module from the Python standard library to wrap the libcap library.
5+
6+
See https://man7.org/linux/man-pages/man7/capabilities.7.html for details on this Linux kernel
7+
feature.
8+
"""
9+
10+
import ctypes
11+
import os
12+
import sys
13+
from contextlib import contextmanager
14+
from ctypes.util import find_library
15+
from enum import Enum
16+
from functools import cache
17+
from typing import Any, Generator, Optional, Tuple, TYPE_CHECKING
18+
19+
20+
from .._logging import LOG
21+
22+
23+
# Capability sets
24+
CAP_EFFECTIVE = 0
25+
CAP_PERMITTED = 1
26+
CAP_INHERITABLE = 2
27+
28+
# Capability bit numbers
29+
CAP_KILL = 5
30+
31+
# Values for cap_flag_value_t arguments
32+
CAP_CLEAR = 0
33+
CAP_SET = 1
34+
35+
cap_flag_t = ctypes.c_int
36+
cap_flag_value_t = ctypes.c_int
37+
cap_value_t = ctypes.c_int
38+
39+
40+
class CapabilitySetType(Enum):
41+
INHERITABLE = CAP_INHERITABLE
42+
PERMITTED = CAP_PERMITTED
43+
EFFECTIVE = CAP_EFFECTIVE
44+
45+
46+
class UserCapHeader(ctypes.Structure):
47+
_fields_ = [
48+
("version", ctypes.c_uint32),
49+
("pid", ctypes.c_int),
50+
]
51+
52+
53+
class UserCapData(ctypes.Structure):
54+
_fields_ = [
55+
("effective", ctypes.c_uint32),
56+
("permitted", ctypes.c_uint32),
57+
("inheritable", ctypes.c_uint32),
58+
]
59+
60+
61+
class Cap(ctypes.Structure):
62+
_fields_ = [
63+
("head", UserCapHeader),
64+
("data", UserCapData),
65+
]
66+
67+
68+
if TYPE_CHECKING:
69+
cap_t = ctypes._Pointer[Cap]
70+
cap_flag_value_ptr = ctypes._Pointer[cap_flag_value_t]
71+
cap_value_ptr = ctypes._Pointer[cap_value_t]
72+
ssize_ptr_t = ctypes._Pointer[ctypes.c_ssize_t]
73+
else:
74+
cap_t = ctypes.POINTER(Cap)
75+
cap_flag_value_ptr = ctypes.POINTER(cap_flag_value_t)
76+
cap_value_ptr = ctypes.POINTER(cap_value_t)
77+
ssize_ptr_t = ctypes.POINTER(ctypes.c_ssize_t)
78+
79+
80+
def _cap_set_err_check(
81+
result: ctypes.c_int,
82+
func: Any,
83+
args: Tuple[Any, ...],
84+
) -> ctypes.c_int:
85+
if result != 0:
86+
errno = ctypes.get_errno()
87+
raise OSError(errno, os.strerror(errno))
88+
return result
89+
90+
91+
def _cap_get_proc_err_check(
92+
result: cap_t,
93+
func: Any,
94+
args: Tuple[cap_t, cap_flag_t, ctypes.c_int, cap_value_ptr, cap_flag_value_t],
95+
) -> cap_t:
96+
if not result:
97+
errno = ctypes.get_errno()
98+
raise OSError(errno, os.strerror(errno))
99+
return result
100+
101+
102+
def _cap_get_flag_errcheck(
103+
result: ctypes.c_int, func: Any, args: Tuple[cap_t, cap_value_t, cap_flag_t, cap_flag_value_ptr]
104+
) -> ctypes.c_int:
105+
if result != 0:
106+
errno = ctypes.get_errno()
107+
raise OSError(errno, os.strerror(errno))
108+
return result
109+
110+
111+
@cache
112+
def _get_libcap() -> Optional[ctypes.CDLL]:
113+
if not sys.platform.startswith("linux"):
114+
raise OSError(f"libcap is only available on Linux, but found platform: {sys.platform}")
115+
116+
libcap_path = find_library("cap")
117+
if libcap_path is None:
118+
LOG.info(
119+
"Unable to locate libcap. Session action cancelation signals will be sent using sudo"
120+
)
121+
return None
122+
123+
libcap = ctypes.CDLL(libcap_path, use_errno=True)
124+
125+
# https://man7.org/linux/man-pages/man3/cap_set_proc.3.html
126+
libcap.cap_set_proc.restype = ctypes.c_int
127+
libcap.cap_set_proc.argtypes = [
128+
cap_t,
129+
]
130+
libcap.cap_set_proc.errcheck = _cap_set_err_check # type: ignore
131+
132+
# https://man7.org/linux/man-pages/man3/cap_get_proc.3.html
133+
libcap.cap_get_proc.restype = cap_t
134+
libcap.cap_get_proc.argtypes = []
135+
libcap.cap_get_proc.errcheck = _cap_get_proc_err_check # type: ignore
136+
137+
# https://man7.org/linux/man-pages/man3/cap_set_flag.3.html
138+
libcap.cap_set_flag.restype = ctypes.c_int
139+
libcap.cap_set_flag.argtypes = [
140+
cap_t,
141+
cap_flag_t,
142+
ctypes.c_int,
143+
cap_value_ptr,
144+
cap_flag_value_t,
145+
]
146+
147+
# https://man7.org/linux/man-pages/man3/cap_get_flag.3.html
148+
libcap.cap_get_flag.restype = ctypes.c_int
149+
libcap.cap_get_flag.argtypes = (
150+
cap_t,
151+
cap_value_t,
152+
cap_flag_t,
153+
cap_flag_value_ptr,
154+
)
155+
libcap.cap_get_flag.errcheck = _cap_get_flag_errcheck # type: ignore
156+
157+
return libcap
158+
159+
160+
def _has_capability(
161+
*,
162+
libcap: ctypes.CDLL,
163+
caps: cap_t,
164+
capability: int,
165+
capability_set_type: CapabilitySetType,
166+
) -> bool:
167+
flag_value = cap_flag_value_t()
168+
libcap.cap_get_flag(caps, capability, capability_set_type.value, ctypes.byref(flag_value))
169+
return flag_value.value == CAP_SET
170+
171+
172+
@contextmanager
173+
def try_use_cap_kill() -> Generator[bool, None, None]:
174+
"""
175+
A context-manager that attempts to leverage the CAP_KILL Linux capability.
176+
177+
If CAP_KILL is in the current thread's effective set, this context-manager takes no action and
178+
yields True.
179+
180+
If CAP_KILL is not in the effective set but is in the permitted set, the context-manager:
181+
1. adds CAP_KILL to the effective set before entering the context-manager
182+
2. yields True
183+
3. clears CAP_KILL from the effective set when exiting the context-manager
184+
185+
Otherwise, the context-manager does nothing and yields False
186+
187+
Returns:
188+
A context manager that yields a bool. See above for details.
189+
"""
190+
if not sys.platform.startswith("linux"):
191+
raise OSError(f"Only Linux is supported, but platform is {sys.platform}")
192+
193+
libcap = _get_libcap()
194+
# If libcap is not found, we yield False indicating we are not aware of having CAP_KILL
195+
if not libcap:
196+
yield False
197+
return
198+
199+
caps = libcap.cap_get_proc()
200+
201+
if _has_capability(
202+
libcap=libcap,
203+
caps=caps,
204+
capability=CAP_KILL,
205+
capability_set_type=CapabilitySetType.EFFECTIVE,
206+
):
207+
LOG.debug("CAP_KILL is in the thread's effective set")
208+
# CAP_KILL is already in the effective set
209+
yield True
210+
elif _has_capability(
211+
libcap=libcap,
212+
caps=caps,
213+
capability=CAP_KILL,
214+
capability_set_type=CapabilitySetType.PERMITTED,
215+
):
216+
# CAP_KILL is in the permitted set. We will temporarily add it to the effective set
217+
LOG.debug("CAP_KILL is in the thread's permitted set. Temporarily adding to effective set")
218+
cap_value_arr_t = cap_value_t * 1
219+
cap_value_arr = cap_value_arr_t()
220+
cap_value_arr[0] = CAP_KILL
221+
libcap.cap_set_flag(
222+
caps,
223+
CAP_EFFECTIVE,
224+
1,
225+
cap_value_arr,
226+
CAP_SET,
227+
)
228+
libcap.cap_set_proc(caps)
229+
try:
230+
yield True
231+
finally:
232+
# Clear CAP_KILL from the effective set
233+
LOG.debug("Clearing CAP_KILL from the thread's effective set")
234+
libcap.cap_set_flag(
235+
caps,
236+
CAP_EFFECTIVE,
237+
1,
238+
cap_value_arr,
239+
CAP_CLEAR,
240+
)
241+
libcap.cap_set_proc(caps)
242+
else:
243+
yield False
244+
245+
246+
def main() -> None:
247+
"""A developer debugging entrypoint for testing the try_use_cap_kill() behaviour"""
248+
import logging
249+
250+
logging.basicConfig(level=logging.DEBUG)
251+
logging.getLogger("openjd.sessions").setLevel(logging.DEBUG)
252+
253+
with try_use_cap_kill() as has_cap_kill:
254+
LOG.info("Has CAP_KILL: %s", has_cap_kill)
255+
256+
257+
if __name__ == "__main__":
258+
main()

0 commit comments

Comments
 (0)