camera_bridge.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. """The slicer's camera view through a virtual printer, signed in for it (#1430).
  2. A slicer bound to a virtual printer opens the camera on the virtual printer's
  3. address. A plain pass-through to the real printer only worked with the
  4. printer's own access code: the printer checks the code itself, so a slicer
  5. signed in with a personal access code was refused there, and the slicer then
  6. asked for the code again on every action.
  7. This bridge ends the slicer's TLS with the virtual printer's certificate (the
  8. one the slicer already trusts for MQTT and FTP), opens its own TLS connection
  9. to the printer, and passes the printer's own code on when the slicer's code is
  10. one it accepts:
  11. * RTSPS (port 322, X1/X2/H2/P2S): the printer asks for Digest auth, so the
  12. code never crosses the wire, only a hash of it. Each ``Authorization``
  13. header is checked against the accepted codes and, on a match, its hash is
  14. computed anew with the printer's code. Nonce and URI stay the slicer's.
  15. * Chamber image (port 6000, A1/P1): the code is in plain text in the first 80
  16. bytes; it is checked and replaced by the printer's.
  17. Codes it accepts (``codes``): the virtual printer's, the printer's own, and
  18. the personal codes of users allowed to watch this printer now (looked up
  19. when a sign-in arrives, again after ``CODES_MAX_AGE``). A sign-in the bridge
  20. does not accept is never passed on: it counts as a failed sign-in and ends
  21. the connection. Otherwise the camera would be a way to guess codes, the
  22. printer's own among them, with no limit.
  23. """
  24. from __future__ import annotations
  25. import asyncio
  26. import hashlib
  27. import hmac
  28. import logging
  29. import re
  30. import ssl
  31. import struct
  32. import time
  33. from collections.abc import Awaitable, Callable
  34. from dataclasses import dataclass
  35. from pathlib import Path
  36. from backend.app.services.virtual_printer.ftp_server import AuthRateLimiter
  37. logger = logging.getLogger(__name__)
  38. RTSP = "rtsp"
  39. CHAMBER = "chamber"
  40. # Chamber image sign-in: magic, command, padding, user (32), code (32).
  41. _CHAMBER_AUTH = struct.Struct("<II8s32s32s")
  42. _CHAMBER_MAGIC = 0x40
  43. _CHAMBER_COMMAND = 0x3000
  44. _DIGEST_PARAM = re.compile(r'(\w+)\s*=\s*(?:"([^"]*)"|([^\s,]+))')
  45. _DIGEST_RESPONSE = re.compile(r'(response\s*=\s*)(?:"[^"]*"|[^\s,]+)', re.IGNORECASE)
  46. _MAX_RTSP_BODY = 1 << 20
  47. @dataclass(frozen=True)
  48. class AcceptedCode:
  49. """A code the camera accepts, and the user whose personal code it is (None for a shared one)."""
  50. code: str
  51. user_id: int | None = None
  52. def parse_digest(value: str) -> dict[str, str] | None:
  53. """The parameters of a ``Digest ...`` Authorization value, or None for another scheme."""
  54. scheme, _, rest = value.strip().partition(" ")
  55. if scheme.lower() != "digest":
  56. return None
  57. return {
  58. m.group(1).lower(): m.group(2) if m.group(2) is not None else m.group(3) for m in _DIGEST_PARAM.finditer(rest)
  59. }
  60. def digest_response(params: dict[str, str], method: str, password: str) -> str | None:
  61. """The Digest ``response`` (RFC 2617, MD5) for ``params`` signed with ``password``.
  62. None when a field it needs is missing or the algorithm is not MD5.
  63. """
  64. if params.get("algorithm", "MD5").upper() != "MD5":
  65. return None
  66. try:
  67. username, realm, nonce, uri = params["username"], params["realm"], params["nonce"], params["uri"]
  68. except KeyError:
  69. return None
  70. def md5(text: str) -> str:
  71. return hashlib.md5(text.encode("latin-1"), usedforsecurity=False).hexdigest()
  72. ha1 = md5(f"{username}:{realm}:{password}")
  73. ha2 = md5(f"{method}:{uri}")
  74. qop = params.get("qop")
  75. if qop:
  76. nc, cnonce = params.get("nc"), params.get("cnonce")
  77. if nc is None or cnonce is None:
  78. return None
  79. return md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}")
  80. return md5(f"{ha1}:{nonce}:{ha2}")
  81. def match_digest(params: dict[str, str], method: str, accepted: list[AcceptedCode]) -> AcceptedCode | None:
  82. """The accepted code the slicer's Digest response was made with, if any."""
  83. given = params.get("response", "").lower()
  84. found = None
  85. for candidate in accepted:
  86. expected = digest_response(params, method, candidate.code)
  87. # Every candidate is tried, so the time taken does not tell which matched.
  88. if expected is not None and hmac.compare_digest(expected, given) and found is None:
  89. found = candidate
  90. return found
  91. def rewrite_rtsp_auth(
  92. head: bytes, accepted: list[AcceptedCode], printer_code: str
  93. ) -> tuple[bytes, AcceptedCode | None, bool]:
  94. """Sign an RTSP request's Digest header with the printer's code.
  95. Returns ``(head, matched, had_auth)``: the request head (rewritten when
  96. the slicer's code is accepted, else unchanged), the code it matched, and
  97. whether the request tries to sign in at all.
  98. Fail-closed: ``had_auth`` is True for any request that mentions
  99. "authorization" anywhere, and it is only re-signed when that is one
  100. ``Authorization: Digest`` header with an accepted code. The printer's
  101. server finds a Digest header wherever it is in the request (in another
  102. header's value, or after a Basic one), so a request the bridge does not
  103. re-sign must never reach it: its answer would tell whether a guessed code
  104. is right, without the guess being counted.
  105. """
  106. if b"authorization" not in head.lower():
  107. return head, None, False
  108. if head.lower().count(b"authorization") != 1:
  109. return head, None, True
  110. text = head.decode("latin-1")
  111. lines = text.split("\r\n")
  112. method = lines[0].split(" ", 1)[0] if lines else ""
  113. for i, line in enumerate(lines[1:], start=1):
  114. name, sep, value = line.partition(":")
  115. if not sep or name.strip().lower() != "authorization":
  116. continue
  117. params = parse_digest(value)
  118. if params is None:
  119. return head, None, True
  120. matched = match_digest(params, method, accepted)
  121. if matched is None:
  122. return head, None, True
  123. new = digest_response(params, method, printer_code)
  124. if new is None:
  125. return head, None, True
  126. signed = _DIGEST_RESPONSE.sub(lambda m, new=new: m.group(1) + '"' + new + '"', value, count=1)
  127. lines[i] = f"{name}:{signed}"
  128. return "\r\n".join(lines).encode("latin-1"), matched, True
  129. return head, None, True
  130. def rewrite_chamber_auth(
  131. payload: bytes, accepted: list[AcceptedCode], printer_code: str
  132. ) -> tuple[bytes, AcceptedCode | None]:
  133. """The chamber image sign-in with the printer's code, when the slicer's is accepted."""
  134. if len(payload) != _CHAMBER_AUTH.size:
  135. return payload, None
  136. magic, command, padding, user, code = _CHAMBER_AUTH.unpack(payload)
  137. if magic != _CHAMBER_MAGIC or command != _CHAMBER_COMMAND:
  138. return payload, None
  139. given = code.rstrip(b"\x00")
  140. found = None
  141. for candidate in accepted:
  142. if hmac.compare_digest(candidate.code.encode(), given) and found is None:
  143. found = candidate
  144. if found is None:
  145. return payload, None
  146. return _CHAMBER_AUTH.pack(magic, command, padding, user, printer_code.encode().ljust(32, b"\x00")), found
  147. def _server_context(cert_path: Path, key_path: Path) -> ssl.SSLContext:
  148. context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  149. context.load_cert_chain(str(cert_path), str(key_path))
  150. context.minimum_version = ssl.TLSVersion.TLSv1_2
  151. # The suites real printers offer; see mqtt_server (#1610).
  152. context.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  153. return context
  154. def _printer_context() -> ssl.SSLContext:
  155. # The printer's certificate is self-signed by its maker; Bambuddy's own
  156. # camera connection does not check it either (services.camera).
  157. context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  158. context.check_hostname = False
  159. context.verify_mode = ssl.CERT_NONE
  160. return context
  161. FIRST_BYTES_TIMEOUT = 15.0
  162. # How long a connection keeps the accepted codes it looked up: a user who
  163. # loses camera:view, is disabled or renews their code is out within this.
  164. CODES_MAX_AGE = 60.0
  165. class _AcceptedCodes:
  166. """One connection's accepted codes: looked up when first needed, again once older than ``CODES_MAX_AGE``."""
  167. def __init__(self, lookup: Callable[[], Awaitable[list[AcceptedCode]]]):
  168. self._lookup = lookup
  169. self._codes: list[AcceptedCode] | None = None
  170. self._at = 0.0
  171. async def get(self) -> list[AcceptedCode]:
  172. now = time.monotonic()
  173. if self._codes is None or now - self._at > CODES_MAX_AGE:
  174. self._codes = await self._lookup()
  175. self._at = now
  176. return self._codes
  177. class CameraBridge:
  178. """Camera port of a virtual printer with a target printer, signing the slicer in with the printer's code."""
  179. def __init__(
  180. self,
  181. *,
  182. name: str,
  183. protocol: str,
  184. listen_port: int,
  185. bind_address: str,
  186. target_host: str,
  187. target_port: int,
  188. cert_path: Path,
  189. key_path: Path,
  190. codes: Callable[[], Awaitable[list[AcceptedCode]]],
  191. printer_code: Callable[[], str | None],
  192. rate_limiter: AuthRateLimiter | None = None,
  193. ):
  194. self.name = name
  195. self.protocol = protocol
  196. self.listen_port = listen_port
  197. self.bind_address = bind_address
  198. self.target_host = target_host
  199. self.target_port = target_port
  200. self.cert_path = cert_path
  201. self.key_path = key_path
  202. self._codes = codes
  203. self._printer_code = printer_code
  204. self.rate_limiter = rate_limiter or AuthRateLimiter()
  205. self._server: asyncio.Server | None = None
  206. self._handlers: set[asyncio.Task] = set()
  207. async def start(self) -> None:
  208. """Listen until stopped."""
  209. logger.info(
  210. "Starting %s camera bridge: %s:%s -> %s:%s",
  211. self.name,
  212. self.bind_address,
  213. self.listen_port,
  214. self.target_host,
  215. self.target_port,
  216. )
  217. try:
  218. self._server = await asyncio.start_server(
  219. self._handle,
  220. self.bind_address,
  221. self.listen_port,
  222. ssl=_server_context(self.cert_path, self.key_path),
  223. )
  224. # The server is serving already. Not ``serve_forever()`` nor
  225. # ``async with``: once cancelled, both wait for every connection to
  226. # close (Python 3.12+), before stop() below gets to end them, so a
  227. # slicer watching the camera kept the virtual printer from stopping.
  228. await asyncio.get_running_loop().create_future()
  229. except OSError as e:
  230. logger.error("%s camera bridge on port %s: %s", self.name, self.listen_port, e)
  231. except asyncio.CancelledError:
  232. pass
  233. finally:
  234. await self.stop()
  235. async def stop(self) -> None:
  236. """Stop listening and end every open camera connection."""
  237. server, self._server = self._server, None
  238. if server is not None:
  239. server.close()
  240. handlers = list(self._handlers)
  241. for task in handlers:
  242. task.cancel()
  243. if handlers:
  244. await asyncio.gather(*handlers, return_exceptions=True)
  245. # No wait_closed(): since Python 3.12 it waits for each connection's
  246. # TLS close, which a slicer that stopped reading holds up for 30 s.
  247. async def _handle(self, client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter) -> None:
  248. current = asyncio.current_task()
  249. if current is not None:
  250. self._handlers.add(current)
  251. peer = client_writer.get_extra_info("peername")
  252. source_ip = peer[0] if peer else "?"
  253. printer_writer = None
  254. try:
  255. printer_code = self._printer_code()
  256. if not printer_code:
  257. logger.warning("%s camera: the target printer's access code is not known", self.name)
  258. return
  259. if self.rate_limiter.is_limited(source_ip):
  260. logger.warning("%s camera from %s refused: too many failed attempts", self.name, source_ip)
  261. return
  262. # Nothing is looked up or opened for a connection that sends nothing.
  263. first = await asyncio.wait_for(
  264. client_reader.readexactly(_CHAMBER_AUTH.size if self.protocol == CHAMBER else 1),
  265. timeout=FIRST_BYTES_TIMEOUT,
  266. )
  267. codes = _AcceptedCodes(self._codes)
  268. if self.protocol == CHAMBER:
  269. first, matched = rewrite_chamber_auth(first, await codes.get(), printer_code)
  270. if matched is None:
  271. self._refused(source_ip)
  272. return
  273. self._signed_in(source_ip, matched, set())
  274. printer_reader, printer_writer = await asyncio.wait_for(
  275. asyncio.open_connection(self.target_host, self.target_port, ssl=_printer_context()),
  276. timeout=10.0,
  277. )
  278. logger.info("%s camera: %s connected", self.name, source_ip)
  279. if self.protocol == CHAMBER:
  280. printer_writer.write(first)
  281. await printer_writer.drain()
  282. pump = self._pipe(client_reader, printer_writer)
  283. else:
  284. pump = self._pump_rtsp(first, client_reader, printer_writer, source_ip, codes, printer_code)
  285. await asyncio.gather(pump, self._pipe(printer_reader, client_writer))
  286. except (ConnectionError, OSError, TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError) as e:
  287. logger.debug("%s camera connection from %s ended: %s", self.name, source_ip, e)
  288. except asyncio.CancelledError:
  289. pass
  290. except Exception as e: # noqa: BLE001 -- one camera connection must not take the bridge down
  291. logger.error("%s camera connection from %s failed: %s", self.name, source_ip, e)
  292. finally:
  293. if current is not None:
  294. self._handlers.discard(current)
  295. for writer in (client_writer, printer_writer):
  296. if writer is not None and not writer.is_closing():
  297. try:
  298. writer.close()
  299. except (OSError, RuntimeError):
  300. pass
  301. def _signed_in(self, source_ip: str, matched: AcceptedCode, logged: set[int | None]) -> None:
  302. if matched.user_id not in logged:
  303. logged.add(matched.user_id)
  304. if matched.user_id is not None:
  305. logger.info("%s camera: %s signed in as user %s", self.name, source_ip, matched.user_id)
  306. def _refused(self, source_ip: str) -> None:
  307. """Count a sign-in the camera doesn't accept; the caller then ends the connection."""
  308. self.rate_limiter.record_failure(source_ip)
  309. logger.warning("%s camera from %s: access code not accepted", self.name, source_ip)
  310. async def _pump_rtsp(
  311. self,
  312. first: bytes,
  313. reader: asyncio.StreamReader,
  314. writer: asyncio.StreamWriter,
  315. source_ip: str,
  316. codes: _AcceptedCodes,
  317. printer_code: str,
  318. ) -> None:
  319. """Slicer -> printer: RTSP requests (Digest re-signed) and interleaved ``$`` frames as they are.
  320. A sign-in that is not re-signed ends the connection: it is never
  321. passed on (see ``rewrite_rtsp_auth``).
  322. """
  323. logged: set[int | None] = set()
  324. try:
  325. while True:
  326. if first == b"$":
  327. header = await reader.readexactly(3)
  328. frame = await reader.readexactly(int.from_bytes(header[1:3], "big"))
  329. writer.write(first + header + frame)
  330. else:
  331. head = first + await reader.readuntil(b"\r\n\r\n")
  332. body = b""
  333. match = re.search(rb"\r\ncontent-length\s*:\s*(\d+)", head, re.IGNORECASE)
  334. if match:
  335. length = int(match.group(1))
  336. if length > _MAX_RTSP_BODY:
  337. return
  338. body = await reader.readexactly(length)
  339. accepted = await codes.get() if b"authorization" in head.lower() else []
  340. head, matched, had_auth = rewrite_rtsp_auth(head, accepted, printer_code)
  341. if matched is not None:
  342. self._signed_in(source_ip, matched, logged)
  343. elif had_auth:
  344. self._refused(source_ip)
  345. return
  346. writer.write(head + body)
  347. await writer.drain()
  348. first = await reader.readexactly(1)
  349. except asyncio.IncompleteReadError:
  350. pass
  351. finally:
  352. if not writer.is_closing():
  353. writer.close()
  354. @staticmethod
  355. async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  356. try:
  357. while True:
  358. data = await reader.read(65536)
  359. if not data:
  360. break
  361. writer.write(data)
  362. await writer.drain()
  363. except (ConnectionError, OSError, RuntimeError):
  364. pass
  365. finally:
  366. if not writer.is_closing():
  367. try:
  368. writer.close()
  369. except (OSError, RuntimeError):
  370. pass