Add standalone GusVoice AWG bot and Linux installer
This commit is contained in:
commit
38c6a1a682
23 files changed
+2924
No files matched your search
Vendored
+133
@@ -0,0 +1,133 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Receiving updates on your own address (`setWebhook`).
|
||||
|
||||
Every delivery carries `X-GusVoice-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>`
|
||||
(the server side is `webhookSignature` in `packages/backend/src/bots/botRules.ts`). Check it on the RAW body, before
|
||||
parsing JSON: a re-serialized body does not keep the signature.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
SIGNATURE_HEADER = "X-GusVoice-Signature"
|
||||
"""Header with the signature (header names are case-insensitive)."""
|
||||
DEFAULT_TOLERANCE_S = 300
|
||||
"""Signature time window, seconds: an older (or far future) delivery is a replay and is refused."""
|
||||
MAX_BODY_BYTES = 8 << 20
|
||||
"""Largest delivery body `webhook_handler` accepts."""
|
||||
|
||||
|
||||
def parse_signature_header(header: Optional[str]) -> Optional[Tuple[int, List[str]]]:
|
||||
"""`(t, [v1, ...])` from the header, or `None` if it is missing or malformed (several `v1` are allowed)."""
|
||||
if not header or not isinstance(header, str):
|
||||
return None
|
||||
t: Optional[int] = None
|
||||
v1: List[str] = []
|
||||
for part in header.split(","):
|
||||
k, sep, v = part.partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
k, v = k.strip(), v.strip()
|
||||
if k == "t" and v.isascii() and v.isdigit() and 1 <= len(v) <= 12:
|
||||
t = int(v)
|
||||
elif k == "v1" and len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v):
|
||||
v1.append(v.lower())
|
||||
return (t, v1) if t is not None and v1 else None
|
||||
|
||||
|
||||
def sign(secret: str, raw_body: Union[bytes, str], now: int) -> str:
|
||||
"""Header value the server would send for this body at `now` (for your tests)."""
|
||||
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
|
||||
mac = hmac.new(secret.encode("utf-8"), str(now).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
|
||||
return f"t={now},v1={mac}"
|
||||
|
||||
|
||||
def verify_signature(
|
||||
secret: str,
|
||||
header: Optional[str],
|
||||
raw_body: Union[bytes, str],
|
||||
tolerance: int = DEFAULT_TOLERANCE_S,
|
||||
now: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""`True` if the delivery is signed with `secret` and fresh (|now - t| <= `tolerance` seconds).
|
||||
|
||||
Comparison runs in constant time (`hmac.compare_digest`). Never raises on bad input: it just returns `False`.
|
||||
"""
|
||||
if not secret or not isinstance(secret, str):
|
||||
return False
|
||||
sig = parse_signature_header(header)
|
||||
if sig is None:
|
||||
return False
|
||||
t, candidates = sig
|
||||
current = time.time() if now is None else now
|
||||
if abs(current - t) > tolerance:
|
||||
return False
|
||||
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
|
||||
expected = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
|
||||
ok = False
|
||||
for v in candidates:
|
||||
ok = hmac.compare_digest(expected, v) or ok
|
||||
return ok
|
||||
|
||||
|
||||
def webhook_handler(
|
||||
secret: str,
|
||||
on_update: Callable[[Dict[str, Any]], Any],
|
||||
path: str = "/",
|
||||
tolerance: int = DEFAULT_TOLERANCE_S,
|
||||
) -> Type[BaseHTTPRequestHandler]:
|
||||
"""Request handler class for `http.server` (use `ThreadingHTTPServer`).
|
||||
|
||||
`POST <path>`: wrong signature -> 401, body larger than `MAX_BODY_BYTES` -> 413, not JSON -> 400; otherwise it
|
||||
answers 200 right away and then calls `on_update(update)` for the update (or each one of a batch; keep the server fast: it waits for the answer).
|
||||
Exceptions of `on_update` are printed and do not reach the server (the delivery is already acknowledged).
|
||||
"""
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None: # noqa: N802 (http.server naming)
|
||||
if self.path.split("?", 1)[0] != path:
|
||||
self._reply(404, b"not found")
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or "0")
|
||||
except ValueError:
|
||||
length = -1
|
||||
if length < 0 or length > MAX_BODY_BYTES:
|
||||
self._reply(413, b"too large")
|
||||
return
|
||||
raw = self.rfile.read(length)
|
||||
if not verify_signature(secret, self.headers.get(SIGNATURE_HEADER), raw, tolerance):
|
||||
self._reply(401, b"bad signature")
|
||||
return
|
||||
try:
|
||||
update = json.loads(raw.decode("utf-8"))
|
||||
except ValueError:
|
||||
self._reply(400, b"bad json")
|
||||
return
|
||||
self._reply(200, b"ok")
|
||||
# `setWebhook(batch=True)` delivers an array of updates in one request.
|
||||
for u in update if isinstance(update, list) else [update]:
|
||||
if not isinstance(u, dict):
|
||||
continue
|
||||
try:
|
||||
on_update(u)
|
||||
except Exception as e: # the delivery is acknowledged; report and go on
|
||||
self.log_error("update handler failed: %r", e)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
self._reply(405, b"POST only")
|
||||
|
||||
def _reply(self, status: int, body: bytes) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
self.wfile.flush()
|
||||
|
||||
return _Handler
|
||||
Reference in new issue
Block a user