175 lines
7.0 KiB
Python
175 lines
7.0 KiB
Python
"""Fixed Bluetooth privilege boundary. Never accepts serials, paths or SDK calls."""
|
|
|
|
import json
|
|
import os
|
|
import pwd
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from layout import CODE, trusted, write # noqa: E402
|
|
from runtime.http import Server # noqa: E402
|
|
from runtime.identity import read_binding # noqa: E402
|
|
from runtime.lifecycle import acquisition # noqa: E402
|
|
|
|
ROOT = Path("/var/lib/mission-core-x4-recovery")
|
|
SOCKET = Path("/run/mission-core-x4-recovery/driver.sock")
|
|
IDENTIFIER = re.compile(r"instax4_[0-9a-f]{32}")
|
|
|
|
|
|
def connected():
|
|
values = []
|
|
for path in Path("/sys/bus/usb/devices").iterdir():
|
|
with suppress(OSError, ValueError):
|
|
values.append(read_binding(path.name))
|
|
return values
|
|
|
|
|
|
class WakeService:
|
|
def __init__(self, root=ROOT):
|
|
self.root = root
|
|
self.lock = threading.RLock()
|
|
self.advertiser = threading.Lock()
|
|
self.running = {}
|
|
|
|
def load(self, ident):
|
|
path = self.root / (ident + ".json")
|
|
if not path.exists():
|
|
return {"enabled": False, "attempts": []}
|
|
return json.loads(trusted(path).read_text())
|
|
|
|
def save(self, ident, value):
|
|
write(self.root / (ident + ".json"), json.dumps(value).encode(), 0o600)
|
|
|
|
def control(self, ident, enabled):
|
|
with self.lock:
|
|
value = self.load(ident)
|
|
if not enabled and "serial" not in value:
|
|
return {"enabled": False}
|
|
if enabled:
|
|
matches = [x for x in connected() if x.device_id == ident]
|
|
if len(matches) != 1 or not re.fullmatch(r"[A-Za-z0-9]{7,64}", matches[0].serial):
|
|
raise ValueError("Connect the exact X4 before enabling recovery")
|
|
if len(list(self.root.glob("instax4_*.json"))) >= 500 and "serial" not in value:
|
|
raise ValueError("Recovery inventory is full")
|
|
value["serial"] = matches[0].serial
|
|
value["enabled"] = enabled
|
|
self.save(ident, value)
|
|
if not enabled and ident in self.running:
|
|
self.running[ident].terminate()
|
|
return {"enabled": enabled}
|
|
|
|
def wake(self, ident, attempt):
|
|
if not self.advertiser.acquire(blocking=False):
|
|
return {"state": "busy"}
|
|
try:
|
|
# Installation cannot replace code/state while a wake is in flight.
|
|
with acquisition():
|
|
return self.wake_locked(ident, attempt)
|
|
finally:
|
|
self.advertiser.release()
|
|
|
|
def wake_locked(self, ident, attempt):
|
|
with self.lock:
|
|
value = self.load(ident)
|
|
if not value["enabled"] or "serial" not in value:
|
|
return {"state": "disabled"}
|
|
serial = value["serial"]
|
|
if any(x.device_id == ident for x in connected()):
|
|
return {"state": "connected"}
|
|
# A radio suffix is weaker than full USB identity. Refuse known collisions.
|
|
for path in self.root.glob("instax4_*.json"):
|
|
other = json.loads(trusted(path).read_text()).get("serial", "")
|
|
if other and other != serial and other[-6:] == serial[-6:]:
|
|
return {"state": "identity_conflict"}
|
|
now = time.time()
|
|
attempts = [x for x in value["attempts"] if now - x["at"] < 600]
|
|
if any(x["id"] == attempt for x in attempts):
|
|
return {"state": "already_attempted"}
|
|
if len(attempts) >= 3:
|
|
return {"state": "exhausted"}
|
|
value["attempts"] = attempts + [{"id": attempt, "at": now}]
|
|
# Persist before advertising; a crash cannot silently replay the attempt.
|
|
self.save(ident, value)
|
|
target = {
|
|
"device_id": ident,
|
|
"serial": serial,
|
|
"bluetooth_address": None,
|
|
"wakeup_enabled": True,
|
|
}
|
|
with tempfile.TemporaryFile(dir=self.root) as output:
|
|
process = subprocess.Popen(
|
|
["/usr/bin/python3", "-I", str(CODE / "ble_wake.py")],
|
|
stdin=subprocess.PIPE,
|
|
stdout=output,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
self.running[ident] = process
|
|
process.stdin.write(json.dumps(target).encode())
|
|
process.stdin.close()
|
|
# Release state lock during hardware wait so disable can cancel.
|
|
self.lock.release()
|
|
try:
|
|
try:
|
|
process.wait(timeout=45)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
output.seek(0)
|
|
raw = output.read(1024 * 1024 + 1)
|
|
finally:
|
|
self.lock.acquire()
|
|
self.running.pop(ident, None)
|
|
state = "unavailable"
|
|
try:
|
|
if len(raw) <= 1024 * 1024:
|
|
report = json.loads(raw)
|
|
write(self.root / (ident + ".last-wake"), raw, 0o600)
|
|
if process.returncode == 0 and report.get("sdk_usb_returned"):
|
|
state = "connected"
|
|
except (ValueError, OSError):
|
|
pass
|
|
return {"state": state if self.load(ident)["enabled"] else "disabled"}
|
|
|
|
def dispatch(self, method, route, value, _headers):
|
|
if method == "GET" and route == "/health":
|
|
return {"ready": True}
|
|
if method != "POST" or not isinstance(value, dict):
|
|
raise ValueError("Unsupported recovery request")
|
|
ident = value.get("device_id")
|
|
if not isinstance(ident, str) or not IDENTIFIER.fullmatch(ident):
|
|
raise ValueError("Invalid recovery identity")
|
|
if route == "/control" and set(value) == {"device_id", "enabled"}:
|
|
if type(value["enabled"]) is not bool:
|
|
raise ValueError("Recovery requires an explicit boolean")
|
|
return self.control(ident, value["enabled"])
|
|
if route == "/wake" and set(value) == {"device_id", "attempt"}:
|
|
if not isinstance(value["attempt"], str) or not re.fullmatch(
|
|
r"[0-9a-f]{32}", value["attempt"]
|
|
):
|
|
raise ValueError("Invalid wake attempt")
|
|
return self.wake(ident, value["attempt"])
|
|
raise ValueError("Unsupported recovery request")
|
|
|
|
|
|
def main():
|
|
if os.geteuid() or sys.argv[1:]:
|
|
raise RuntimeError("Use the installed fixed wake service")
|
|
trusted(ROOT, True)
|
|
if SOCKET.exists():
|
|
SOCKET.unlink()
|
|
allowed = {0, pwd.getpwnam("mission-core-insta360").pw_uid}
|
|
with Server(SOCKET, WakeService().dispatch, allowed) as server:
|
|
SOCKET.chmod(0o660)
|
|
server.serve_forever(poll_interval=0.5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|