Files
MeshCore/tools/hil/room_browser_acceptance.py
T

635 lines
33 KiB
Python

#!/usr/bin/env python3
"""Exercise the real Room page against an explicitly selected live board.
Run with other room authentication tests paused. This causes a temporary global
30-second password lock, uses three ephemeral browser identities, and reads
chat/index data. It never submits a chat draft, saves settings, or changes room
data. Chromium uses an isolated private profile; window.fetch is not replaced.
CDP passively observes requests and responses without pausing or altering them.
Any unexpected write fails the run immediately. Request evidence excludes bodies,
passwords, tokens and headers.
Example, with an existing SSH TCP forward to the actual board::
python3 tools/hil/room_browser_acceptance.py \
--endpoint http://127.0.0.1:19080 --manifest identity.json \
--secrets-file private.json --run-id v4-room-ui-01 \
--allow-auth-attempts --work-dir /private/browser-runs \
--chromium /usr/bin/chromium --report new-result.json
The manifest uses room_webconfig_acceptance.py's identity/artifact schema.
Its optional room_html_sha256 pins the exact served HTML; otherwise --room-source
extracts that hash from the repository's RoomWebHtml.h. The firmware SHA is
external evidence, not a live flash measurement. The owned 0600/0400 private JSON
needs admin_password and room_public_read_only; optional request_timeout,
room_timeout and timing_guard have the same meaning as the HTTP runner. Its
source_ips are unused: the Room budget is global across tokens. Passwords,
tokens, chat contents, names, origins and raw browser errors are never printed.
Requires Chromium and websocket-client (optionally supplied by --vendor), plus
the adjacent official_console_acceptance.py, linux_web_serial.py and
room_webconfig_acceptance.py. No serial port is opened. Allow about 90 seconds.
Failure may leave a temporary room lock; private browser state is retained in
the protected work directory for operator cleanup, and the owned browser exits.
"""
from __future__ import annotations
import argparse
from collections import Counter
import hashlib
import http.client
import ipaddress
import json
import os
from pathlib import Path
import re
import secrets
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
from urllib.parse import urlsplit
from official_console_acceptance import CDP
from room_webconfig_acceptance import (
CheckFailure, HttpClient, MAX_RESPONSE, ROOM_ERROR, ROOM_WRONG, RoomClient,
load_json, parse_endpoint, require, resolve_endpoint, subset_matches,
validate_manifest,
)
def private_settings(value):
password = value.get("admin_password")
require(isinstance(password, str) and 0 < len(password.encode("utf-8")) <= 64
and "\0" not in password, "private_admin_password_invalid")
require(type(value.get("room_public_read_only")) is bool,
"room_read_only_expectation_required")
value = dict(value)
for key, default, minimum, maximum in (
("request_timeout", 2.0, 0.1, 5.0),
("room_timeout", 12.0, 2.0, 20.0),
("timing_guard", 0.35, 0.2, 1.0)):
number = value.get(key, default)
require(type(number) in (int, float) and minimum <= number <= maximum,
"private_timing_value_invalid")
value[key] = float(number)
return value
def html_hash(args, manifest):
supplied = manifest.get("room_html_sha256")
if supplied is not None:
require(isinstance(supplied, str) and re.fullmatch(r"[0-9a-fA-F]{64}", supplied),
"manifest_room_html_sha256_invalid")
return supplied.lower()
try:
source = args.room_source.read_bytes()
require(source.count(b'R"roomhtml(') == 1
and source.count(b')roomhtml";') == 1, "room_source_delimiters_invalid")
html = source.split(b'R"roomhtml(', 1)[1].split(b')roomhtml";', 1)[0]
except OSError:
raise CheckFailure("room_source_unreadable_or_manifest_HTML_hash_required") from None
require(html.startswith(b"<!doctype html>"), "room_source_HTML_invalid")
return hashlib.sha256(html).hexdigest()
def served_html_hash(endpoint, timeout):
connection = http.client.HTTPConnection(endpoint["address"], endpoint["port"],
timeout=timeout, source_address=("127.0.0.1", 0))
try:
connection.request("GET", "/room", headers={"Host": endpoint["host_header"],
"Accept": "text/html", "Connection": "close"})
response = connection.getresponse()
body = response.read(MAX_RESPONSE + 1)
require(response.status == 200 and len(body) <= MAX_RESPONSE
and response.getheader("Content-Type", "").startswith("text/html"),
"live_room_page_contract_invalid")
return hashlib.sha256(body).hexdigest()
except (OSError, http.client.HTTPException):
raise CheckFailure("live_room_page_request_failed") from None
finally:
connection.close()
class PassiveCDP(CDP):
"""Observe the existing transport without Fetch interception or patches."""
def __init__(self, websocket, debug_port, profile, origin, *, setup_mode=False):
self.origin = origin
self.setup_mode = setup_mode
self.operations = Counter()
self.unexpected_requests = 0
self.write_requests = 0
self.network_started = time.monotonic()
self.requests = []
self.request_ids = {}
self.approved_favicons = {}
self.ap_favicon_redirects = []
self.room_operations = {}
self.runtime_errors = Counter()
self.browser_errors = Counter()
super().__init__(websocket, debug_port, profile)
try:
self.call("Log.enable")
self.call("Network.enable", {"maxPostDataSize": 4096})
except BaseException:
# A constructor failure never reaches the caller's page cleanup.
# Release this partial connection before retrying the owned page.
self.socket.close()
raise
def is_ap_favicon_redirect(self, identifier, request, response):
previous = self.approved_favicons.get(identifier)
if not (self.setup_mode is True and isinstance(identifier, str) and identifier
and previous is not None and self.request_ids.get(identifier) is previous
and not previous["finished"] and not previous["failed"]
and isinstance(response, dict) and isinstance(response.get("headers"), dict)):
return False
locations = [value for key, value in response["headers"].items()
if key.lower() == "location"]
return (response.get("url") == self.origin + "/favicon.ico"
and response.get("status") in (301, 302, 303, 307, 308)
and locations == ["http://192.168.4.1/"]
and request.get("method") == "GET"
and request.get("url") == "http://192.168.4.1/"
and not request.get("postData"))
def favicon_redirect_evidence(self):
return [{"from_ordinal": source["ordinal"], "to_ordinal": target["ordinal"],
"redirect_status": source["status"],
"followup_state": ("failed" if target["failed"] else
"finished" if target["finished"] else "pending"),
"followup_finished": target["finished"], "followup_failed": target["failed"]}
for source, target in self.ap_favicon_redirects]
def network(self, method, event):
identifier = event.get("requestId")
elapsed = round(time.monotonic() - self.network_started, 3)
if method == "Network.requestWillBeSent":
request = event["request"]
url = urlsplit(request["url"])
verb = request["method"]
same_origin = url.scheme + "://" + url.netloc == self.origin
known_paths = ("/room", "/api/status", "/favicon.ico", "/api/room", "/api/room/result")
path = url.path if same_origin and url.path in known_paths else "other"
headers = {key.lower(): value for key, value in request.get("headers", {}).items()}
sequence = headers.get("x-room-seq", "")
sequence = int(sequence) if re.fullmatch(r"[0-9]{1,10}", str(sequence)) else None
operation = None
read_only = same_origin and not url.query and not url.fragment and (
verb == "GET" and path in ("/room", "/api/status", "/favicon.ico")
or verb == "POST" and path == "/api/room/result")
if verb == "POST" and path == "/api/room":
try:
payload = json.loads(request.get("postData", ""))
operation = payload.get("op") if isinstance(payload, dict) else None
except (ValueError, TypeError):
operation = None
if operation in ("status", "posts", "board.index"):
self.operations[operation] += 1
if sequence is not None:
self.room_operations[sequence] = operation
read_only = not url.query and not url.fragment
else:
operation = "unexpected"
elif path == "/api/room/result":
operation = self.room_operations.get(sequence, "unmatched_result")
redirect_source = None
if "redirectResponse" in event:
# Only the setup portal's known favicon redirect may leave the
# forwarded origin. A redirect to another ordinarily allowed
# path must not regain permission through the normal allowlist.
read_only = self.is_ap_favicon_redirect(identifier, request, event["redirectResponse"])
if read_only:
redirect_source = self.approved_favicons.pop(identifier)
path = "captive_favicon_root"
entry = {"ordinal": len(self.requests) + 1, "path": path,
"method": verb if verb in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") else "other",
"operation": operation, "room_sequence": sequence,
"started_seconds": elapsed, "status": None,
"finished": False, "failed": False}
self.requests.append(entry)
self.request_ids[identifier] = entry
if redirect_source is not None:
redirect_source.update(status=int(event["redirectResponse"]["status"]),
response_seconds=elapsed, redirected=True,
redirect_to_ordinal=entry["ordinal"])
entry["redirect_from_ordinal"] = redirect_source["ordinal"]
self.ap_favicon_redirects.append((redirect_source, entry))
if not read_only:
self.unexpected_requests += 1
if verb not in ("GET", "HEAD", "OPTIONS"):
self.write_requests += 1
raise CheckFailure("unexpected_browser_request_observed_passively")
if path == "/favicon.ico" and isinstance(identifier, str) and identifier:
self.approved_favicons[identifier] = entry
return
entry = self.request_ids.get(identifier)
if entry is None:
return
if method == "Network.responseReceived":
status = event.get("response", {}).get("status")
entry["status"] = int(status) if type(status) in (int, float) else None
entry["response_seconds"] = elapsed
elif method == "Network.loadingFinished":
entry.update(finished=True, finished_seconds=elapsed,
encoded_bytes=event.get("encodedDataLength"))
elif method == "Network.loadingFailed":
error = event.get("errorText", "")
entry.update(failed=True, failed_seconds=elapsed,
canceled=bool(event.get("canceled")),
failure_class=(error if re.fullmatch(r"net::ERR_[A-Z0-9_]+", error)
else "other_network_failure"))
def call(self, method, parameters=None, timeout=15):
self.sequence += 1
sequence = self.sequence
self.socket.send(json.dumps({"id": sequence, "method": method, "params": parameters or {}}))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
response = json.loads(self.socket.recv())
except self.websocket.WebSocketTimeoutException:
continue
if response.get("method", "").startswith("Network."):
self.network(response["method"], response.get("params", {}))
continue
if response.get("method") == "Runtime.exceptionThrown":
details = response.get("params", {}).get("exceptionDetails", {})
name = details.get("exception", {}).get("className", "OtherError")
if name not in ("SyntaxError", "ReferenceError", "TypeError", "RangeError",
"SecurityError", "DOMException", "Error"):
name = "OtherError"
self.runtime_errors[name] += 1
continue
if response.get("method") == "Log.entryAdded":
entry = response.get("params", {}).get("entry", {})
if entry.get("level") == "error":
source = entry.get("source")
if source not in ("javascript", "network", "security", "storage", "rendering"):
source = "other"
self.browser_errors[source] += 1
continue
identifier = response.get("id")
if identifier == sequence:
require("error" not in response, "CDP_request_failed")
return response.get("result", {})
raise CheckFailure("CDP_request_deadline")
def wait_for_room_browser(websocket, debug_port, profile, origin, browser, timeout=60, *, setup_mode=False):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
require(browser.poll() is None, "owned_Chromium_exited_before_ready")
try:
return PassiveCDP(websocket, debug_port, profile, origin, setup_mode=setup_mode)
except (OSError, urllib.error.URLError, websocket.WebSocketTimeoutException,
websocket.WebSocketConnectionClosedException):
time.sleep(0.1)
raise CheckFailure("owned_Chromium_startup_deadline")
def snapshot(page, draft):
return page.evaluate("""(() => {
const el=id=>document.getElementById(id);
// DOM nodes precede the large inline script in the streamed response.
// Also tolerate lexical bindings that are still uninitialized mid-parse.
const safely=(read,fallback)=>{try{return read()}catch(_){return fallback}};
const complete=document.readyState==='complete';
const scriptReady=complete&&safely(()=>typeof joined==='boolean'
&&typeof polling==='boolean'&&typeof role==='number'
&&typeof makeIdentity==='function'&&typeof request==='function'
&&typeof refreshChat==='function'&&typeof chain==='object',false);
return {ready:scriptReady&&!!el('join')&&!!el('message')&&!!el('refresh'),
document_ready_state:document.readyState, script_ready:scriptReady,
form_present:!!el('join'), draft_input_present:!!el('message'),
joined:safely(()=>typeof joined==='boolean'&&joined,false),
role:safely(()=>typeof role==='number'?role:null,null),
boot:safely(()=>typeof boot==='number'?boot:null,null),
polling:safely(()=>typeof polling==='boolean'&&polling,false),
room_visible:!!el('room')&&!el('room').hidden,
state:el('state')?.textContent, error:el('error')?.textContent,
draft_matches:el('message')?.value===__DRAFT__,
password_cleared:el('password')?.value==='',
notices_ready:!!el('notices')?.childNodes.length,
native_fetch:Function.prototype.toString.call(window.fetch).includes('[native code]'),
web_key:el('web-key')?.textContent, room_name:el('room-name')?.textContent};
})()""".replace("__DRAFT__", json.dumps(draft)))
def safe_snapshot(value):
"""Only fixed classifications/booleans leave the private CDP state."""
keep = ("ready", "document_ready_state", "script_ready", "form_present",
"draft_input_present", "joined", "role", "polling", "room_visible",
"draft_matches", "password_cleared", "notices_ready", "native_fetch")
result = {key: value.get(key) for key in keep}
states = {"Admin access": "admin", "Read and write access": "read_write",
"Read-only access": "read_only", "Disconnected": "disconnected", "": "empty"}
result["state_kind"] = states.get(value.get("state"), "other")
error = value.get("error") or ""
result["error_kind"] = ({"": "none", ROOM_ERROR: "password_rate_window",
ROOM_WRONG: "incorrect_password",
"No confirmation received. Refresh and check the room before trying the operation again.":
"request_confirmation_timeout"}.get(error, "other"))
result["error_length"] = len(error)
result["boot_present"] = type(value.get("boot")) is int and value["boot"] > 0
return result
def require_join_handler(page):
node = page.call("Runtime.evaluate", {
"expression": "document.getElementById('join')", "returnByValue": False})
object_id = node.get("result", {}).get("objectId")
require(object_id is not None, "production_join_form_object_missing")
try:
listeners = page.call("DOMDebugger.getEventListeners", {"objectId": object_id})
require(any(listener.get("type") == "submit" for listener in listeners.get("listeners", [])),
"production_join_submit_handler_not_attached")
finally:
page.call("Runtime.releaseObject", {"objectId": object_id})
def wait_for(page, draft, predicate, seconds, failure):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
require(page.unexpected_requests == 0, "unexpected_browser_request_observed_passively")
value = snapshot(page, draft)
if predicate(value):
return value
time.sleep(0.1)
raise CheckFailure(failure)
def join(page, password):
# Interact with the production form; never invoke an API or compose handler.
require_join_handler(page)
page.evaluate("""(() => {
if(document.readyState!=='complete'||typeof joined!=='boolean')
throw Error('Room production handlers are not ready');
document.getElementById('password').value=__PASSWORD__;
document.getElementById('join').requestSubmit(); return true;
})()""".replace("__PASSWORD__", json.dumps(password)))
def execute(args, manifest, private, report, record):
if args.vendor:
sys.path.insert(0, str(args.vendor.resolve()))
import websocket
endpoint = resolve_endpoint(args.endpoint)
require(ipaddress.ip_address(endpoint["address"]).is_loopback,
"endpoint_must_be_loopback_forward_to_board")
origin = "http://" + endpoint["host_header"]
expected_html = html_hash(args, manifest)
client = HttpClient(endpoint, "127.0.0.1", private["request_timeout"])
def identity():
reply = client.request("GET", "/api/status", use_cookie=False)
require(reply.status == 200 and subset_matches(reply.body, manifest["status"]),
"live_status_identity_mismatch")
require(reply.body.get("password_supported") is True
and reply.body.get("needs_setup") is False
and reply.body.get("needs_password") is False,
"configured_admin_auth_precondition_failed")
require(type(reply.body.get("uptime_s")) is int, "live_uptime_missing")
return reply.body["uptime_s"], time.monotonic()
uptime, identity_at = identity()
require(served_html_hash(endpoint, private["request_timeout"]) == expected_html,
"live_room_HTML_does_not_match_expected_source")
report["room_html_sha256"] = expected_html
record("live_node_version_mode_and_Room_HTML_match_manifest_and_source")
report["phase"] = "initial_quiet_window"
record("initial_quiet_window", "WAIT", seconds=30 + private["timing_guard"])
time.sleep(30 + private["timing_guard"])
work = Path(tempfile.mkdtemp(prefix="room-browser-", dir=args.work_dir))
work.chmod(0o700)
report["private_state_directory"] = str(work)
profile = work / "profile"
with socket.socket() as reservation:
reservation.bind(("127.0.0.1", 0))
debug_port = reservation.getsockname()[1]
browser = page = None
browser_log = (work / "chromium-private.log").open("xb")
draft = "HIL unsent draft " + secrets.token_hex(12)
report["draft_sha256"] = hashlib.sha256(draft.encode()).hexdigest()
try:
browser = subprocess.Popen([str(args.chromium), "--headless=new", "--no-sandbox",
"--disable-dev-shm-usage", "--disable-gpu", "--disable-background-networking",
"--disable-extensions", "--disable-sync", "--no-first-run", "--no-default-browser-check",
"--enable-automation", "--renderer-process-limit=1",
"--proxy-server=direct://", "--proxy-bypass-list=*",
"--remote-debugging-address=127.0.0.1", "--remote-debugging-port=" + str(debug_port),
"--user-data-dir=" + str(profile), "about:blank"],
stdout=browser_log, stderr=browser_log, start_new_session=True)
page = wait_for_room_browser(websocket, debug_port, profile, origin, browser,
setup_mode=manifest["status"]["mode"] == "setup")
report["browser_version"] = page.browser_version
report["phase"] = "initial_admin_UI_login"
page.call("Page.navigate", {"url": origin + "/room"})
wait_for(page, draft, lambda state: state["ready"], 15, "real_room_UI_did_not_load")
report["authentication_attempts_started"] = True
join(page, private["admin_password"])
ready = lambda state: (state["joined"] and state["role"] == 3
and state["room_visible"] and not state["polling"] and state["notices_ready"]
and state["state"] == "Admin access" and state["error"] == "")
initial = wait_for(page, draft, ready, 30, "real_admin_UI_login_failed")
require(initial["password_cleared"] and initial["native_fetch"],
"UI_password_not_cleared_or_fetch_not_native")
expected_name = manifest["config_expected"].get("radio", {}).get("name")
if expected_name is not None:
require(initial["room_name"] == expected_name, "room_UI_node_name_mismatch")
require(type(initial["boot"]) is int and initial["boot"] > 0
and re.fullmatch(r"[0-9A-Fa-f]{64}", initial["web_key"] or "") is not None,
"room_UI_boot_or_identity_missing")
page.evaluate("""(() => {const el=document.getElementById('message');
el.value=__DRAFT__;el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()
""".replace("__DRAFT__", json.dumps(draft)))
require(snapshot(page, draft)["draft_matches"], "draft_input_failed")
record("real_admin_UI_login_and_unsent_draft")
report["phase"] = "force_global_failure_window"
actors = [RoomClient(HttpClient(endpoint, "127.0.0.1", private["request_timeout"]),
private["room_timeout"]) for _ in range(2)]
forbidden = {private["admin_password"], private.get("room_guest_password", "")}
first = None
before_status_requests = page.operations["status"]
for index in range(5):
wrong = "hil-wrong-" + secrets.token_hex(16)
while wrong in forbidden:
wrong = "hil-wrong-" + secrets.token_hex(16)
forbidden.add(wrong)
reply = actors[index % 2].status(wrong)
if first is None:
first = reply
if private["room_public_read_only"]:
require("error" not in reply.body and reply.body.get("role") == 0,
"wrong_password_not_admitted_as_public_guest")
else:
require(reply.body.get("error") == ROOM_WRONG,
"wrong_password_did_not_match_private_policy")
require(time.monotonic() - first.started < 10,
"five_failure_setup_too_slow_for_UI_window")
locked = actors[1].status(private["admin_password"])
require(locked.status == 200 and locked.body.get("error") == ROOM_ERROR,
"real_global_lock_not_confirmed_as_JSON_error")
empty = actors[1].status("")
if private["room_public_read_only"]:
require("error" not in empty.body and empty.body.get("role") == 0,
"empty_password_public_guest_access_lost_during_lock")
else:
require(empty.body.get("error") == ROOM_WRONG,
"empty_password_bypassed_private_room_policy")
record("five_unique_wrong_passwords_across_two_tokens_lock_valid_admin_JSON_error")
report["phase"] = "real_automatic_UI_poll_under_lock"
# Pump CDP events while the unmodified page's own 15-second timer polls.
remaining = min(20.0, first.started + 27.0 - time.monotonic())
require(remaining > 0, "insufficient_time_for_automatic_UI_poll")
failed = wait_for(page, draft, lambda state: state["error"] == ROOM_ERROR,
remaining, "automatic_UI_poll_did_not_show_real_lock_message")
require(failed["draft_matches"] and failed["joined"]
and failed["room_visible"] and failed["native_fetch"]
and failed["boot"] == initial["boot"]
and page.operations["status"] > before_status_requests,
"UI_draft_session_or_real_poll_not_preserved_during_lock")
record("automatic_real_UI_poll_shows_rate_window_and_preserves_draft")
report["phase"] = "window_expiry_and_UI_reconnect"
deadline = first.finished + 30 + private["timing_guard"]
while time.monotonic() < deadline:
require(snapshot(page, draft)["draft_matches"], "draft_changed_while_waiting_for_retry")
require(page.unexpected_requests == 0, "unexpected_browser_request_observed_passively")
time.sleep(min(0.25, max(0, deadline - time.monotonic())))
# Connect is the production retry that also clears an old error banner.
# Refresh alone leaves that banner's text unchanged in the current UI.
join(page, private["admin_password"])
recovered = wait_for(page, draft, ready, 30, "UI_admin_retry_did_not_recover_after_window")
require(recovered["draft_matches"] and recovered["password_cleared"]
and recovered["native_fetch"] and recovered["boot"] == initial["boot"]
and recovered["web_key"] == initial["web_key"],
"UI_retry_changed_draft_identity_boot_or_fetch")
record("Connect_retry_recovers_admin_and_clears_error_without_clearing_draft")
final_uptime, final_at = identity()
require(abs(final_uptime - uptime - (final_at - identity_at)) < 5,
"board_restarted_or_uptime_changed_during_UI_scenario")
require(page.unexpected_requests == 0, "unexpected_browser_request_observed_passively")
report.update(browser_read_operations=dict(page.operations),
unexpected_browser_requests_observed=page.unexpected_requests,
native_fetch_unchanged=True, room_boot_unchanged=True,
browser_identity_preserved=True, unsent_draft_preserved=True,
retry_method="production Connect form", temporary_room_lock_may_remain=False)
finally:
if page is not None:
try:
report["final_UI_state"] = safe_snapshot(snapshot(page, draft))
except Exception:
report["final_UI_state_unavailable"] = True
report["browser_read_operations"] = dict(page.operations)
report["unexpected_browser_requests_observed"] = page.unexpected_requests
report["browser_write_requests_observed"] = page.write_requests
report["browser_request_evidence"] = page.requests
report["ap_favicon_redirect_evidence"] = page.favicon_redirect_evidence()
report["page_runtime_exception_classes"] = dict(page.runtime_errors)
report["browser_error_sources"] = dict(page.browser_errors)
page.socket.close()
if browser is not None and browser.poll() is None:
os.killpg(browser.pid, signal.SIGTERM)
try:
browser.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(browser.pid, signal.SIGKILL)
browser.wait(timeout=5)
report["owned_browser_closed"] = browser is None or browser.poll() is not None
browser_log.close()
def argument_parser():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--endpoint", required=True, help="Loopback HTTP origin forwarded to the actual board")
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--secrets-file", type=Path,
default=os.environ.get("MESHCORE_AUTH_SECRETS_FILE"))
parser.add_argument("--run-id", required=True)
parser.add_argument("--allow-auth-attempts", action="store_true")
parser.add_argument("--report", type=Path, required=True, help="New sanitized JSON report; never overwritten")
parser.add_argument("--work-dir", type=Path, required=True, help="Existing private parent for browser state")
parser.add_argument("--chromium", type=Path, default=Path("/usr/bin/chromium"))
parser.add_argument("--vendor", type=Path, help="Directory containing websocket-client")
parser.add_argument("--room-source", type=Path,
default=Path(__file__).resolve().parents[2] / "src/helpers/esp32/RoomWebHtml.h")
return parser
def main(argv=None):
args = argument_parser().parse_args(argv)
report = {"schema": 1, "result": "FAIL", "phase": "input_validation", "steps": [],
"run_id": None, "live_flash_hash_verified": False,
"firmware_sha256_evidence": "externally supplied manifest",
"board_settings_changed_by_runner": False, "compose_submit_actions": 0,
"browser_network_interception": False, "authentication_attempts_started": False,
"temporary_room_lock_may_remain": False}
output = None
started = time.monotonic()
def record(step, result="PASS", **values):
event = {"step": step, "result": result, **values}
report["steps"].append(event)
print(json.dumps(event, sort_keys=True), flush=True)
try:
require(args.allow_auth_attempts, "explicit_allow_auth_attempts_required")
require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,79}", args.run_id) is not None,
"run_id_invalid")
report["run_id"] = args.run_id
require(args.secrets_file is not None, "private_secrets_file_required")
manifest = validate_manifest(load_json(args.manifest))
private = private_settings(load_json(args.secrets_file, private=True))
endpoint = parse_endpoint(args.endpoint)
require(endpoint["hostname"] in ("localhost", "127.0.0.1"),
"endpoint_must_be_localhost_or_127_0_0_1_forward")
require(args.work_dir.is_dir() and args.work_dir.stat().st_uid == os.geteuid()
and args.work_dir.stat().st_mode & 0o077 == 0,
"work_directory_must_be_owned_and_private")
try:
output = os.fdopen(os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600), "w", encoding="utf-8")
except OSError:
raise CheckFailure("report_path_unavailable_or_already_exists") from None
report.update(source_commit=manifest["source_commit"].lower(),
firmware_sha256=manifest["firmware_sha256"].lower(),
mode=manifest["status"]["mode"],
node_id_sha256=hashlib.sha256(manifest["status"]["node_id"].encode()).hexdigest())
execute(args, manifest, private, report, record)
report["result"] = "PASS"
report["phase"] = "complete"
return_code = 0
except (CheckFailure, KeyboardInterrupt) as error:
report["failure"] = "operator_interrupted" if isinstance(error, KeyboardInterrupt) else str(error)
return_code = 130 if isinstance(error, KeyboardInterrupt) else 1
except Exception as error:
# Do not expose URLs, request bodies, raw browser errors or credentials.
report["failure"] = "unexpected_" + type(error).__name__
return_code = 1
finally:
if report["result"] != "PASS":
report["temporary_room_lock_may_remain"] = report["authentication_attempts_started"]
report["elapsed_seconds"] = round(time.monotonic() - started, 3)
if output:
json.dump(report, output, indent=2, sort_keys=True)
output.write("\n")
output.close()
print(json.dumps({key: value for key, value in report.items() if key != "steps"},
sort_keys=True), flush=True)
return return_code
if __name__ == "__main__":
sys.exit(main())