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
labctl/core/config.py ADDED
@@ -0,0 +1,1068 @@
1
+ """
2
+ Configuration management for lab controller.
3
+
4
+ Loads configuration from YAML files with environment variable overrides.
5
+ """
6
+
7
+ import contextvars
8
+ import errno
9
+ import hmac
10
+ import logging
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ import yaml
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Set during load_config(fresh=True): its per-request re-reads (API keys)
21
+ # must not log on every request; the caller reports state changes instead.
22
+ _quiet = contextvars.ContextVar("labctl_config_quiet", default=False)
23
+
24
+
25
+ class _QuietFilter(logging.Filter):
26
+ def filter(self, record: logging.LogRecord) -> bool:
27
+ return not _quiet.get()
28
+
29
+
30
+ logger.addFilter(_QuietFilter())
31
+
32
+
33
+ def _default_config_dir() -> Path:
34
+ """Return the user config dir, honoring XDG_CONFIG_HOME when set."""
35
+ base = os.environ.get("XDG_CONFIG_HOME")
36
+ if base:
37
+ return Path(base).expanduser() / "labctl"
38
+ return Path.home() / ".config" / "labctl"
39
+
40
+
41
+ def _default_config_file() -> Path:
42
+ """Return the default config file path for the current user."""
43
+ return _default_config_dir() / "config.yaml"
44
+
45
+
46
+ def _default_data_dir() -> Path:
47
+ """Return the user data dir, honoring XDG_DATA_HOME when set."""
48
+ base = os.environ.get("XDG_DATA_HOME")
49
+ if base:
50
+ return Path(base).expanduser() / "labctl"
51
+ return Path.home() / ".local" / "share" / "labctl"
52
+
53
+
54
+ def _expand_path(value: str | Path) -> Path:
55
+ """Expand env vars and ~ in config-provided path values."""
56
+ return Path(os.path.expandvars(str(value))).expanduser()
57
+
58
+
59
+ # LABCTL_SYSTEM_CONFIG replaces the system-wide fallback (read at import,
60
+ # so child processes inherit it; ~ and $VARS expanded, as for
61
+ # LABCTL_CONFIG); the test suite points it at a file that doesn't exist,
62
+ # so tests never load a lab host's real config.
63
+ SYSTEM_CONFIG_FILE = _expand_path(
64
+ os.environ.get("LABCTL_SYSTEM_CONFIG") or "/etc/labctl/config.yaml"
65
+ )
66
+
67
+
68
+ def _section(data: dict, key: str) -> dict:
69
+ """A config section as a dict, tolerating an empty or malformed one.
70
+
71
+ A header with everything under it commented out (``mcp:`` followed only
72
+ by ``#`` lines) parses as None; ``data.get(key, {})`` then returned None
73
+ and every labctl command crashed in from_dict. Treat None as an empty
74
+ section; ignore (with a warning) anything that isn't a mapping.
75
+ """
76
+ value = data.get(key)
77
+ if value is None:
78
+ return {}
79
+ if not isinstance(value, dict):
80
+ logger.warning("Config: section %r is not a mapping; ignoring", key)
81
+ return {}
82
+ return value
83
+
84
+
85
+ def _path_list(section: dict, key: str) -> list[str]:
86
+ """Read a list of absolute directory paths (an allowlist) from config.
87
+
88
+ Accepts a list or a single string. ``~`` and ``$VARS`` are expanded.
89
+ Relative or non-string entries are skipped with a warning: an allowlist
90
+ root that silently depended on the working directory would be a trap.
91
+ """
92
+ raw = section.get(key) or []
93
+ if isinstance(raw, str):
94
+ raw = [raw]
95
+ if not isinstance(raw, list):
96
+ logger.warning("Config: %s must be a list of paths; ignoring", key)
97
+ return []
98
+ paths = []
99
+ for entry in raw:
100
+ if not isinstance(entry, str) or not entry:
101
+ logger.warning("Config: %s entry %r is not a path; ignoring", key, entry)
102
+ continue
103
+ path = _expand_path(entry)
104
+ if not path.is_absolute():
105
+ logger.warning("Config: %s entry %r is not absolute; ignoring", key, entry)
106
+ continue
107
+ paths.append(str(path))
108
+ return paths
109
+
110
+
111
+ def _abs_path(section: dict, key: str) -> str:
112
+ """Read one absolute path from config ("" when unset). ``~`` and
113
+ ``$VARS`` are expanded; a relative or non-string value is ignored with
114
+ a warning, as in _path_list."""
115
+ raw = section.get(key)
116
+ if raw is None or raw == "":
117
+ return ""
118
+ if not isinstance(raw, str):
119
+ logger.warning("Config: %s must be a path, got %r; ignoring", key, raw)
120
+ return ""
121
+ path = _expand_path(raw)
122
+ if not path.is_absolute():
123
+ logger.warning("Config: %s %r is not absolute; ignoring", key, raw)
124
+ return ""
125
+ return str(path)
126
+
127
+
128
+ def _positive_int(section: dict, key: str, default: int) -> int:
129
+ """Read a whole number above zero; anything else keeps the default,
130
+ with a warning."""
131
+ value = section.get(key)
132
+ if value is None:
133
+ return default
134
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
135
+ logger.warning(
136
+ "Config: %s must be a whole number above 0, got %r; using %d",
137
+ key,
138
+ value,
139
+ default,
140
+ )
141
+ return default
142
+ return value
143
+
144
+
145
+ def _non_negative_int(section: dict, key: str, default: int) -> int:
146
+ """Read a whole number of 0 or more; anything else keeps the default,
147
+ with a warning."""
148
+ value = section.get(key)
149
+ if value is None:
150
+ return default
151
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
152
+ _warn_once(
153
+ "Config: %s must be a whole number (0 or more), got %r; using %d",
154
+ key,
155
+ value,
156
+ default,
157
+ )
158
+ return default
159
+ return value
160
+
161
+
162
+ def _tcp_port(section: dict, key: str, default: int) -> int:
163
+ """Read a TCP port number (1-65535); anything else keeps the default,
164
+ with a warning."""
165
+ value = section.get(key)
166
+ if value is None:
167
+ return default
168
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 65535:
169
+ _warn_once(
170
+ "Config: %s must be a TCP port number (1-65535), got %r; using %d",
171
+ key,
172
+ value,
173
+ default,
174
+ )
175
+ return default
176
+ return value
177
+
178
+
179
+ # Keys earlier versions documented but never acted on; they were removed.
180
+ # A config that still sets one gets a warning, not an error.
181
+ _REMOVED_KEYS = {
182
+ "proxy": ("enabled", "log_retention_days", "idle_timeout"),
183
+ "health": ("update_status_on_check",),
184
+ }
185
+
186
+
187
+ def _warn_removed_keys(data: dict) -> None:
188
+ for section, keys in _REMOVED_KEYS.items():
189
+ values = _section(data, section)
190
+ for key in keys:
191
+ if key in values:
192
+ _warn_once(
193
+ "Config: %s.%s is no longer supported and is ignored",
194
+ section,
195
+ key,
196
+ )
197
+
198
+
199
+ def _safety_flag(section: dict, key: str, default: bool = True) -> bool:
200
+ """Read an on/off safety switch: only an explicit boolean counts.
201
+
202
+ A key left blank (``confirm_destructive:``) parses as None, and
203
+ ``bool(None)`` would silently turn the safety off. Blank keeps the safe
204
+ default; any other non-boolean also keeps it, with a warning.
205
+ """
206
+ value = section.get(key)
207
+ if value is None:
208
+ return default
209
+ if isinstance(value, bool):
210
+ return value
211
+ _warn_once(
212
+ "Config: %s must be true or false, got %r; keeping %s", key, value, default
213
+ )
214
+ return default
215
+
216
+
217
+ _FALSE_WORDS = {"false", "no", "off", "0"}
218
+
219
+ # The MCP HTTP server re-reads the config for every request (API keys),
220
+ # so warn about a given bad value once per process, not per request.
221
+ _warned: set[str] = set()
222
+
223
+
224
+ def _warn_once(msg: str, *args) -> None:
225
+ text = msg % args
226
+ if text not in _warned:
227
+ _warned.add(text)
228
+ logger.warning(text)
229
+
230
+
231
+ def _opt_in_flag(section: dict, key: str) -> bool:
232
+ """Read an opt-in safety switch (off by default). Booleans count as
233
+ given; blank keeps it off; anything else is on, with a warning, unless
234
+ it plainly means off ("false", "no", "off", 0): a quoted ``"true"``
235
+ or ``"yes"`` mustn't silently leave the protection off."""
236
+ value = section.get(key)
237
+ if isinstance(value, str) and not value.strip():
238
+ value = None # blank, like an empty YAML value
239
+ if value is None or isinstance(value, bool):
240
+ return bool(value)
241
+ enabled = str(value).strip().lower() not in _FALSE_WORDS
242
+ _warn_once(
243
+ "Config: %s must be true or false, got %r; using %s", key, value, enabled
244
+ )
245
+ return enabled
246
+
247
+
248
+ def _role(user: dict) -> str:
249
+ """A user's role: missing or blank means operator (existing configs
250
+ keep working); anything unrecognised means agent, the role with less
251
+ privilege, with a warning."""
252
+ value = user.get("role")
253
+ if value is None or (isinstance(value, str) and not value.strip()):
254
+ return ROLE_OPERATOR
255
+ if isinstance(value, str) and value.strip().lower() in ROLES:
256
+ return value.strip().lower()
257
+ _warn_once(
258
+ "Config: auth user %r has unknown role %r (expected %s); treating as %s",
259
+ user.get("username", ""),
260
+ value,
261
+ " or ".join(ROLES),
262
+ ROLE_AGENT,
263
+ )
264
+ return ROLE_AGENT
265
+
266
+
267
+ def _name_list(section: dict, key: str) -> list[str]:
268
+ """Read a list of names (strings) from config; skip non-strings."""
269
+ raw = section.get(key) or []
270
+ if isinstance(raw, str):
271
+ raw = [raw]
272
+ if not isinstance(raw, list):
273
+ logger.warning("Config: %s must be a list of names; ignoring", key)
274
+ return []
275
+ names = [n for n in raw if isinstance(n, str) and n]
276
+ if len(names) != len(raw):
277
+ logger.warning("Config: %s has non-string entries; ignoring them", key)
278
+ return names
279
+
280
+
281
+ def _path_exists(path: Path) -> bool:
282
+ """Best-effort existence check that tolerates unreadable paths."""
283
+ try:
284
+ return path.exists()
285
+ except OSError as e:
286
+ logger.warning("Failed to access config path %s: %s", path, e)
287
+ return False
288
+
289
+
290
+ @dataclass
291
+ class SerialConfig:
292
+ """Serial port configuration."""
293
+
294
+ dev_dir: Path = field(default_factory=lambda: Path("/dev/lab"))
295
+ base_tcp_port: int = 4000
296
+ default_baud: int = 115200
297
+
298
+
299
+ @dataclass
300
+ class Ser2NetConfig:
301
+ """ser2net configuration."""
302
+
303
+ config_file: Path = field(default_factory=lambda: Path("/etc/ser2net.yaml"))
304
+ enabled: bool = True
305
+
306
+
307
+ @dataclass
308
+ class ProxyConfig:
309
+ """Multi-client serial proxy configuration."""
310
+
311
+ port_base: int = 5500
312
+ port_range: int = 100 # Ports 5500-5599 available
313
+ write_policy: str = "first" # first, all, queue
314
+ log_dir: Path = field(default_factory=lambda: _default_data_dir() / "logs")
315
+ max_clients: int = 10
316
+
317
+
318
+ @dataclass
319
+ class HealthConfig:
320
+ """Health check and monitoring configuration.
321
+
322
+ The daemon runs two cadences:
323
+ - Fast track (`check_interval`): ping + serial probe.
324
+ - Slow track (`power_check_interval`): power probe (network-bound,
325
+ sometimes slow). Power runs on every Nth fast tick where
326
+ N = ceil(power_check_interval / check_interval).
327
+
328
+ `min_sleep_seconds` is the floor on between-cycle sleep so a runaway
329
+ cycle can't pin a CPU spinning when elapsed >= check_interval.
330
+ """
331
+
332
+ check_interval: int = 10 # seconds between fast (ping+serial) cycles
333
+ power_check_interval: int = 60 # seconds between power probes
334
+ min_sleep_seconds: float = 1.0 # floor on between-cycle sleep
335
+ ping_timeout: float = 2.0 # seconds
336
+ serial_timeout: float = 2.0 # seconds
337
+ # Days the monitor keeps status history; 0 keeps all of it.
338
+ status_retention_days: int = 30
339
+ alert_log_path: Path = field(
340
+ default_factory=lambda: _default_data_dir() / "alerts.log"
341
+ )
342
+ alert_on_offline: bool = True
343
+ alert_on_power_change: bool = True
344
+
345
+
346
+ @dataclass
347
+ class WebConfig:
348
+ """Web server SSL/TLS configuration."""
349
+
350
+ cert_file: str = ""
351
+ key_file: str = ""
352
+
353
+
354
+ @dataclass
355
+ class KasaConfig:
356
+ """TP-Link Kasa device credentials.
357
+
358
+ Newer Kasa devices (KLAP protocol) require TP-Link cloud account
359
+ credentials for local control.
360
+ """
361
+
362
+ username: str = ""
363
+ password: str = ""
364
+
365
+
366
+ # One image upload over MCP HTTP (mcp.max_upload_mb): room for a full
367
+ # SD-card image of a typical embedded Linux build.
368
+ DEFAULT_MAX_UPLOAD_MB = 8192
369
+
370
+ ROLE_OPERATOR = "operator"
371
+ ROLE_AGENT = "agent"
372
+ ROLES = (ROLE_OPERATOR, ROLE_AGENT)
373
+
374
+
375
+ @dataclass
376
+ class UserConfig:
377
+ """User authentication configuration.
378
+
379
+ ``role`` decides who may override another user's claim (D013):
380
+ operators may force-release or override; agents can only ask.
381
+ """
382
+
383
+ username: str = ""
384
+ password_hash: str = ""
385
+ api_key: str = ""
386
+ role: str = ROLE_OPERATOR
387
+
388
+ @property
389
+ def is_operator(self) -> bool:
390
+ return self.role == ROLE_OPERATOR
391
+
392
+
393
+ @dataclass
394
+ class AuthConfig:
395
+ """Authentication configuration."""
396
+
397
+ enabled: bool = False
398
+ users: list[UserConfig] = field(default_factory=list)
399
+ secret_key: str = ""
400
+ session_lifetime_minutes: int = 480
401
+
402
+ def key_users(self) -> list[UserConfig]:
403
+ """Users that can authenticate with an API key.
404
+
405
+ Both the key and the username must be non-empty strings: a YAML
406
+ scalar like ``12345`` loads as int, and a blank username would make
407
+ the user indistinguishable from an unauthenticated caller.
408
+ """
409
+ return [
410
+ u
411
+ for u in self.users
412
+ if isinstance(u.api_key, str)
413
+ and u.api_key
414
+ and isinstance(u.username, str)
415
+ and u.username
416
+ ]
417
+
418
+ def user_for_api_key(self, api_key: str) -> Optional[UserConfig]:
419
+ """The user whose API key this is, or None.
420
+
421
+ Shared by the web REST API (``X-API-Key``) and the MCP HTTP server
422
+ (``Authorization: Bearer``). Compares as UTF-8 bytes in constant
423
+ time: ``hmac.compare_digest`` raises TypeError on non-ASCII str, and
424
+ a header value is attacker-controlled. An empty key never matches,
425
+ nor does a user without a usable name (see ``key_users``).
426
+ """
427
+ if not api_key:
428
+ return None
429
+ presented = api_key.encode("utf-8", "surrogateescape")
430
+ match = None
431
+ for user in self.key_users():
432
+ if hmac.compare_digest(
433
+ user.api_key.encode("utf-8", "surrogateescape"), presented
434
+ ):
435
+ match = match or user
436
+ return match
437
+
438
+
439
+ @dataclass
440
+ class ClaimsConfig:
441
+ """Hardware claim (exclusive access) configuration."""
442
+
443
+ enabled: bool = True
444
+ default_duration_minutes: int = 30
445
+ max_duration_minutes: int = 1440 # 24h — covers overnight reliability runs
446
+ min_duration_minutes: int = 1
447
+ grace_period_seconds: int = 60
448
+ auto_prune_released_after_days: int = 30
449
+ require_agent_name: bool = False
450
+ # MCP/REST calls that change a board refuse unless the caller holds
451
+ # its claim (D013). Off by default: single-agent setups never claim.
452
+ require_claim: bool = False
453
+
454
+ def validate(self) -> list[str]:
455
+ """Check invariants, returning a list of warning messages.
456
+
457
+ Clamps obviously wrong values to sane defaults so the system
458
+ still functions. Call after construction.
459
+ """
460
+ warnings = []
461
+ if self.require_claim and not self.enabled:
462
+ warnings.append(
463
+ "claims.require_claim has no effect while claims.enabled is false"
464
+ )
465
+ if self.min_duration_minutes < 1:
466
+ warnings.append(
467
+ f"claims.min_duration_minutes={self.min_duration_minutes}"
468
+ " clamped to 1"
469
+ )
470
+ self.min_duration_minutes = 1
471
+ if self.max_duration_minutes < self.min_duration_minutes:
472
+ warnings.append(
473
+ f"claims.max_duration_minutes={self.max_duration_minutes}"
474
+ f" < min ({self.min_duration_minutes}), clamped to min"
475
+ )
476
+ self.max_duration_minutes = self.min_duration_minutes
477
+ if (
478
+ self.default_duration_minutes < self.min_duration_minutes
479
+ or self.default_duration_minutes > self.max_duration_minutes
480
+ ):
481
+ clamped = max(
482
+ self.min_duration_minutes,
483
+ min(self.default_duration_minutes, self.max_duration_minutes),
484
+ )
485
+ warnings.append(
486
+ f"claims.default_duration_minutes="
487
+ f"{self.default_duration_minutes}"
488
+ f" outside [{self.min_duration_minutes},"
489
+ f" {self.max_duration_minutes}], clamped to {clamped}"
490
+ )
491
+ self.default_duration_minutes = clamped
492
+ if self.grace_period_seconds < 0:
493
+ warnings.append("claims.grace_period_seconds < 0, clamped to 0")
494
+ self.grace_period_seconds = 0
495
+ if self.auto_prune_released_after_days < 1:
496
+ warnings.append("claims.auto_prune_released_after_days < 1, clamped to 1")
497
+ self.auto_prune_released_after_days = 1
498
+ return warnings
499
+
500
+
501
+ @dataclass
502
+ class DatabaseConfig:
503
+ """SQLite database tuning knobs.
504
+
505
+ ``timeout_seconds`` is the SQLite busy-wait used when another
506
+ process holds an incompatible lock. With WAL mode (always on for
507
+ labctl), readers never block writers and vice versa — the only
508
+ serialization is writer-vs-writer, which for labctl's
509
+ single-statement writes commits sub-millisecond. 10s is several
510
+ orders of magnitude over the realistic worst case; raise only if
511
+ a migration on a contended DB is timing out.
512
+ """
513
+
514
+ timeout_seconds: float = 10.0
515
+
516
+
517
+ @dataclass
518
+ class McpConfig:
519
+ """MCP server configuration.
520
+
521
+ ``allow_admin_actuator_ops`` gates the privileged actuator
522
+ provisioning tools (``actuator_add``, ``actuator_remove``,
523
+ ``actuator_set``) over MCP. Defaults to False because raw
524
+ ``actuator_set`` lets an agent bypass the binding layer; operators
525
+ deploying MCP behind a trusted transport can flip this on.
526
+
527
+ ``allowed_read_paths`` / ``allowed_write_paths`` list the host
528
+ directories MCP tools may read (images, files to copy onto an SD card)
529
+ or write (boot-test output). Both default to empty, which denies all
530
+ host file access over MCP (D012); the CLI is not affected.
531
+
532
+ ``confirm_destructive`` (default True) makes every MCP tool annotated
533
+ destructive a two-step call: the first call returns a plan and a
534
+ single-use ``confirm_token``; only a second call with that token and the
535
+ same arguments acts (D012). ``confirm_exempt`` lists tool names that
536
+ skip this (e.g. ``serial_send`` for heavy interactive console use).
537
+
538
+ ``allowed_hosts`` / ``allowed_origins`` add accepted ``Host`` / ``Origin``
539
+ header values for the HTTP transport (DNS-rebinding protection), e.g. the
540
+ public name a reverse proxy forwards. ``name:*`` matches any port. HTTP
541
+ authentication itself follows ``auth.enabled`` and the users' API keys.
542
+
543
+ ``upload_dir`` is the directory ``PUT /images/<name>`` on the HTTP
544
+ transport writes to (empty, the default, disables uploads); it should
545
+ be inside ``allowed_read_paths`` so tools can use what's uploaded.
546
+ ``max_upload_mb`` caps one upload's size (D014).
547
+
548
+ ``cert_file`` / ``key_file`` serve the HTTP transport over TLS (both or
549
+ neither; ``labctl mcp --cert/--key`` take precedence).
550
+ """
551
+
552
+ allow_admin_actuator_ops: bool = False
553
+ allowed_read_paths: list[str] = field(default_factory=list)
554
+ allowed_write_paths: list[str] = field(default_factory=list)
555
+ confirm_destructive: bool = True
556
+ confirm_exempt: list[str] = field(default_factory=list)
557
+ allowed_hosts: list[str] = field(default_factory=list)
558
+ allowed_origins: list[str] = field(default_factory=list)
559
+ upload_dir: str = ""
560
+ max_upload_mb: int = DEFAULT_MAX_UPLOAD_MB
561
+ cert_file: str = ""
562
+ key_file: str = ""
563
+
564
+
565
+ @dataclass
566
+ class Config:
567
+ """Main configuration for lab controller."""
568
+
569
+ serial: SerialConfig = field(default_factory=SerialConfig)
570
+ ser2net: Ser2NetConfig = field(default_factory=Ser2NetConfig)
571
+ proxy: ProxyConfig = field(default_factory=ProxyConfig)
572
+ health: HealthConfig = field(default_factory=HealthConfig)
573
+ auth: AuthConfig = field(default_factory=AuthConfig)
574
+ web: WebConfig = field(default_factory=WebConfig)
575
+ kasa: KasaConfig = field(default_factory=KasaConfig)
576
+ claims: ClaimsConfig = field(default_factory=ClaimsConfig)
577
+ mcp: McpConfig = field(default_factory=McpConfig)
578
+ database: DatabaseConfig = field(default_factory=DatabaseConfig)
579
+ database_path: Path = field(
580
+ default_factory=lambda: _default_config_dir() / "labctl.db"
581
+ )
582
+ log_level: str = "WARNING"
583
+ # The file this config was loaded from (None: defaults); not serialized.
584
+ source_path: Optional[Path] = field(default=None, compare=False, repr=False)
585
+ # "<path>: <error>" for each config file found but not loadable.
586
+ load_errors: list[str] = field(default_factory=list, compare=False, repr=False)
587
+ # True if one of those was a permission error (EACCES/EPERM).
588
+ load_denied: bool = field(default=False, compare=False, repr=False)
589
+
590
+ @classmethod
591
+ def from_dict(cls, data: dict[str, Any]) -> "Config":
592
+ """Create Config from dictionary."""
593
+ serial_data = _section(data, "serial")
594
+ ser2net_data = _section(data, "ser2net")
595
+ proxy_data = _section(data, "proxy")
596
+ health_data = _section(data, "health")
597
+ auth_data = _section(data, "auth")
598
+ web_data = _section(data, "web")
599
+ kasa_data = _section(data, "kasa")
600
+ claims_data = _section(data, "claims")
601
+ mcp_data = _section(data, "mcp")
602
+ database_data = _section(data, "database")
603
+ _warn_removed_keys(data)
604
+
605
+ serial = SerialConfig(
606
+ dev_dir=_expand_path(serial_data.get("dev_dir", "/dev/lab")),
607
+ base_tcp_port=_tcp_port(serial_data, "base_tcp_port", 4000),
608
+ default_baud=serial_data.get("default_baud", 115200),
609
+ )
610
+
611
+ ser2net = Ser2NetConfig(
612
+ config_file=_expand_path(
613
+ ser2net_data.get("config_file", "/etc/ser2net.yaml")
614
+ ),
615
+ enabled=ser2net_data.get("enabled", True),
616
+ )
617
+
618
+ proxy = ProxyConfig(
619
+ port_base=proxy_data.get("port_base", 5500),
620
+ port_range=proxy_data.get("port_range", 100),
621
+ write_policy=proxy_data.get("write_policy", "first"),
622
+ log_dir=_expand_path(
623
+ proxy_data.get("log_dir", str(_default_data_dir() / "logs"))
624
+ ),
625
+ max_clients=proxy_data.get("max_clients", 10),
626
+ )
627
+
628
+ health = HealthConfig(
629
+ check_interval=health_data.get("check_interval", 10),
630
+ power_check_interval=health_data.get("power_check_interval", 60),
631
+ min_sleep_seconds=health_data.get("min_sleep_seconds", 1.0),
632
+ ping_timeout=health_data.get("ping_timeout", 2.0),
633
+ serial_timeout=health_data.get("serial_timeout", 2.0),
634
+ status_retention_days=_non_negative_int(
635
+ health_data, "status_retention_days", 30
636
+ ),
637
+ alert_log_path=_expand_path(
638
+ health_data.get(
639
+ "alert_log_path",
640
+ str(_default_data_dir() / "alerts.log"),
641
+ )
642
+ ),
643
+ alert_on_offline=health_data.get("alert_on_offline", True),
644
+ alert_on_power_change=health_data.get("alert_on_power_change", True),
645
+ )
646
+
647
+ auth_users = []
648
+ for u in auth_data.get("users", []):
649
+ auth_users.append(
650
+ UserConfig(
651
+ username=u.get("username", ""),
652
+ password_hash=u.get("password_hash", ""),
653
+ api_key=u.get("api_key", ""),
654
+ role=_role(u),
655
+ )
656
+ )
657
+
658
+ auth = AuthConfig(
659
+ enabled=auth_data.get("enabled", False),
660
+ users=auth_users,
661
+ secret_key=auth_data.get("secret_key", ""),
662
+ session_lifetime_minutes=auth_data.get("session_lifetime_minutes", 480),
663
+ )
664
+
665
+ web = WebConfig(
666
+ cert_file=web_data.get("cert_file", ""),
667
+ key_file=web_data.get("key_file", ""),
668
+ )
669
+
670
+ kasa = KasaConfig(
671
+ username=kasa_data.get("username", ""),
672
+ password=kasa_data.get("password", ""),
673
+ )
674
+
675
+ claims = ClaimsConfig(
676
+ enabled=claims_data.get("enabled", True),
677
+ default_duration_minutes=claims_data.get("default_duration_minutes", 30),
678
+ max_duration_minutes=claims_data.get("max_duration_minutes", 1440),
679
+ min_duration_minutes=claims_data.get("min_duration_minutes", 1),
680
+ grace_period_seconds=claims_data.get("grace_period_seconds", 60),
681
+ auto_prune_released_after_days=claims_data.get(
682
+ "auto_prune_released_after_days", 30
683
+ ),
684
+ require_agent_name=_opt_in_flag(claims_data, "require_agent_name"),
685
+ require_claim=_opt_in_flag(claims_data, "require_claim"),
686
+ )
687
+
688
+ # Validate and clamp claim config bounds
689
+ claim_warnings = claims.validate()
690
+ for w in claim_warnings:
691
+ logger.warning("Config: %s", w)
692
+
693
+ mcp = McpConfig(
694
+ # A grant, not a protection: only an explicit true turns it on.
695
+ allow_admin_actuator_ops=_safety_flag(
696
+ mcp_data, "allow_admin_actuator_ops", default=False
697
+ ),
698
+ allowed_read_paths=_path_list(mcp_data, "allowed_read_paths"),
699
+ allowed_write_paths=_path_list(mcp_data, "allowed_write_paths"),
700
+ confirm_destructive=_safety_flag(mcp_data, "confirm_destructive"),
701
+ confirm_exempt=_name_list(mcp_data, "confirm_exempt"),
702
+ allowed_hosts=_name_list(mcp_data, "allowed_hosts"),
703
+ allowed_origins=_name_list(mcp_data, "allowed_origins"),
704
+ upload_dir=_abs_path(mcp_data, "upload_dir"),
705
+ max_upload_mb=_positive_int(
706
+ mcp_data, "max_upload_mb", DEFAULT_MAX_UPLOAD_MB
707
+ ),
708
+ cert_file=_abs_path(mcp_data, "cert_file"),
709
+ key_file=_abs_path(mcp_data, "key_file"),
710
+ )
711
+
712
+ database = DatabaseConfig(
713
+ timeout_seconds=float(database_data.get("timeout_seconds", 10.0)),
714
+ )
715
+
716
+ return cls(
717
+ serial=serial,
718
+ ser2net=ser2net,
719
+ proxy=proxy,
720
+ health=health,
721
+ auth=auth,
722
+ web=web,
723
+ kasa=kasa,
724
+ claims=claims,
725
+ mcp=mcp,
726
+ database=database,
727
+ database_path=_expand_path(
728
+ data.get("database_path", str(_default_config_dir() / "labctl.db"))
729
+ ),
730
+ log_level=data.get("log_level", "WARNING"),
731
+ )
732
+
733
+ def to_dict(self) -> dict[str, Any]:
734
+ """Convert Config to dictionary."""
735
+ return {
736
+ "serial": {
737
+ "dev_dir": str(self.serial.dev_dir),
738
+ "base_tcp_port": self.serial.base_tcp_port,
739
+ "default_baud": self.serial.default_baud,
740
+ },
741
+ "ser2net": {
742
+ "config_file": str(self.ser2net.config_file),
743
+ "enabled": self.ser2net.enabled,
744
+ },
745
+ "proxy": {
746
+ "port_base": self.proxy.port_base,
747
+ "port_range": self.proxy.port_range,
748
+ "write_policy": self.proxy.write_policy,
749
+ "log_dir": str(self.proxy.log_dir),
750
+ "max_clients": self.proxy.max_clients,
751
+ },
752
+ "health": {
753
+ "check_interval": self.health.check_interval,
754
+ "power_check_interval": self.health.power_check_interval,
755
+ "min_sleep_seconds": self.health.min_sleep_seconds,
756
+ "ping_timeout": self.health.ping_timeout,
757
+ "serial_timeout": self.health.serial_timeout,
758
+ "status_retention_days": self.health.status_retention_days,
759
+ "alert_log_path": str(self.health.alert_log_path),
760
+ "alert_on_offline": self.health.alert_on_offline,
761
+ "alert_on_power_change": self.health.alert_on_power_change,
762
+ },
763
+ "auth": {
764
+ "enabled": self.auth.enabled,
765
+ "users": [
766
+ {
767
+ "username": u.username,
768
+ "password_hash": u.password_hash,
769
+ "api_key": u.api_key,
770
+ "role": u.role,
771
+ }
772
+ for u in self.auth.users
773
+ ],
774
+ "secret_key": self.auth.secret_key,
775
+ "session_lifetime_minutes": self.auth.session_lifetime_minutes,
776
+ },
777
+ "web": {
778
+ "cert_file": self.web.cert_file,
779
+ "key_file": self.web.key_file,
780
+ },
781
+ "kasa": {
782
+ "username": self.kasa.username,
783
+ "password": self.kasa.password,
784
+ },
785
+ "claims": {
786
+ "enabled": self.claims.enabled,
787
+ "default_duration_minutes": self.claims.default_duration_minutes,
788
+ "max_duration_minutes": self.claims.max_duration_minutes,
789
+ "min_duration_minutes": self.claims.min_duration_minutes,
790
+ "grace_period_seconds": self.claims.grace_period_seconds,
791
+ "auto_prune_released_after_days": (
792
+ self.claims.auto_prune_released_after_days
793
+ ),
794
+ "require_agent_name": self.claims.require_agent_name,
795
+ "require_claim": self.claims.require_claim,
796
+ },
797
+ "mcp": {
798
+ "allow_admin_actuator_ops": self.mcp.allow_admin_actuator_ops,
799
+ "allowed_read_paths": list(self.mcp.allowed_read_paths),
800
+ "allowed_write_paths": list(self.mcp.allowed_write_paths),
801
+ "confirm_destructive": self.mcp.confirm_destructive,
802
+ "confirm_exempt": list(self.mcp.confirm_exempt),
803
+ "allowed_hosts": list(self.mcp.allowed_hosts),
804
+ "allowed_origins": list(self.mcp.allowed_origins),
805
+ "upload_dir": self.mcp.upload_dir,
806
+ "max_upload_mb": self.mcp.max_upload_mb,
807
+ "cert_file": self.mcp.cert_file,
808
+ "key_file": self.mcp.key_file,
809
+ },
810
+ "database": {
811
+ "timeout_seconds": self.database.timeout_seconds,
812
+ },
813
+ "database_path": str(self.database_path),
814
+ "log_level": self.log_level,
815
+ }
816
+
817
+
818
+ # Set by snapshot_config(); process-local on purpose (not inherited by
819
+ # child processes, unlike an environment variable).
820
+ _snapshot: Optional["Config"] = None
821
+
822
+
823
+ def snapshot_config(config: "Config") -> None:
824
+ """Make later load_config() calls in this process (without a path
825
+ argument) return ``config``: a long-running server keeps the config it
826
+ started with (database, allowlists, gates, Kasa, health) until it is
827
+ restarted, whatever happens to the files or the environment.
828
+
829
+ load_config(fresh=True) instead re-reads the file ``config`` came from
830
+ (or none, if it started on built-in defaults), for checks that must see
831
+ changes at once and fail closed, i.e. API keys.
832
+ """
833
+ global _snapshot
834
+ _snapshot = config
835
+
836
+
837
+ def load_config(
838
+ config_path: Optional[Path] = None,
839
+ create_if_missing: bool = False,
840
+ *,
841
+ fresh: bool = False,
842
+ ) -> Config:
843
+ """
844
+ Load configuration from YAML file.
845
+
846
+ Once a long-running server has called snapshot_config(), calls without
847
+ a ``config_path`` return that snapshot, whatever the environment or the
848
+ files say; ``fresh=True`` instead re-reads only the snapshot's own file
849
+ (or none), without logging (the caller reports problems). Otherwise:
850
+
851
+ Search order:
852
+ 1. Explicit path if provided
853
+ 2. LABCTL_CONFIG environment variable
854
+ 3. ~/.config/labctl/config.yaml
855
+ 4. /etc/labctl/config.yaml
856
+ 5. Default values
857
+
858
+ An explicit path is the only file tried: if it's missing or fails to
859
+ load, defaults are used, never another config. LABCTL_CONFIG normally
860
+ falls through to 3 and 4; with LABCTL_CONFIG_EXCLUSIVE=1 it behaves
861
+ like an explicit path. `labctl -c FILE mcp` sets both, so every
862
+ load_config() in the server process (including e.g. Kasa credential
863
+ lookups) uses FILE and only FILE.
864
+
865
+ Environment variable overrides:
866
+ - LABCTL_DEV_DIR: Override serial.dev_dir
867
+ - LABCTL_BASE_TCP_PORT: Override serial.base_tcp_port
868
+ - LABCTL_DATABASE_PATH: Override database_path
869
+ - LABCTL_DATABASE_TIMEOUT: Override database.timeout_seconds (float)
870
+ - LABCTL_LOG_LEVEL: Override log_level
871
+ - LABCTL_SYSTEM_CONFIG: Replace /etc/labctl/config.yaml in the search
872
+ order (read when labctl.core.config is imported)
873
+
874
+ Args:
875
+ config_path: Optional explicit path to config file
876
+ create_if_missing: Create default config if no config found
877
+ fresh: With a snapshot, re-read its file instead (see above)
878
+
879
+ Returns:
880
+ Loaded configuration
881
+ """
882
+ token = _quiet.set(True) if fresh else None
883
+ try:
884
+ return _load_config(config_path, create_if_missing, fresh)
885
+ finally:
886
+ if token is not None:
887
+ _quiet.reset(token)
888
+
889
+
890
+ def _load_config(
891
+ config_path: Optional[Path], create_if_missing: bool, fresh: bool
892
+ ) -> Config:
893
+ # Determine config file path
894
+ named_path = None # a file named explicitly: missing is an error
895
+ if config_path:
896
+ paths_to_try = [config_path]
897
+ only_file = True
898
+ named_path = config_path
899
+ elif _snapshot is not None:
900
+ if not fresh:
901
+ return _snapshot
902
+ # Fresh: only the snapshot's own file (or none), never a fallback.
903
+ paths_to_try = [] if _snapshot.source_path is None else [_snapshot.source_path]
904
+ only_file = _snapshot.source_path is not None
905
+ named_path = _snapshot.source_path
906
+ else:
907
+ env_path = os.environ.get("LABCTL_CONFIG")
908
+ paths_to_try = []
909
+ if env_path:
910
+ named_path = _expand_path(env_path)
911
+ paths_to_try.append(named_path)
912
+ only_file = bool(env_path) and os.environ.get("LABCTL_CONFIG_EXCLUSIVE") == "1"
913
+ if not only_file:
914
+ paths_to_try.extend([_default_config_file(), SYSTEM_CONFIG_FILE])
915
+
916
+ # Try to load from file
917
+ config = None
918
+ loaded_from = None
919
+ load_errors = []
920
+ load_denied = False
921
+ for path in paths_to_try:
922
+ try:
923
+ os.stat(path)
924
+ except OSError as e:
925
+ if e.errno not in (errno.ENOENT, errno.ENOTDIR, errno.ELOOP):
926
+ # e.g. EACCES on a 750 /etc/labctl: a config may well be
927
+ # there. (os.stat, not Path.exists(), which hides this.)
928
+ # A file named explicitly (-c, exclusive LABCTL_CONFIG)
929
+ # warns; search-path entries log at debug only, since users
930
+ # outside the labctl group hit /etc/labctl on every command
931
+ # (MCP startup and --check report load_errors either way).
932
+ logger.log(
933
+ logging.WARNING if path == named_path else logging.DEBUG,
934
+ "Failed to access config path %s: %s",
935
+ path,
936
+ e,
937
+ )
938
+ load_errors.append(f"{path}: {e}")
939
+ load_denied |= e.errno in (errno.EACCES, errno.EPERM)
940
+ continue
941
+ # A file named explicitly (-c, LABCTL_CONFIG) being absent is
942
+ # an error even if the search goes on: warn and record it (the
943
+ # HTTP server refuses to start, --check fails).
944
+ if path == named_path:
945
+ logger.warning("Config file %s not found", path)
946
+ load_errors.append(f"{path}: {e.strerror}")
947
+ continue
948
+ else:
949
+ try:
950
+ with open(path) as f:
951
+ data = yaml.safe_load(f) or {}
952
+ except Exception as e:
953
+ logger.warning("Failed to load config from %s: %s", path, e)
954
+ load_errors.append(f"{path}: {e}")
955
+ load_denied |= isinstance(e, PermissionError)
956
+ continue
957
+ # Valid YAML can still hold invalid values (`timeout_seconds:
958
+ # 10s`, a list at the top): treated like a parse error, i.e.
959
+ # on to the next file, not a crash.
960
+ try:
961
+ config = Config.from_dict(data)
962
+ except Exception as e:
963
+ logger.warning("Invalid config in %s: %s", path, e)
964
+ load_errors.append(f"{path}: invalid value: {e}")
965
+ continue
966
+ loaded_from = path
967
+ break
968
+
969
+ if config is None:
970
+ config = Config.from_dict({})
971
+ config.source_path = loaded_from
972
+ config.load_errors = load_errors
973
+ config.load_denied = load_denied
974
+
975
+ # Apply environment variable overrides
976
+ config = _apply_env_overrides(config)
977
+
978
+ # Create default config file if requested and none exists
979
+ if create_if_missing and not any(_path_exists(p) for p in paths_to_try):
980
+ save_config(config, _default_config_file())
981
+
982
+ return config
983
+
984
+
985
+ def _env_int(text: str) -> int | str:
986
+ """An environment value as a whole number, or the text unchanged (so
987
+ the caller's check rejects it with the value in the warning)."""
988
+ try:
989
+ return int(text)
990
+ except ValueError:
991
+ return text
992
+
993
+
994
+ def _apply_env_overrides(config: Config) -> Config:
995
+ """Apply environment variable overrides to config."""
996
+ if "LABCTL_DEV_DIR" in os.environ:
997
+ config.serial.dev_dir = _expand_path(os.environ["LABCTL_DEV_DIR"])
998
+
999
+ if "LABCTL_BASE_TCP_PORT" in os.environ:
1000
+ config.serial.base_tcp_port = _tcp_port(
1001
+ {"LABCTL_BASE_TCP_PORT": _env_int(os.environ["LABCTL_BASE_TCP_PORT"])},
1002
+ "LABCTL_BASE_TCP_PORT",
1003
+ config.serial.base_tcp_port,
1004
+ )
1005
+
1006
+ if "LABCTL_DATABASE_PATH" in os.environ:
1007
+ config.database_path = _expand_path(os.environ["LABCTL_DATABASE_PATH"])
1008
+
1009
+ if "LABCTL_DATABASE_TIMEOUT" in os.environ:
1010
+ try:
1011
+ config.database.timeout_seconds = float(
1012
+ os.environ["LABCTL_DATABASE_TIMEOUT"]
1013
+ )
1014
+ except ValueError:
1015
+ pass
1016
+
1017
+ if "LABCTL_LOG_LEVEL" in os.environ:
1018
+ config.log_level = os.environ["LABCTL_LOG_LEVEL"]
1019
+
1020
+ # Proxy overrides
1021
+ if "LABCTL_PROXY_ENABLED" in os.environ:
1022
+ _warn_once("LABCTL_PROXY_ENABLED is no longer supported and is ignored")
1023
+
1024
+ if "LABCTL_PROXY_PORT_BASE" in os.environ:
1025
+ try:
1026
+ config.proxy.port_base = int(os.environ["LABCTL_PROXY_PORT_BASE"])
1027
+ except ValueError:
1028
+ pass
1029
+
1030
+ if "LABCTL_PROXY_WRITE_POLICY" in os.environ:
1031
+ policy = os.environ["LABCTL_PROXY_WRITE_POLICY"]
1032
+ if policy in ("first", "all", "queue"):
1033
+ config.proxy.write_policy = policy
1034
+
1035
+ if "LABCTL_PROXY_LOG_DIR" in os.environ:
1036
+ config.proxy.log_dir = _expand_path(os.environ["LABCTL_PROXY_LOG_DIR"])
1037
+
1038
+ return config
1039
+
1040
+
1041
+ def save_config(config: Config, path: Path) -> None:
1042
+ """
1043
+ Save configuration to YAML file.
1044
+
1045
+ Args:
1046
+ config: Configuration to save
1047
+ path: Path to save to
1048
+ """
1049
+ # Ensure directory exists
1050
+ path.parent.mkdir(parents=True, exist_ok=True)
1051
+
1052
+ # Write config with header comment
1053
+ with open(path, "w") as f:
1054
+ f.write("# Lab Controller Configuration\n")
1055
+ f.write("# See documentation for all options\n\n")
1056
+ yaml.dump(config.to_dict(), f, default_flow_style=False, sort_keys=False)
1057
+
1058
+
1059
+ def get_default_config() -> Config:
1060
+ """Get default configuration without loading from file."""
1061
+ return Config()
1062
+
1063
+
1064
+ def ensure_config_dir() -> Path:
1065
+ """Ensure config directory exists and return its path."""
1066
+ config_dir = _default_config_dir()
1067
+ config_dir.mkdir(parents=True, exist_ok=True)
1068
+ return config_dir