""" Request bounds and the LAN token check. A client can declare a tiny Content-Length and then keep writing, so the declared length is a hint and the read itself stops at the cap. The token never travels in a query string: proxies and access logs keep those. """ from __future__ import annotations import hmac import ipaddress import os import shutil import signal import socket import subprocess from typing import Any, Dict, Optional LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) def is_loopback_host(host: Optional[str]) -> bool: text = (host or "").strip().lower() if text.startswith("[") and text.endswith("]"): text = text[1:-1] if "%" in text: # fe80::1%lo0 text = text.split("%", 1)[0] if text in LOOPBACK_HOSTS: return True try: return ipaddress.ip_address(text).is_loopback except ValueError: return False def require_token_for_bind(host: Optional[str], token: Optional[str]) -> None: """A non-loopback bind with no token is reachable by anyone on that network.""" if token or is_loopback_host(host): return raise SystemExit( f"refusing to bind {host} without DORAYAKI_API_TOKEN " "(loopback is the only bind that may run with auth off)" ) def token_matches(presented: Optional[str], token: Optional[str]) -> bool: if not presented or not token: return False # compare_digest raises when the lengths differ. Fold the length in so a # short guess neither throws nor returns early. return hmac.compare_digest( len(presented).to_bytes(8, "big") + presented.encode(), len(token).to_bytes(8, "big") + token.encode(), ) def bearer_matches(authorization: Optional[str], token: str) -> bool: if not authorization or not token: return False scheme, _, presented = authorization.partition(" ") if scheme.lower() != "bearer" or not presented or " " in presented: return False return token_matches(presented, token) def declared_length(header: Optional[str]) -> Optional[int]: if header is None or header == "": return None try: length = int(header) except (TypeError, ValueError): return None return length if length >= 0 else None async def read_bounded(source, max_bytes: int) -> bytes: """Stop once the cap is passed. The caller rejects the oversize body. A request stream is async-iterable. An UploadFile is not: it has read(n). """ out = bytearray() read = getattr(source, "read", None) if read is not None and not hasattr(source, "__aiter__"): while True: chunk = await read(65536) if not chunk: break out += chunk if len(out) > max_bytes: break return bytes(out) async for chunk in source: if not chunk: continue out += chunk if len(out) > max_bytes: break return bytes(out) class BodyLimit: """Stop the ASGI receive once the body passes the cap. A handler that checks Content-Length, or reads request.body(), runs after the server has already buffered the stream. This counts bytes at receive and returns 413 without handing the rest to the app. Content-Length is not trusted: the stream is what counts, and reading stops at the cap. If the app already started the response, the connection is aborted instead of sending a second http.response.start. WebSocket frames are not HTTP bodies and are left alone. """ def __init__(self, app: Any, max_bytes: int) -> None: self.app = app self.max_bytes = int(max_bytes) async def __call__(self, scope, receive, send) -> None: if scope.get("type") != "http": await self.app(scope, receive, send) return class TooLarge(Exception): pass seen = 0 started = False async def limited() -> Dict[str, Any]: nonlocal seen message = await receive() if message.get("type") != "http.request": return message body = message.get("body") or b"" seen += len(body) if seen > self.max_bytes: # The app must not see this chunk or any later one. raise TooLarge return message async def tracked(message: Dict[str, Any]) -> None: nonlocal started if message.get("type") == "http.response.start": started = True await send(message) try: await self.app(scope, limited, tracked) except TooLarge: if started: # The status line is already on the wire. Returning leaves the # response unfinished so the server closes the connection # instead of accepting a second http.response.start. return await send( { "type": "http.response.start", "status": 413, "headers": [(b"content-type", b"application/json")], } ) await send( { "type": "http.response.body", "body": b'{"error":{"code":"PAYLOAD_TOO_LARGE","message":"request body too large"}}', } ) SERVICE_TYPE = "_dorayaki._tcp" def _lan_address() -> Optional[str]: """Address a phone on this network can open. Loopback is not one. The demo launcher uses `ipconfig getifaddr en0` then `en1`. A UDP connect is the fallback when those interfaces are absent. """ if shutil.which("ipconfig"): for iface in ("en0", "en1"): try: out = subprocess.check_output( ["ipconfig", "getifaddr", iface], stderr=subprocess.DEVNULL, text=True ).strip() except (subprocess.CalledProcessError, OSError): continue if out and not is_loopback_host(out): return out try: probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: probe.connect(("192.0.2.1", 9)) found = probe.getsockname()[0] finally: probe.close() except OSError: return None if not found or is_loopback_host(found): return None return found def advertise_host(bind_host: Optional[str]) -> Optional[str]: """The address clients should browse. A loopback bind is not advertised. A wildcard bind (0.0.0.0, ::) has no address of its own, so the record uses the LAN address the demo launcher would hand a phone. """ text = (bind_host or "").strip() if text.startswith("[") and text.endswith("]"): text = text[1:-1] if is_loopback_host(text): return None if text in ("", "0.0.0.0", "::"): return _lan_address() try: parsed = ipaddress.ip_address(text) except ValueError: return None if parsed.is_loopback or parsed.is_unspecified: return None if parsed.is_loopback else _lan_address() return str(parsed) class ServiceAdvertiser: """Register `_dorayaki._tcp` with the system mDNS daemon and drop it on close. ponytail: macOS `dns-sd` only. A Linux host needs `avahi-publish` wired the same way; do not add a Python mDNS package for this one record. """ def __init__(self, host: str, port: int, name: str = "Dorayaki") -> None: self.host = host self.port = int(port) self.name = name self._process: Optional[subprocess.Popen] = None def start(self) -> bool: if self._process is not None: return True binary = shutil.which("dns-sd") if not binary: return False self._process = subprocess.Popen( [binary, "-P", self.name, SERVICE_TYPE, "local", str(self.port), self.host, self.host], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) return True def close(self) -> None: process = self._process self._process = None if process is None or process.poll() is not None: return try: os.killpg(process.pid, signal.SIGTERM) except ProcessLookupError: return try: process.wait(timeout=2) except subprocess.TimeoutExpired: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: return process.wait(timeout=2)