embedded-lab-control 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/core/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core components for lab controller.
|
|
3
|
+
|
|
4
|
+
Provides configuration, database, and resource management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from labctl.core.config import Config, load_config
|
|
8
|
+
from labctl.core.database import Database, get_database
|
|
9
|
+
from labctl.core.manager import ResourceManager, get_manager
|
|
10
|
+
from labctl.core.models import (
|
|
11
|
+
SBC,
|
|
12
|
+
AddressType,
|
|
13
|
+
NetworkAddress,
|
|
14
|
+
PlugType,
|
|
15
|
+
PortType,
|
|
16
|
+
PowerPlug,
|
|
17
|
+
SerialPort,
|
|
18
|
+
Status,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Config",
|
|
23
|
+
"load_config",
|
|
24
|
+
"Database",
|
|
25
|
+
"get_database",
|
|
26
|
+
"ResourceManager",
|
|
27
|
+
"get_manager",
|
|
28
|
+
"SBC",
|
|
29
|
+
"SerialPort",
|
|
30
|
+
"NetworkAddress",
|
|
31
|
+
"PowerPlug",
|
|
32
|
+
"Status",
|
|
33
|
+
"PortType",
|
|
34
|
+
"AddressType",
|
|
35
|
+
"PlugType",
|
|
36
|
+
]
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Root-run file operations for the install and update scripts.
|
|
3
|
+
|
|
4
|
+
scripts/install-services.sh and scripts/update.sh run as root but create,
|
|
5
|
+
copy and re-permission files under /var/lib/labctl, a tree the ``labctl``
|
|
6
|
+
user (the identity of the web and MCP services) can write to. Path-based
|
|
7
|
+
``chown``/``chmod``/``cp`` follow a symlink planted there, so a compromised
|
|
8
|
+
service could have root hand it ``/etc/labctl/config.yaml``, or copy
|
|
9
|
+
``/etc/shadow`` into a config file its group can read.
|
|
10
|
+
|
|
11
|
+
Everything here works on descriptors opened without following symlinks on
|
|
12
|
+
any path component (:mod:`labctl.core.safe_open`), requires a regular file
|
|
13
|
+
with a single link, and creates files with ``O_EXCL``. Callers pass paths
|
|
14
|
+
whose trusted prefix (the labctl home, which an admin may have symlinked
|
|
15
|
+
elsewhere) is already resolved. Command line::
|
|
16
|
+
|
|
17
|
+
python -m labctl.core.admin_files state PATH
|
|
18
|
+
prints "missing" or "regular"; exit 2 (with the reason) otherwise
|
|
19
|
+
python -m labctl.core.admin_files secure PATH USER GROUP MODE
|
|
20
|
+
python -m labctl.core.admin_files secure-dir PATH USER GROUP MODE
|
|
21
|
+
(creates the directory if missing)
|
|
22
|
+
python -m labctl.core.admin_files create PATH USER GROUP MODE
|
|
23
|
+
creates an empty file if missing, then secures it like `secure`
|
|
24
|
+
python -m labctl.core.admin_files shared-db SYSTEM_CONFIG DB_DIR USER GROUP
|
|
25
|
+
when SYSTEM_CONFIG's database_path is DB_DIR/labctl.db, makes DB_DIR
|
|
26
|
+
setgid 2775, the database (and any -wal/-shm) 664 and DB_DIR's parent
|
|
27
|
+
searchable by GROUP (g+x if missing); prints what
|
|
28
|
+
it did; exit 2 after a warning, never stopping half-way silently
|
|
29
|
+
python -m labctl.core.admin_files copy SRC DST MODE [OLD_LINE NEW_LINE]...
|
|
30
|
+
DST must not exist; lines equal to OLD_LINE are replaced
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import errno
|
|
36
|
+
import grp
|
|
37
|
+
import os
|
|
38
|
+
import pwd
|
|
39
|
+
import stat
|
|
40
|
+
import sys
|
|
41
|
+
|
|
42
|
+
from labctl.core.safe_open import open_dir_nofollow, open_file_nofollow
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UnsafeFileError(OSError):
|
|
46
|
+
"""A path that root must not touch by name (symlink, hard link...)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_single_link(fd: int, path: str) -> None:
|
|
50
|
+
if os.fstat(fd).st_nlink != 1:
|
|
51
|
+
raise UnsafeFileError(errno.EPERM, f"{path} has more than one hard link")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _nofollow(path: str, opener, *args, **kwargs) -> int:
|
|
55
|
+
"""Call a safe_open opener, turning "not followed" errors into
|
|
56
|
+
UnsafeFileError so every refusal reads the same."""
|
|
57
|
+
if not os.path.isabs(path) or os.path.normpath(path) != path:
|
|
58
|
+
# safe_open would also raise EINVAL: say what is actually wrong.
|
|
59
|
+
raise UnsafeFileError(
|
|
60
|
+
errno.EINVAL, f"{path}: path must be absolute and normalized"
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
return opener(*args, **kwargs)
|
|
64
|
+
except OSError as e:
|
|
65
|
+
if e.errno in (errno.ELOOP, errno.ENOTDIR):
|
|
66
|
+
raise UnsafeFileError(
|
|
67
|
+
e.errno, f"{path}: a symlink or non-directory on the path"
|
|
68
|
+
) from e
|
|
69
|
+
if e.errno == errno.EINVAL:
|
|
70
|
+
raise UnsafeFileError(e.errno, f"{path} is not a regular file") from e
|
|
71
|
+
if e.errno == errno.EEXIST:
|
|
72
|
+
raise UnsafeFileError(e.errno, f"{path} already exists") from e
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def open_regular(path: str, flags: int = os.O_RDONLY) -> int:
|
|
77
|
+
"""Open a regular, singly linked file with no symlink on its path."""
|
|
78
|
+
fd = _nofollow(path, open_file_nofollow, path, flags)
|
|
79
|
+
try:
|
|
80
|
+
_check_single_link(fd, path)
|
|
81
|
+
except BaseException:
|
|
82
|
+
os.close(fd)
|
|
83
|
+
raise
|
|
84
|
+
return fd
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def state(path: str) -> str:
|
|
88
|
+
"""'missing' or 'regular'; raises UnsafeFileError for anything else.
|
|
89
|
+
|
|
90
|
+
One walk with no symlink followed on any component: a symlinked parent
|
|
91
|
+
is refused (not "missing"); a missing file or parent is "missing".
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
fd = open_regular(path)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
return "missing"
|
|
97
|
+
os.close(fd)
|
|
98
|
+
return "regular"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ids(user: str, group: str) -> tuple[int, int]:
|
|
102
|
+
return pwd.getpwnam(user).pw_uid, grp.getgrnam(group).gr_gid
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def secure(path: str, user: str, group: str, mode: int) -> None:
|
|
106
|
+
"""chown/chmod a regular file through a descriptor (no symlinks)."""
|
|
107
|
+
uid, gid = _ids(user, group)
|
|
108
|
+
fd = open_regular(path)
|
|
109
|
+
try:
|
|
110
|
+
os.fchown(fd, uid, gid)
|
|
111
|
+
os.fchmod(fd, mode)
|
|
112
|
+
finally:
|
|
113
|
+
os.close(fd)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def secure_dir(path: str, user: str, group: str, mode: int) -> None:
|
|
117
|
+
"""Create (if needed) and chown/chmod a directory, no symlinks.
|
|
118
|
+
|
|
119
|
+
Only the last component is created: a missing parent is an error, not
|
|
120
|
+
a reason to build a root-owned chain (e.g. the labctl home itself).
|
|
121
|
+
"""
|
|
122
|
+
uid, gid = _ids(user, group)
|
|
123
|
+
parent = _nofollow(path, open_dir_nofollow, os.path.dirname(path))
|
|
124
|
+
name = os.path.basename(path)
|
|
125
|
+
try:
|
|
126
|
+
try:
|
|
127
|
+
os.mkdir(name, 0o700, dir_fd=parent)
|
|
128
|
+
except FileExistsError:
|
|
129
|
+
pass # existing (or concurrently created): the open re-checks it
|
|
130
|
+
fd = _nofollow(
|
|
131
|
+
path,
|
|
132
|
+
os.open,
|
|
133
|
+
name,
|
|
134
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
135
|
+
dir_fd=parent,
|
|
136
|
+
)
|
|
137
|
+
finally:
|
|
138
|
+
os.close(parent)
|
|
139
|
+
try:
|
|
140
|
+
os.fchown(fd, uid, gid)
|
|
141
|
+
os.fchmod(fd, mode)
|
|
142
|
+
finally:
|
|
143
|
+
os.close(fd)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def create(path: str, user: str, group: str, mode: int) -> None:
|
|
147
|
+
"""Create an empty file (``O_EXCL``, no symlinks) unless a regular one
|
|
148
|
+
is there, then chown/chmod it like :func:`secure`."""
|
|
149
|
+
parent = _nofollow(path, open_dir_nofollow, os.path.dirname(path))
|
|
150
|
+
name = os.path.basename(path)
|
|
151
|
+
try:
|
|
152
|
+
try:
|
|
153
|
+
fd = _nofollow(
|
|
154
|
+
path,
|
|
155
|
+
os.open,
|
|
156
|
+
name,
|
|
157
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
158
|
+
0o600,
|
|
159
|
+
dir_fd=parent,
|
|
160
|
+
)
|
|
161
|
+
except OSError as e:
|
|
162
|
+
if e.errno != errno.EEXIST:
|
|
163
|
+
raise
|
|
164
|
+
# secure() below refuses anything but a regular file.
|
|
165
|
+
else:
|
|
166
|
+
os.close(fd)
|
|
167
|
+
finally:
|
|
168
|
+
os.close(parent)
|
|
169
|
+
secure(path, user, group, mode)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
DB_NAME = "labctl.db"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _configured_database_path(system_config: str) -> str:
|
|
176
|
+
"""``database_path`` from the (root-owned) system config, or ""."""
|
|
177
|
+
import yaml
|
|
178
|
+
|
|
179
|
+
with open(system_config) as f:
|
|
180
|
+
data = yaml.safe_load(f)
|
|
181
|
+
value = data.get("database_path", "") if isinstance(data, dict) else ""
|
|
182
|
+
return value if isinstance(value, str) else ""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _is_db_in(configured: str, db_dir: str) -> bool:
|
|
186
|
+
"""Whether ``configured`` (database_path) names ``db_dir/labctl.db``.
|
|
187
|
+
|
|
188
|
+
Its directory part is compared resolved, so a symlinked
|
|
189
|
+
/var/lib/labctl (or a path written through one) still matches the
|
|
190
|
+
resolved ``db_dir``; the file name is compared literally and the
|
|
191
|
+
database file itself is never resolved or followed.
|
|
192
|
+
"""
|
|
193
|
+
if not configured or not os.path.isabs(configured):
|
|
194
|
+
return False
|
|
195
|
+
directory, name = os.path.split(configured)
|
|
196
|
+
if name != DB_NAME:
|
|
197
|
+
return False
|
|
198
|
+
resolved = os.path.realpath(directory)
|
|
199
|
+
return resolved in (os.path.normpath(db_dir), os.path.realpath(db_dir))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ensure_group_search(home: str, gid: int) -> str | None:
|
|
203
|
+
"""Make sure members of group ``gid`` can traverse ``home`` (the
|
|
204
|
+
labctl home, already resolved, in root-owned /var/lib).
|
|
205
|
+
|
|
206
|
+
If ``home`` belongs to that group and lacks group execute, add it (only
|
|
207
|
+
``g+x``, through a descriptor opened without following symlinks).
|
|
208
|
+
Returns a reason when group members still can't search it (e.g. it
|
|
209
|
+
belongs to another group), else None.
|
|
210
|
+
"""
|
|
211
|
+
fd = open_dir_nofollow(home)
|
|
212
|
+
try:
|
|
213
|
+
st = os.fstat(fd)
|
|
214
|
+
mode = stat.S_IMODE(st.st_mode)
|
|
215
|
+
if st.st_gid == gid and not mode & stat.S_IXGRP:
|
|
216
|
+
os.fchmod(fd, mode | stat.S_IXGRP)
|
|
217
|
+
print(f"[ok] {home}: added group search (g+x) for the database")
|
|
218
|
+
mode |= stat.S_IXGRP
|
|
219
|
+
finally:
|
|
220
|
+
os.close(fd)
|
|
221
|
+
if (st.st_gid == gid and mode & stat.S_IXGRP) or mode & stat.S_IXOTH:
|
|
222
|
+
return None
|
|
223
|
+
try:
|
|
224
|
+
owner_group = grp.getgrgid(st.st_gid).gr_name
|
|
225
|
+
except KeyError:
|
|
226
|
+
owner_group = str(st.st_gid)
|
|
227
|
+
return (
|
|
228
|
+
f"{home} (mode {mode:04o}, group {owner_group}) can't be traversed by "
|
|
229
|
+
"the group's members, so they can't reach the database"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def shared_db(system_config: str, db_dir: str, user: str, group: str) -> int:
|
|
234
|
+
"""Keep the services' database writable by ``group`` (install-services.sh
|
|
235
|
+
and update.sh both call this).
|
|
236
|
+
|
|
237
|
+
Only when the system config's ``database_path`` is ``db_dir/labctl.db``:
|
|
238
|
+
``db_dir`` setgid ``2775`` (the -wal/-shm files SQLite creates are then
|
|
239
|
+
in the group, with the database's mode; not sticky, so whichever
|
|
240
|
+
process closes the database last can remove them), the database
|
|
241
|
+
(created empty if missing) and any existing -wal/-shm ``664``, and the
|
|
242
|
+
labctl home (``db_dir``'s parent) searchable by the group (``g+x``
|
|
243
|
+
added if missing, nothing else). A symlinked ``db_dir`` (an admin's
|
|
244
|
+
relocation) is left alone. Prints one line per outcome; returns 0, or
|
|
245
|
+
2 after printing a warning, so the calling script carries on.
|
|
246
|
+
"""
|
|
247
|
+
import yaml
|
|
248
|
+
|
|
249
|
+
db_path = os.path.join(db_dir, DB_NAME)
|
|
250
|
+
try:
|
|
251
|
+
configured = _configured_database_path(system_config)
|
|
252
|
+
except (OSError, UnicodeDecodeError, yaml.YAMLError) as e:
|
|
253
|
+
print(
|
|
254
|
+
f"[!!] Database permissions not checked: can't read "
|
|
255
|
+
f"database_path from {system_config}: {e}"
|
|
256
|
+
)
|
|
257
|
+
return 2
|
|
258
|
+
if not _is_db_in(configured, db_dir):
|
|
259
|
+
print(f"[--] The services' database ({configured or 'database_path unset'})")
|
|
260
|
+
print(f" isn't {db_path}, so {group} group members can't write it from")
|
|
261
|
+
print(' the CLI. To move it there, see "Moving the database" in README.md.')
|
|
262
|
+
return 0
|
|
263
|
+
if os.path.islink(db_dir):
|
|
264
|
+
print(f"[ok] {db_dir} is a symlink; leaving it (and its permissions) as is")
|
|
265
|
+
return 0
|
|
266
|
+
problems = []
|
|
267
|
+
try:
|
|
268
|
+
secure_dir(db_dir, user, group, 0o2775)
|
|
269
|
+
create(db_path, user, group, 0o664)
|
|
270
|
+
unreachable = _ensure_group_search(
|
|
271
|
+
os.path.dirname(db_dir), grp.getgrnam(group).gr_gid
|
|
272
|
+
)
|
|
273
|
+
except (OSError, KeyError) as e:
|
|
274
|
+
print(f"[!!] Database {db_path} not made group-writable: {_reason(e)}")
|
|
275
|
+
return 2
|
|
276
|
+
if unreachable:
|
|
277
|
+
problems.append(unreachable)
|
|
278
|
+
for extra in (db_path + "-wal", db_path + "-shm"):
|
|
279
|
+
try:
|
|
280
|
+
if state(extra) == "regular":
|
|
281
|
+
secure(extra, user, group, 0o664)
|
|
282
|
+
except OSError as e:
|
|
283
|
+
problems.append(f"Left as is: {extra}: {_reason(e)}")
|
|
284
|
+
if problems:
|
|
285
|
+
for problem in problems:
|
|
286
|
+
print(f"[!!] {problem}")
|
|
287
|
+
return 2
|
|
288
|
+
print(f"[ok] Database {db_path} ({user}:{group}, 664; group-writable directory)")
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _reason(e: BaseException) -> str:
|
|
293
|
+
if isinstance(e, KeyError):
|
|
294
|
+
return f"unknown user or group {e}"
|
|
295
|
+
if isinstance(e, UnsafeFileError):
|
|
296
|
+
return f"refusing: {e.strerror}"
|
|
297
|
+
return getattr(e, "strerror", None) or str(e)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def copy(src: str, dst: str, mode: int, replace: dict[str, str] | None = None) -> None:
|
|
301
|
+
"""Copy a regular file to a new file (``O_EXCL``), neither via symlinks.
|
|
302
|
+
|
|
303
|
+
Byte for byte (line endings and encoding kept, so a later ``cmp`` of
|
|
304
|
+
the two matches), except that lines equal to a key of ``replace``
|
|
305
|
+
become its value.
|
|
306
|
+
"""
|
|
307
|
+
fd = open_regular(src)
|
|
308
|
+
try:
|
|
309
|
+
with os.fdopen(fd, "rb", closefd=False) as f:
|
|
310
|
+
data = f.read()
|
|
311
|
+
finally:
|
|
312
|
+
os.close(fd)
|
|
313
|
+
if replace:
|
|
314
|
+
swap = {k.encode(): v.encode() for k, v in replace.items()}
|
|
315
|
+
data = b"\n".join(swap.get(line, line) for line in data.split(b"\n"))
|
|
316
|
+
parent = _nofollow(dst, open_dir_nofollow, os.path.dirname(dst))
|
|
317
|
+
name = os.path.basename(dst)
|
|
318
|
+
try:
|
|
319
|
+
out = _nofollow(
|
|
320
|
+
dst,
|
|
321
|
+
os.open,
|
|
322
|
+
name,
|
|
323
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
324
|
+
mode,
|
|
325
|
+
dir_fd=parent,
|
|
326
|
+
)
|
|
327
|
+
try:
|
|
328
|
+
os.fchmod(out, mode) # not narrowed by the umask
|
|
329
|
+
with os.fdopen(out, "wb", closefd=False) as f:
|
|
330
|
+
f.write(data)
|
|
331
|
+
os.fsync(out)
|
|
332
|
+
except BaseException:
|
|
333
|
+
# Never leave a truncated config that a rerun would take as
|
|
334
|
+
# complete ("already exists, skipping"); keep the real error.
|
|
335
|
+
try:
|
|
336
|
+
os.unlink(name, dir_fd=parent)
|
|
337
|
+
except OSError:
|
|
338
|
+
pass
|
|
339
|
+
raise
|
|
340
|
+
finally:
|
|
341
|
+
os.close(out)
|
|
342
|
+
finally:
|
|
343
|
+
os.close(parent)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def main(argv: list[str]) -> int:
|
|
347
|
+
"""Exit 0 on success, 2 with a one-line reason for any refusal or
|
|
348
|
+
error (on stdout for `state`, which the scripts capture; stderr
|
|
349
|
+
otherwise), 64 for bad usage."""
|
|
350
|
+
if not argv:
|
|
351
|
+
print(__doc__, file=sys.stderr)
|
|
352
|
+
return 64
|
|
353
|
+
cmd, *args = argv
|
|
354
|
+
try:
|
|
355
|
+
if cmd == "state" and len(args) == 1:
|
|
356
|
+
print(state(args[0]))
|
|
357
|
+
elif cmd == "secure" and len(args) == 4:
|
|
358
|
+
secure(args[0], args[1], args[2], int(args[3], 8))
|
|
359
|
+
elif cmd == "secure-dir" and len(args) == 4:
|
|
360
|
+
secure_dir(args[0], args[1], args[2], int(args[3], 8))
|
|
361
|
+
elif cmd == "create" and len(args) == 4:
|
|
362
|
+
create(args[0], args[1], args[2], int(args[3], 8))
|
|
363
|
+
elif cmd == "shared-db" and len(args) == 4:
|
|
364
|
+
return shared_db(*args)
|
|
365
|
+
elif cmd == "copy" and len(args) >= 3 and len(args) % 2 == 1:
|
|
366
|
+
pairs = args[3:]
|
|
367
|
+
copy(args[0], args[1], int(args[2], 8), dict(zip(pairs[::2], pairs[1::2])))
|
|
368
|
+
else:
|
|
369
|
+
print(__doc__, file=sys.stderr)
|
|
370
|
+
return 64
|
|
371
|
+
except UnsafeFileError as e:
|
|
372
|
+
reason = f"refusing: {e.strerror}"
|
|
373
|
+
except OSError as e:
|
|
374
|
+
# The paths asked about, not e.filename (often one path component).
|
|
375
|
+
paths = " -> ".join(args[:2] if cmd == "copy" else args[:1])
|
|
376
|
+
reason = f"error: {paths}: {e.strerror or e}"
|
|
377
|
+
except KeyError as e: # unknown user or group
|
|
378
|
+
reason = f"error: unknown user or group {e}"
|
|
379
|
+
except ValueError as e: # bad mode
|
|
380
|
+
reason = f"error: {e}"
|
|
381
|
+
else:
|
|
382
|
+
return 0
|
|
383
|
+
print(reason, file=sys.stdout if cmd == "state" else sys.stderr)
|
|
384
|
+
return 2
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
if __name__ == "__main__":
|
|
388
|
+
sys.exit(main(sys.argv[1:]))
|
labctl/core/audit.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Activity stream — structured record of every state-changing action.
|
|
3
|
+
|
|
4
|
+
Each call to `emit()` writes one row to `audit_log`. The `actor`,
|
|
5
|
+
`source`, and `claim_id` fields are pulled from contextvars set by
|
|
6
|
+
the originating front-end (CLI, MCP, web, daemon) via the
|
|
7
|
+
`activity_context()` context manager.
|
|
8
|
+
|
|
9
|
+
See docs/RESOLVED_SPEC_activity_stream.md for the full design.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from contextvars import ContextVar
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Iterator, Optional
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from labctl.core.database import Database
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
DETAILS_MAX_BYTES = 4096
|
|
27
|
+
SERIAL_BUFFER_HEAD_TAIL = 256
|
|
28
|
+
REDACT_KEYS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"password",
|
|
31
|
+
"token",
|
|
32
|
+
"api_key",
|
|
33
|
+
"apikey",
|
|
34
|
+
"ssh_key",
|
|
35
|
+
"session_cookie",
|
|
36
|
+
"cookie",
|
|
37
|
+
"authorization",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
REDACT_PLACEHOLDER = "***"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_actor: ContextVar[str] = ContextVar("labctl_audit_actor", default="internal")
|
|
44
|
+
_source: ContextVar[str] = ContextVar("labctl_audit_source", default="internal")
|
|
45
|
+
_claim_id: ContextVar[Optional[int]] = ContextVar("labctl_audit_claim_id", default=None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def activity_context(
|
|
50
|
+
actor: str,
|
|
51
|
+
source: str,
|
|
52
|
+
claim_id: Optional[int] = None,
|
|
53
|
+
) -> Iterator[None]:
|
|
54
|
+
"""Set the audit context for the enclosed block.
|
|
55
|
+
|
|
56
|
+
Usage at a front-end boundary:
|
|
57
|
+
|
|
58
|
+
with activity_context(f"cli:{getuser()}", "cli"):
|
|
59
|
+
main()
|
|
60
|
+
"""
|
|
61
|
+
token_a = _actor.set(actor)
|
|
62
|
+
token_s = _source.set(source)
|
|
63
|
+
token_c = _claim_id.set(claim_id)
|
|
64
|
+
try:
|
|
65
|
+
yield
|
|
66
|
+
finally:
|
|
67
|
+
_actor.reset(token_a)
|
|
68
|
+
_source.reset(token_s)
|
|
69
|
+
_claim_id.reset(token_c)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_context(
|
|
73
|
+
actor: str,
|
|
74
|
+
source: str,
|
|
75
|
+
claim_id: Optional[int] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Set the audit context for the remainder of this contextvars context.
|
|
78
|
+
|
|
79
|
+
Prefer `activity_context()` for scoped changes. Use this at process
|
|
80
|
+
entry (e.g., `main()` in the CLI) where there's no natural exit
|
|
81
|
+
point to pair with.
|
|
82
|
+
"""
|
|
83
|
+
_actor.set(actor)
|
|
84
|
+
_source.set(source)
|
|
85
|
+
_claim_id.set(claim_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def current_actor() -> str:
|
|
89
|
+
return _actor.get()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def current_source() -> str:
|
|
93
|
+
return _source.get()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def current_claim_id() -> Optional[int]:
|
|
97
|
+
return _claim_id.get()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _redact(obj: Any) -> Any:
|
|
101
|
+
"""Recursively strip sensitive fields from a details payload."""
|
|
102
|
+
if isinstance(obj, dict):
|
|
103
|
+
return {
|
|
104
|
+
k: REDACT_PLACEHOLDER if k.lower() in REDACT_KEYS else _redact(v)
|
|
105
|
+
for k, v in obj.items()
|
|
106
|
+
}
|
|
107
|
+
if isinstance(obj, list):
|
|
108
|
+
return [_redact(v) for v in obj]
|
|
109
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
110
|
+
return _truncate_buffer(bytes(obj))
|
|
111
|
+
return obj
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _truncate_buffer(buf: bytes) -> str:
|
|
115
|
+
"""Truncate large binary buffers to head+tail with elision marker."""
|
|
116
|
+
if len(buf) <= 2 * SERIAL_BUFFER_HEAD_TAIL:
|
|
117
|
+
return buf.decode("utf-8", errors="replace")
|
|
118
|
+
head = buf[:SERIAL_BUFFER_HEAD_TAIL].decode("utf-8", errors="replace")
|
|
119
|
+
tail = buf[-SERIAL_BUFFER_HEAD_TAIL:].decode("utf-8", errors="replace")
|
|
120
|
+
elided = len(buf) - 2 * SERIAL_BUFFER_HEAD_TAIL
|
|
121
|
+
return f"{head}...<{elided} bytes elided>...{tail}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _serialize_details(details: Optional[dict]) -> Optional[str]:
|
|
125
|
+
if details is None:
|
|
126
|
+
return None
|
|
127
|
+
redacted = _redact(details)
|
|
128
|
+
text = json.dumps(redacted, default=str, separators=(",", ":"))
|
|
129
|
+
if len(text.encode("utf-8")) > DETAILS_MAX_BYTES:
|
|
130
|
+
trimmed = text.encode("utf-8")[: DETAILS_MAX_BYTES - 32]
|
|
131
|
+
text = trimmed.decode("utf-8", errors="replace") + '..."_truncated":true}'
|
|
132
|
+
return text
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _now_ms() -> str:
|
|
136
|
+
"""ISO8601 timestamp with millisecond precision."""
|
|
137
|
+
return (
|
|
138
|
+
datetime.now().strftime("%Y-%m-%d %H:%M:%S.")
|
|
139
|
+
+ f"{datetime.now().microsecond // 1000:03d}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def row_to_event_dict(row) -> dict[str, Any]:
|
|
144
|
+
"""Convert an audit_log row to the public event representation."""
|
|
145
|
+
return {
|
|
146
|
+
"id": row["id"],
|
|
147
|
+
"logged_at": row["logged_at"],
|
|
148
|
+
"actor": row["actor"] or "internal",
|
|
149
|
+
"source": row["source"] or "internal",
|
|
150
|
+
"action": row["action"],
|
|
151
|
+
"entity_type": row["entity_type"],
|
|
152
|
+
"entity_id": row["entity_id"],
|
|
153
|
+
"entity_name": row["entity_name"],
|
|
154
|
+
"result": row["result"] or "ok",
|
|
155
|
+
"details": row["details"],
|
|
156
|
+
"claim_id": row["claim_id"],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def query_events(
|
|
161
|
+
db: "Database",
|
|
162
|
+
*,
|
|
163
|
+
limit: int = 50,
|
|
164
|
+
sbc: Optional[str] = None,
|
|
165
|
+
actor: Optional[str] = None,
|
|
166
|
+
source: Optional[str] = None,
|
|
167
|
+
result: Optional[str] = None,
|
|
168
|
+
since: Optional[str] = None,
|
|
169
|
+
after_id: Optional[int] = None,
|
|
170
|
+
order_desc: bool = True,
|
|
171
|
+
) -> list[dict[str, Any]]:
|
|
172
|
+
"""Query audit_log rows with the standard activity filters."""
|
|
173
|
+
where: list[str] = []
|
|
174
|
+
params: list[Any] = []
|
|
175
|
+
for value, column in (
|
|
176
|
+
(sbc, "entity_name"),
|
|
177
|
+
(actor, "actor"),
|
|
178
|
+
(source, "source"),
|
|
179
|
+
(result, "result"),
|
|
180
|
+
):
|
|
181
|
+
if value:
|
|
182
|
+
where.append(f"{column} = ?")
|
|
183
|
+
params.append(value)
|
|
184
|
+
if since:
|
|
185
|
+
where.append("logged_at >= ?")
|
|
186
|
+
params.append(since)
|
|
187
|
+
if after_id is not None:
|
|
188
|
+
where.append("id > ?")
|
|
189
|
+
params.append(int(after_id))
|
|
190
|
+
|
|
191
|
+
sql = "SELECT * FROM audit_log"
|
|
192
|
+
if where:
|
|
193
|
+
sql += " WHERE " + " AND ".join(where)
|
|
194
|
+
sql += " ORDER BY id DESC" if order_desc else " ORDER BY id ASC"
|
|
195
|
+
sql += " LIMIT ?"
|
|
196
|
+
params.append(int(limit))
|
|
197
|
+
return [row_to_event_dict(r) for r in db.execute(sql, tuple(params))]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def prune_old_events(db: "Database", *, older_than_days: int = 30) -> int:
|
|
201
|
+
"""Delete audit events older than the given retention window."""
|
|
202
|
+
cutoff = (datetime.now() - timedelta(days=int(older_than_days))).strftime(
|
|
203
|
+
"%Y-%m-%d %H:%M:%S.%f"
|
|
204
|
+
)[:-3]
|
|
205
|
+
return db.execute_modify(
|
|
206
|
+
"""
|
|
207
|
+
DELETE FROM audit_log
|
|
208
|
+
WHERE logged_at < ?
|
|
209
|
+
""",
|
|
210
|
+
(cutoff,),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def emit(
|
|
215
|
+
db: "Database",
|
|
216
|
+
*,
|
|
217
|
+
action: str,
|
|
218
|
+
entity_type: str = "",
|
|
219
|
+
entity_id: Optional[int] = None,
|
|
220
|
+
entity_name: Optional[str] = None,
|
|
221
|
+
result: str = "ok",
|
|
222
|
+
details: Optional[dict] = None,
|
|
223
|
+
actor: Optional[str] = None,
|
|
224
|
+
source: Optional[str] = None,
|
|
225
|
+
claim_id: Optional[int] = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Record a single activity event.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
db: Database handle.
|
|
231
|
+
action: Canonical verb_noun action name (e.g. "power_on").
|
|
232
|
+
entity_type: Kind of entity acted on (e.g. "sbc", "serial_port").
|
|
233
|
+
entity_id: Numeric ID of the entity (optional).
|
|
234
|
+
entity_name: Human-readable target (e.g. SBC name). Used as `target`.
|
|
235
|
+
result: "ok" | "error" | "forbidden".
|
|
236
|
+
details: Structured details. Redacted and JSON-encoded.
|
|
237
|
+
actor: Override the contextvar actor (rare; usually leave None).
|
|
238
|
+
source: Override the contextvar source.
|
|
239
|
+
claim_id: Override the contextvar claim id.
|
|
240
|
+
|
|
241
|
+
Never raises — audit failures are logged but do not propagate.
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
effective_actor = actor if actor is not None else _actor.get()
|
|
245
|
+
effective_source = source if source is not None else _source.get()
|
|
246
|
+
effective_claim = claim_id if claim_id is not None else _claim_id.get()
|
|
247
|
+
details_text = _serialize_details(details)
|
|
248
|
+
db.execute_insert(
|
|
249
|
+
"""
|
|
250
|
+
INSERT INTO audit_log (
|
|
251
|
+
action, entity_type, entity_id, entity_name,
|
|
252
|
+
details, logged_at, actor, source, result, claim_id
|
|
253
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
254
|
+
""",
|
|
255
|
+
(
|
|
256
|
+
action,
|
|
257
|
+
entity_type,
|
|
258
|
+
entity_id,
|
|
259
|
+
entity_name,
|
|
260
|
+
details_text,
|
|
261
|
+
_now_ms(),
|
|
262
|
+
effective_actor,
|
|
263
|
+
effective_source,
|
|
264
|
+
result,
|
|
265
|
+
effective_claim,
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
except Exception as e: # noqa: BLE001
|
|
269
|
+
logger.warning("audit.emit failed for action=%s: %s", action, e)
|