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/web/app.py ADDED
@@ -0,0 +1,202 @@
1
+ """
2
+ Flask application factory for lab controller web interface.
3
+ """
4
+
5
+ import secrets
6
+ from datetime import timedelta
7
+ from pathlib import Path
8
+
9
+ from flask import Flask, g, jsonify, redirect, request, session, url_for
10
+
11
+ from labctl import __version__
12
+ from labctl.core import audit
13
+ from labctl.core.config import Config, load_config
14
+ from labctl.core.manager import ResourceManager, get_manager
15
+ from labctl.web.auth import (
16
+ generate_csrf_token,
17
+ get_user_by_api_key,
18
+ get_user_by_username,
19
+ validate_csrf_token,
20
+ )
21
+
22
+
23
+ def create_app(config: Config | None = None) -> Flask:
24
+ """
25
+ Create and configure Flask application.
26
+
27
+ Args:
28
+ config: Optional Config instance. If None, loads from default location.
29
+
30
+ Returns:
31
+ Configured Flask application
32
+ """
33
+ app = Flask(
34
+ __name__,
35
+ template_folder=str(Path(__file__).parent / "templates"),
36
+ static_folder=str(Path(__file__).parent / "static"),
37
+ )
38
+
39
+ # Load configuration
40
+ if config is None:
41
+ config = load_config()
42
+ if config.load_errors:
43
+ # Fail closed: serving on another file's or built-in settings could
44
+ # mean no login (auth.enabled off). `labctl web` checks this too;
45
+ # this covers WSGI servers and other callers of create_app().
46
+ raise RuntimeError(
47
+ "Refusing to create the web app: config not loaded ("
48
+ + "; ".join(config.load_errors)
49
+ + ")"
50
+ )
51
+
52
+ app.config["LABCTL_CONFIG"] = config
53
+ app.jinja_env.globals["labctl_version"] = __version__
54
+
55
+ # SECRET_KEY signs the session cookie, which carries the CSRF token even
56
+ # when auth is disabled, so it must never be a known constant. Without a
57
+ # configured key, use a random per-process key (the web server is a single
58
+ # process); sessions then reset on restart.
59
+ if config.auth.secret_key:
60
+ app.config["SECRET_KEY"] = config.auth.secret_key
61
+ else:
62
+ app.config["SECRET_KEY"] = secrets.token_hex(32)
63
+
64
+ # Set session lifetime
65
+ app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
66
+ minutes=config.auth.session_lifetime_minutes
67
+ )
68
+
69
+ # Register blueprints
70
+ from labctl.web.activity_broadcaster import ActivityBroadcaster
71
+ from labctl.web.api import api_bp
72
+ from labctl.web.auth import auth_bp
73
+ from labctl.web.views import views_bp
74
+
75
+ app.register_blueprint(auth_bp)
76
+ app.register_blueprint(api_bp, url_prefix="/api")
77
+ app.register_blueprint(views_bp)
78
+
79
+ # Start the activity-stream broadcaster thread. Polls audit_log and
80
+ # fans new rows out to SSE subscribers on /activity/stream.
81
+ mgr_for_broadcast = get_manager(
82
+ config.database_path,
83
+ timeout_seconds=config.database.timeout_seconds,
84
+ )
85
+ app.config["ACTIVITY_BROADCASTER"] = ActivityBroadcaster(mgr_for_broadcast.db)
86
+ app.config["ACTIVITY_BROADCASTER"].start()
87
+
88
+ # Register csrf_token as Jinja2 template global
89
+ app.jinja_env.globals["csrf_token"] = generate_csrf_token
90
+
91
+ def _request_activity_identity() -> tuple[str, str]:
92
+ """Derive audit actor/source for the current Flask request."""
93
+ source = (
94
+ "api" if request.endpoint and request.endpoint.startswith("api.") else "web"
95
+ )
96
+
97
+ user = getattr(g, "audit_user", None)
98
+ if not user:
99
+ user = session.get("user")
100
+ if not user:
101
+ user = "anonymous"
102
+
103
+ return f"{source}:{user}", source
104
+
105
+ @app.before_request
106
+ def before_request():
107
+ """Set up manager and enforce auth for each request."""
108
+ config = app.config["LABCTL_CONFIG"]
109
+ g.manager = get_manager(
110
+ config.database_path,
111
+ timeout_seconds=config.database.timeout_seconds,
112
+ base_tcp_port=config.serial.base_tcp_port,
113
+ )
114
+ g.config = config
115
+
116
+ api_request = bool(request.endpoint and request.endpoint.startswith("api."))
117
+ api_user = None
118
+ # The authenticated user (UserConfig), for claim checks: their
119
+ # role decides who may override a claim (D013).
120
+ g.auth_user = None
121
+
122
+ if api_request and config.auth.enabled:
123
+ api_key = request.headers.get("X-API-Key", "")
124
+ if api_key:
125
+ api_user = get_user_by_api_key(config.auth, api_key)
126
+ if api_user:
127
+ g.audit_user = api_user.username
128
+ g.auth_user = api_user
129
+ elif "user" in session:
130
+ g.audit_user = session["user"]
131
+ if config.auth.enabled:
132
+ g.auth_user = get_user_by_username(config.auth, session["user"])
133
+
134
+ actor, source = _request_activity_identity()
135
+ ctx = audit.activity_context(actor, source)
136
+ ctx.__enter__()
137
+ g._activity_context = ctx
138
+
139
+ # Skip auth enforcement if auth is disabled
140
+ if not config.auth.enabled:
141
+ return
142
+
143
+ # Whitelist: login page, static files, health check
144
+ if request.endpoint in (
145
+ "auth.login",
146
+ "auth.logout",
147
+ "static",
148
+ "api.health_check",
149
+ ):
150
+ return
151
+
152
+ # API requests: check X-API-Key header
153
+ if api_request:
154
+ api_key = request.headers.get("X-API-Key", "")
155
+ if not api_key:
156
+ return jsonify({"error": "API key required"}), 401
157
+ if not api_user:
158
+ return jsonify({"error": "Invalid API key"}), 401
159
+
160
+ # Web requests: check session
161
+ if "user" not in session:
162
+ if not api_request:
163
+ if request.endpoint not in ("auth.login", "auth.logout", "static"):
164
+ return redirect(url_for("auth.login", next=request.path))
165
+
166
+ @app.teardown_request
167
+ def teardown_request(exc):
168
+ """Reset per-request audit attribution context."""
169
+ ctx = getattr(g, "_activity_context", None)
170
+ if ctx is not None:
171
+ ctx.__exit__(
172
+ type(exc) if exc else None, exc, exc.__traceback__ if exc else None
173
+ )
174
+
175
+ @app.before_request
176
+ def enforce_csrf():
177
+ """Enforce CSRF token on state-changing web requests."""
178
+ config = app.config["LABCTL_CONFIG"]
179
+ if not config.auth.enabled:
180
+ return
181
+
182
+ # Only enforce on state-changing methods
183
+ if request.method not in ("POST", "PUT", "DELETE"):
184
+ return
185
+
186
+ # Skip for API endpoints (they use API keys, not CSRF)
187
+ if request.endpoint and request.endpoint.startswith("api."):
188
+ return
189
+
190
+ # Skip for login (uses its own CSRF)
191
+ if request.endpoint == "auth.login":
192
+ return
193
+
194
+ if not validate_csrf_token():
195
+ return redirect(request.referrer or url_for("views.index"))
196
+
197
+ return app
198
+
199
+
200
+ def get_manager_from_app() -> ResourceManager:
201
+ """Get resource manager from Flask g object."""
202
+ return g.manager
labctl/web/auth.py ADDED
@@ -0,0 +1,97 @@
1
+ """
2
+ Authentication module for lab controller web interface.
3
+
4
+ Provides session-based login for web UI and API key auth for REST API.
5
+ """
6
+
7
+ import hmac
8
+ import secrets
9
+ from typing import Optional
10
+
11
+ from flask import (
12
+ Blueprint,
13
+ current_app,
14
+ flash,
15
+ redirect,
16
+ render_template,
17
+ request,
18
+ session,
19
+ url_for,
20
+ )
21
+ from werkzeug.security import check_password_hash
22
+
23
+ from labctl.core.config import AuthConfig, UserConfig
24
+
25
+ auth_bp = Blueprint("auth", __name__)
26
+
27
+
28
+ def get_user_by_username(
29
+ auth_config: AuthConfig, username: str
30
+ ) -> Optional[UserConfig]:
31
+ """Look up a user by username."""
32
+ for user in auth_config.users:
33
+ if user.username == username:
34
+ return user
35
+ return None
36
+
37
+
38
+ def get_user_by_api_key(auth_config: AuthConfig, api_key: str) -> Optional[UserConfig]:
39
+ """Look up a user by API key using constant-time comparison."""
40
+ return auth_config.user_for_api_key(api_key)
41
+
42
+
43
+ def verify_password(user: UserConfig, password: str) -> bool:
44
+ """Verify a password against a user's stored hash."""
45
+ if not user.password_hash:
46
+ return False
47
+ return check_password_hash(user.password_hash, password)
48
+
49
+
50
+ def generate_csrf_token() -> str:
51
+ """Generate or retrieve a CSRF token for the current session."""
52
+ if "_csrf_token" not in session:
53
+ session["_csrf_token"] = secrets.token_hex(32)
54
+ return session["_csrf_token"]
55
+
56
+
57
+ def validate_csrf_token() -> bool:
58
+ """Validate CSRF token from form data against session."""
59
+ token = request.form.get("_csrf_token", "")
60
+ session_token = session.get("_csrf_token", "")
61
+ if not token or not session_token:
62
+ return False
63
+ return hmac.compare_digest(token, session_token)
64
+
65
+
66
+ @auth_bp.route("/login", methods=["GET", "POST"])
67
+ def login():
68
+ """Handle login page and form submission."""
69
+ if request.method == "POST":
70
+ username = request.form.get("username", "")
71
+ password = request.form.get("password", "")
72
+
73
+ config = current_app.config["LABCTL_CONFIG"]
74
+ user = get_user_by_username(config.auth, username)
75
+
76
+ if user and verify_password(user, password):
77
+ session["user"] = username
78
+ session.permanent = True
79
+ flash("Logged in successfully.", "success")
80
+ next_url = request.args.get("next", "")
81
+ # Prevent open redirect: only allow relative paths
82
+ if not next_url or next_url.startswith("//") or "://" in next_url:
83
+ next_url = url_for("views.index")
84
+ return redirect(next_url)
85
+ else:
86
+ flash("Invalid username or password.", "error")
87
+
88
+ return render_template("login.html")
89
+
90
+
91
+ @auth_bp.route("/logout", methods=["POST"])
92
+ def logout():
93
+ """Log out the current user."""
94
+ session.pop("user", None)
95
+ session.pop("_csrf_token", None)
96
+ flash("Logged out.", "success")
97
+ return redirect(url_for("auth.login"))
labctl/web/claims.py ADDED
@@ -0,0 +1,232 @@
1
+ """
2
+ Claim checks for the web UI and REST API (D013).
3
+
4
+ A change to a board that another user has claimed is refused: REST with
5
+ 409 ``sbc_claimed``, the web UI with a page naming the holder. Operators
6
+ may override explicitly (``"override_claim": true`` in a REST body, or the
7
+ button on that page); the override is recorded in the activity log.
8
+ """
9
+
10
+ from typing import Optional
11
+
12
+ from flask import g, render_template, request, session, url_for
13
+
14
+ from labctl.core.models import (
15
+ ClaimActor,
16
+ ClaimConflict,
17
+ ClaimRequiredError,
18
+ NotOperatorError,
19
+ UnknownSBCError,
20
+ )
21
+
22
+ # A refusal: the JSON error body and its HTTP status.
23
+ Refusal = tuple[dict, int]
24
+
25
+ _TRUE_VALUES = ("1", "true", "yes", "on")
26
+
27
+
28
+ def port_number(value) -> Optional[int]:
29
+ """``value`` (an int, or text of decimal digits) when it is a whole
30
+ number from 1 to 65535, else None. Text is length-checked before
31
+ ``int()``, which refuses very long digit strings."""
32
+ if isinstance(value, bool):
33
+ return None
34
+ if isinstance(value, str):
35
+ value = value.strip()
36
+ if not value.isdecimal() or len(value) > 5:
37
+ return None
38
+ value = int(value)
39
+ if isinstance(value, int) and 1 <= value <= 65535:
40
+ return value
41
+ return None
42
+
43
+
44
+ def is_api_request() -> bool:
45
+ return bool(request.endpoint and request.endpoint.startswith("api."))
46
+
47
+
48
+ def web_actor() -> ClaimActor:
49
+ """Who is acting, for claim checks.
50
+
51
+ With auth, the logged-in or API-key user: their claims from any
52
+ session (REST, the web UI, MCP over HTTP) count as theirs, and their
53
+ role decides whether they may override. Without auth there is one
54
+ shared identity, which is an operator (as unauthenticated MCP is).
55
+ """
56
+ if not g.config.auth.enabled:
57
+ return ClaimActor(session_id="web:anonymous", operator=True)
58
+ user = getattr(g, "auth_user", None)
59
+ if user is not None:
60
+ return ClaimActor(
61
+ session_id=f"web:{user.username}",
62
+ owner=user.username,
63
+ operator=user.is_operator,
64
+ )
65
+ # A login whose user has since left the config: theirs, but no
66
+ # operator rights.
67
+ name = session.get("user") or "unknown"
68
+ return ClaimActor(session_id=f"web:{name}", owner=name, operator=False)
69
+
70
+
71
+ def web_user_name() -> str:
72
+ """The name recorded for this caller's claims, releases and requests."""
73
+ actor = web_actor()
74
+ return actor.owner or "web-operator"
75
+
76
+
77
+ def override_requested() -> bool:
78
+ """``override_claim`` in the JSON body (must be ``true``), the form or
79
+ the query string (``1``, ``true``, ``yes`` or ``on``)."""
80
+ data = request.get_json(silent=True) if request.is_json else None
81
+ if isinstance(data, dict) and "override_claim" in data:
82
+ return data["override_claim"] is True
83
+ value = request.values.get("override_claim", "")
84
+ return str(value).strip().lower() in _TRUE_VALUES
85
+
86
+
87
+ def claimed_error(sbc_name: str, claim, *, override_hint: bool = True) -> dict:
88
+ """``sbc_claimed`` for a change to ``sbc_name``; ``claim`` may be on
89
+ another board that shares its hardware."""
90
+ holder_board = claim.sbc_name or sbc_name
91
+ holder = claim.agent_name or "unknown"
92
+ shared = (
93
+ f" (it shares hardware with '{sbc_name}')" if holder_board != sbc_name else ""
94
+ )
95
+ if claim.is_active:
96
+ until = f"until {claim.expires_at.isoformat()}" if claim.expires_at else ""
97
+ hints = [
98
+ "Wait for the claim to expire or be released",
99
+ f"Ask for it: POST /api/claims/{holder_board}/request-release",
100
+ ]
101
+ if web_actor().operator:
102
+ if override_hint:
103
+ hints.append(
104
+ 'Operator override: repeat with "override_claim": true '
105
+ "(recorded in the activity log)"
106
+ )
107
+ else:
108
+ hints.append(
109
+ f"Operator override: POST /api/claims/{holder_board}"
110
+ "/force-release"
111
+ )
112
+ else:
113
+ # Past its deadline, inside the grace period: only its holder may
114
+ # re-claim it, and release requests only act on active claims.
115
+ until = "(expired, in its grace period)"
116
+ hints = ["Retry once the grace period ends"]
117
+ return {
118
+ "error": "sbc_claimed",
119
+ "sbc_name": holder_board,
120
+ "message": f"SBC '{holder_board}' is claimed by '{holder}' {until}{shared}",
121
+ "claim": claim.to_dict(),
122
+ "hints": hints,
123
+ }
124
+
125
+
126
+ def _refusal(exc: Exception, sbc_name: str, for_sbc: Optional[str]) -> Refusal:
127
+ if isinstance(exc, ClaimConflict):
128
+ return claimed_error(for_sbc or sbc_name, exc.claim), 409
129
+ if isinstance(exc, NotOperatorError):
130
+ return (
131
+ {
132
+ "error": "not_operator",
133
+ "sbc_name": sbc_name,
134
+ "message": f"{exc}; your user has role 'agent'",
135
+ "hints": [f"Ask for it: POST /api/claims/{sbc_name}/request-release"],
136
+ },
137
+ 403,
138
+ )
139
+ assert isinstance(exc, ClaimRequiredError)
140
+ return (
141
+ {
142
+ "error": "claim_required",
143
+ "sbc_name": sbc_name,
144
+ "message": str(exc),
145
+ "hints": [f"Claim it first: POST /api/claims/{sbc_name}"],
146
+ },
147
+ 409,
148
+ )
149
+
150
+
151
+ def check(
152
+ sbc_name: str,
153
+ *,
154
+ shared: bool = True,
155
+ sharers: Optional[dict] = None,
156
+ ) -> Optional[Refusal]:
157
+ """Refusal for a change to ``sbc_name`` by this caller, or None to go
158
+ ahead (the caller's own claim is heartbeated; an override is logged).
159
+
160
+ ``shared`` also checks boards sharing ``sbc_name``'s hardware; pass
161
+ False for changes to the board's own records. ``sharers`` is hardware
162
+ about to be wired to ``sbc_name`` (keyword arguments for
163
+ ResourceManager.sbcs_sharing_hardware): the boards that already have
164
+ it are checked first, so a claimed board's plug, SDWire or console
165
+ can't be driven through another board. ``claims.require_claim``
166
+ applies to REST calls, not to the web UI. An override is what the
167
+ request asks for (see override_requested).
168
+ """
169
+ config = g.config
170
+ if not config.claims.enabled:
171
+ return None
172
+ override = override_requested()
173
+ manager = g.manager
174
+ actor = web_actor()
175
+ if sharers:
176
+ others = set(manager.sbcs_sharing_hardware(**sharers)) - {sbc_name}
177
+ try:
178
+ manager.check_claims_of(others, actor, override=override)
179
+ except (ClaimConflict, NotOperatorError) as exc:
180
+ return _refusal(exc, sbc_name, sbc_name)
181
+ try:
182
+ manager.check_claim(
183
+ sbc_name,
184
+ actor,
185
+ override=override,
186
+ require_claim=is_api_request() and config.claims.require_claim,
187
+ include_shared=shared,
188
+ )
189
+ except UnknownSBCError:
190
+ # Callers load the board first, so it was renamed or deleted
191
+ # meanwhile: don't act on what they loaded unchecked.
192
+ return (
193
+ {
194
+ "error": "sbc_changed",
195
+ "sbc_name": sbc_name,
196
+ "message": f"SBC '{sbc_name}' was renamed or removed; retry",
197
+ },
198
+ 404,
199
+ )
200
+ except (ClaimConflict, NotOperatorError, ClaimRequiredError) as exc:
201
+ return _refusal(exc, sbc_name, None)
202
+ return None
203
+
204
+
205
+ def refusal_page(refusal: Refusal, sbc_name: str):
206
+ """The web UI's answer to a refused change: the holder, how to ask for
207
+ the board, and (for operators, on an active claim) a button that
208
+ repeats the change with ``override_claim``."""
209
+ body, status = refusal
210
+ claim = body.get("claim") or {}
211
+ fields = [
212
+ (key, value)
213
+ for key, value in request.form.items(multi=True)
214
+ if key not in ("_csrf_token", "override_claim")
215
+ ]
216
+ can_override = (
217
+ body["error"] == "sbc_claimed"
218
+ and web_actor().operator
219
+ and bool(claim.get("is_active", True))
220
+ )
221
+ return (
222
+ render_template(
223
+ "claim_refused.html",
224
+ body=body,
225
+ sbc_name=sbc_name,
226
+ fields=fields,
227
+ action=request.path,
228
+ can_override=can_override,
229
+ back=url_for("views.sbc_detail", name=sbc_name),
230
+ ),
231
+ status,
232
+ )