174 lines
6.5 KiB
Python
174 lines
6.5 KiB
Python
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import aiohttp
|
|
from aiohttp import web
|
|
|
|
|
|
class InternalSigner:
|
|
HEADER_TIMESTAMP = "X-Account-Manager-Timestamp"
|
|
HEADER_SIGNATURE = "X-Account-Manager-Signature"
|
|
|
|
def __init__(self, secret_file: str, max_age_seconds: int = 30):
|
|
self.secret_file = Path(secret_file)
|
|
self.max_age_seconds = max(5, int(max_age_seconds))
|
|
self.secret = self._load_or_create_secret()
|
|
|
|
def _load_or_create_secret(self) -> bytes:
|
|
if self.secret_file.exists():
|
|
return self.secret_file.read_text(encoding="utf-8").strip().encode("utf-8")
|
|
self.secret_file.parent.mkdir(parents=True, exist_ok=True)
|
|
value = secrets.token_hex(64)
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
try:
|
|
descriptor = os.open(self.secret_file, flags, 0o600)
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
handle.write(value)
|
|
except FileExistsError:
|
|
return self.secret_file.read_text(encoding="utf-8").strip().encode("utf-8")
|
|
return value.encode("utf-8")
|
|
|
|
@staticmethod
|
|
def _canonical(method: str, path: str, timestamp: str, body: bytes) -> bytes:
|
|
digest = hashlib.sha256(body).hexdigest()
|
|
return f"{method.upper()}\n{path}\n{timestamp}\n{digest}".encode("utf-8")
|
|
|
|
def headers(self, method: str, path: str, body: bytes) -> dict[str, str]:
|
|
timestamp = str(int(time.time()))
|
|
signature = hmac.new(
|
|
self.secret,
|
|
self._canonical(method, path, timestamp, body),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
return {
|
|
self.HEADER_TIMESTAMP: timestamp,
|
|
self.HEADER_SIGNATURE: signature,
|
|
}
|
|
|
|
def verify(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
body: bytes,
|
|
timestamp: str,
|
|
signature: str,
|
|
) -> bool:
|
|
try:
|
|
numeric_timestamp = int(timestamp)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
if abs(int(time.time()) - numeric_timestamp) > self.max_age_seconds:
|
|
return False
|
|
expected = hmac.new(
|
|
self.secret,
|
|
self._canonical(method, path, timestamp, body),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
return hmac.compare_digest(expected, signature or "")
|
|
|
|
async def verify_request(self, request: web.Request, body: bytes) -> bool:
|
|
return self.verify(
|
|
request.method,
|
|
request.path,
|
|
body,
|
|
request.headers.get(self.HEADER_TIMESTAMP, ""),
|
|
request.headers.get(self.HEADER_SIGNATURE, ""),
|
|
)
|
|
|
|
|
|
class EventRelay:
|
|
PATH = "/account-manager/internal/event"
|
|
|
|
def __init__(self, server, scheduler, signer: InternalSigner, instance_port: int):
|
|
self.server = server
|
|
self.scheduler = scheduler
|
|
self.signer = signer
|
|
self.instance_port = int(instance_port)
|
|
self._original_send_json = server.send_json
|
|
self._original_send_bytes = server.send_bytes
|
|
|
|
def install(self) -> None:
|
|
self.server.send_json = self.send_json
|
|
self.server.send_bytes = self.send_bytes
|
|
|
|
async def _post(self, ingress_port: int, payload: dict) -> bool:
|
|
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
|
|
"utf-8"
|
|
)
|
|
headers = self.signer.headers("POST", self.PATH, body)
|
|
headers["Content-Type"] = "application/json"
|
|
url = f"http://127.0.0.1:{int(ingress_port)}{self.PATH}"
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.post(url, data=body, headers=headers) as response:
|
|
return 200 <= response.status < 300
|
|
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
return False
|
|
|
|
async def send_json(self, event, data, sid=None):
|
|
if sid is None or sid in self.server.sockets:
|
|
return await self._original_send_json(event, data, sid)
|
|
ingress_port = self.scheduler.client_ingress(str(sid))
|
|
if not ingress_port or ingress_port == self.instance_port:
|
|
return await self._original_send_json(event, data, sid)
|
|
relayed = await self._post(
|
|
ingress_port,
|
|
{
|
|
"kind": "json",
|
|
"event": event,
|
|
"data": data,
|
|
"sid": str(sid),
|
|
"source_port": self.instance_port,
|
|
},
|
|
)
|
|
if not relayed:
|
|
return await self._original_send_json(event, data, sid)
|
|
return None
|
|
|
|
async def send_bytes(self, event, data, sid=None):
|
|
if sid is None or sid in self.server.sockets:
|
|
return await self._original_send_bytes(event, data, sid)
|
|
ingress_port = self.scheduler.client_ingress(str(sid))
|
|
if not ingress_port or ingress_port == self.instance_port:
|
|
return await self._original_send_bytes(event, data, sid)
|
|
encoded = self.server.encode_bytes(event, data)
|
|
relayed = await self._post(
|
|
ingress_port,
|
|
{
|
|
"kind": "bytes",
|
|
"data": base64.b64encode(encoded).decode("ascii"),
|
|
"sid": str(sid),
|
|
"source_port": self.instance_port,
|
|
},
|
|
)
|
|
if not relayed:
|
|
return await self._original_send_bytes(event, data, sid)
|
|
return None
|
|
|
|
async def handle_event(self, request: web.Request) -> web.Response:
|
|
body = await request.read()
|
|
if not await self.signer.verify_request(request, body):
|
|
return web.json_response({"error": "Invalid internal signature"}, status=403)
|
|
try:
|
|
payload = json.loads(body)
|
|
sid = str(payload["sid"])
|
|
if payload["kind"] == "json":
|
|
await self._original_send_json(payload["event"], payload.get("data"), sid)
|
|
elif payload["kind"] == "bytes":
|
|
message = base64.b64decode(payload["data"], validate=True)
|
|
socket = self.server.sockets.get(sid)
|
|
if socket is not None:
|
|
await socket.send_bytes(message)
|
|
else:
|
|
raise ValueError("Unsupported event kind")
|
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"error": "Invalid internal event"}, status=400)
|
|
return web.json_response({"ok": True})
|