fix: restore reliable authenticated live recognition
This commit is contained in:
parent
0e23425ef0
commit
934615b593
40 changed files with 2556 additions and 820 deletions
254
recognization/code/transport.py
Normal file
254
recognization/code/transport.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""
|
||||
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. 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
|
||||
seen = 0
|
||||
blocked = False
|
||||
|
||||
async def limited() -> Dict[str, Any]:
|
||||
nonlocal seen, blocked
|
||||
if blocked:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
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:
|
||||
blocked = True
|
||||
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"}}',
|
||||
}
|
||||
)
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
return message
|
||||
|
||||
try:
|
||||
await self.app(scope, limited, send)
|
||||
except RuntimeError:
|
||||
if not blocked:
|
||||
raise
|
||||
|
||||
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue