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.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. labctl/web/websocket.py +208 -0
@@ -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)