Initial commit: ASCILINE YouTube Streamer
ASCII-art YouTube streaming for the Tesla in-car browser. - FastAPI server on a Mac mini, no Docker. - yt-dlp resolver: ID/URL/search. - ffmpeg with -re -fps_mode cfr for source-paced video; trivial drain consumer. Separate ffmpeg for AAC/ADTS audio. - Vendored ASCILINE renderer (MIT) for the binary wire protocol; pure fillText color path, on-demand selection flush. - HMAC PIN-gated cookie; Secure flag scheme-aware so /audio works on plain http during local dev. - LOW preset (120x50 24fps) verified clean on M4: FPS 24/24, JIT ~42ms.
This commit is contained in:
162
auth.py
Normal file
162
auth.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""PIN gate.
|
||||
|
||||
- Signing secret loaded from / generated into .secret (survives restarts).
|
||||
- HMAC-signed session cookie via itsdangerous TimestampSigner.
|
||||
- Per-IP brute-force counter: 5 failures → 10-minute lockout.
|
||||
- Middleware enforces the cookie on every route; WS rejects with code 4003.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
|
||||
|
||||
log = logging.getLogger("auth")
|
||||
|
||||
COOKIE_NAME = "asciline_auth"
|
||||
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
|
||||
_TOKEN_PAYLOAD = "ok" # fixed payload; signature is the proof
|
||||
_MAX_ATTEMPTS = 5
|
||||
_LOCKOUT_SECONDS = 600 # 10 minutes
|
||||
|
||||
SECRET_FILE = Path(__file__).parent / ".secret"
|
||||
|
||||
|
||||
def _load_or_create_secret() -> str:
|
||||
if SECRET_FILE.exists():
|
||||
secret = SECRET_FILE.read_text().strip()
|
||||
if secret:
|
||||
return secret
|
||||
secret = secrets.token_hex(32)
|
||||
SECRET_FILE.write_text(secret)
|
||||
SECRET_FILE.chmod(0o600)
|
||||
log.info("Generated new signing secret → %s", SECRET_FILE)
|
||||
return secret
|
||||
|
||||
|
||||
_SECRET: Optional[str] = None
|
||||
_SIGNER: Optional[TimestampSigner] = None
|
||||
|
||||
|
||||
def _signer() -> TimestampSigner:
|
||||
if _SIGNER is None:
|
||||
raise RuntimeError("auth not initialised — call auth.init() first")
|
||||
return _SIGNER
|
||||
|
||||
|
||||
def init() -> None:
|
||||
"""Load (or generate) the signing secret. Must be called before serving."""
|
||||
global _SECRET, _SIGNER
|
||||
_SECRET = _load_or_create_secret()
|
||||
_SIGNER = TimestampSigner(_SECRET)
|
||||
|
||||
|
||||
# Brute-force tracker keyed by IP. In-memory is enough for a personal service.
|
||||
# {ip: {"count": int, "locked_until": float}}
|
||||
_attempts: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _ip_from_scope(scope) -> str:
|
||||
"""CF-Connecting-IP first (the real client behind Cloudflare), then peer."""
|
||||
headers = dict(scope.get("headers") or [])
|
||||
cf_ip = headers.get(b"cf-connecting-ip", b"").decode("latin-1").strip()
|
||||
if cf_ip:
|
||||
return cf_ip
|
||||
client = scope.get("client")
|
||||
if client:
|
||||
return client[0]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_locked_out(ip: str) -> bool:
|
||||
entry = _attempts.get(ip)
|
||||
if entry is None:
|
||||
return False
|
||||
if entry["locked_until"] and time.monotonic() < entry["locked_until"]:
|
||||
return True
|
||||
if entry["locked_until"] and time.monotonic() >= entry["locked_until"]:
|
||||
_attempts.pop(ip, None)
|
||||
return False
|
||||
|
||||
|
||||
def record_failure(ip: str) -> bool:
|
||||
"""Record a failed attempt; return True if this IP is now locked out."""
|
||||
entry = _attempts.setdefault(ip, {"count": 0, "locked_until": 0.0})
|
||||
entry["count"] += 1
|
||||
if entry["count"] >= _MAX_ATTEMPTS:
|
||||
entry["locked_until"] = time.monotonic() + _LOCKOUT_SECONDS
|
||||
log.warning("IP %s locked out for %ds after %d failures",
|
||||
ip, _LOCKOUT_SECONDS, entry["count"])
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def record_success(ip: str) -> None:
|
||||
_attempts.pop(ip, None)
|
||||
|
||||
|
||||
def make_cookie_value() -> str:
|
||||
return _signer().sign(_TOKEN_PAYLOAD).decode()
|
||||
|
||||
|
||||
def verify_cookie(value: str) -> bool:
|
||||
try:
|
||||
_signer().unsign(value, max_age=COOKIE_MAX_AGE)
|
||||
return True
|
||||
except (BadSignature, SignatureExpired):
|
||||
return False
|
||||
|
||||
|
||||
def cookie_header(value: str, *, secure: bool = True) -> str:
|
||||
"""Build the Set-Cookie header string.
|
||||
|
||||
Pass ``secure=False`` only when the request that triggered this
|
||||
Set-Cookie was itself served over plain http — browsers refuse to send
|
||||
Secure cookies over plain http, which would silently 403 the
|
||||
/audio GET on http://localhost:* while the WebSocket still works
|
||||
(different upgrade path).
|
||||
"""
|
||||
parts = [
|
||||
f"{COOKIE_NAME}={value}",
|
||||
f"Max-Age={COOKIE_MAX_AGE}",
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
]
|
||||
if secure:
|
||||
parts.append("Secure")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
_PIN: Optional[str] = None
|
||||
|
||||
|
||||
def set_pin(pin: str) -> None:
|
||||
global _PIN
|
||||
_PIN = pin
|
||||
|
||||
|
||||
def check_pin(provided: str) -> bool:
|
||||
return _PIN is not None and provided == _PIN
|
||||
|
||||
|
||||
def get_cookie(scope) -> Optional[str]:
|
||||
"""Extract the auth cookie value from a Starlette scope's headers."""
|
||||
headers = scope.get("headers") or []
|
||||
for name, value in headers:
|
||||
if name.lower() == b"cookie":
|
||||
for part in value.decode("latin-1").split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith(COOKIE_NAME + "="):
|
||||
return part[len(COOKIE_NAME) + 1:]
|
||||
return None
|
||||
|
||||
|
||||
def is_authenticated(scope) -> bool:
|
||||
value = get_cookie(scope)
|
||||
return value is not None and verify_cookie(value)
|
||||
Reference in New Issue
Block a user