Files
Tuwunel-Interceptor/app.py
T
2026-05-05 19:53:09 +02:00

334 lines
9.1 KiB
Python

import json
import time
import os
import logging
import threading
from collections import defaultdict
from datetime import datetime, timezone
import requests
from flask import Flask, request, Response
app = Flask(__name__)
# ============================================================
# CONFIG
# ============================================================
class Config:
REQUIRED_KEYS = ["TUWUNEL_URL", "LOCAL_DOMAIN"]
def __init__(self):
self.tuwunel_url = os.getenv("TUWUNEL_URL")
self.local_domain = os.getenv("LOCAL_DOMAIN")
self.admin_users = self._parse_list_env("ADMIN_USERS", [])
self.admin_token = os.getenv("ADMIN_TOKEN", "")
self.domain_whitelist = set(
self._parse_list_env("DOMAIN_WHITELIST", [])
)
self.block_external_dms = self._parse_bool("BLOCK_EXTERNAL_DMS", True)
self.allow_room_creation = self._parse_bool("ALLOW_ROOM_CREATION", False)
self.cache_ttl = int(os.getenv("CACHE_TTL_SECONDS", 604800))
self.rate_limit_per_minute = int(os.getenv("RATE_LIMIT_PER_MINUTE", 20))
self.http_timeout = int(os.getenv("HTTP_TIMEOUT", 5))
self.fail_open = self._parse_bool("FAIL_OPEN", True)
self.debug = self._parse_bool("DEBUG", False)
def validate(self):
return [k for k in self.REQUIRED_KEYS if not os.getenv(k)]
def _parse_list_env(self, key, default=None):
raw = os.getenv(key)
if not raw:
return default or []
return [x.strip() for x in raw.split(",") if x.strip()]
def _parse_bool(self, key, default):
val = os.getenv(key)
if val is None:
return default
return val.lower() in ("1", "true", "yes", "on")
# ============================================================
# LOGGING
# ============================================================
log_level = logging.DEBUG if os.getenv("DEBUG", "false").lower() == "true" else logging.INFO
logging.basicConfig(level=log_level)
logging.getLogger("werkzeug").setLevel(logging.ERROR)
logger = logging.getLogger("matrix-interceptor")
def now_iso():
return datetime.now(timezone.utc).isoformat()
def log_event(event: str, **kwargs):
base = f"{now_iso()} EVENT={event}"
details = " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.info(f"{base} {details}")
# ============================================================
# INIT
# ============================================================
config = Config()
missing = config.validate()
if missing:
logger.error(f"Missing env vars: {missing}")
raise SystemExit(1)
# ============================================================
# STATE
# ============================================================
KNOWN_EXTERNAL_USERS = {}
RATE_LIMIT = defaultdict(list)
# ============================================================
# HELPERS
# ============================================================
def extract_domain(user_id):
try:
return user_id.split(":")[1].lower().rstrip(".")
except:
return "unknown"
def is_external(user_id):
return extract_domain(user_id) != config.local_domain
def is_known_user(user_id):
ts = KNOWN_EXTERNAL_USERS.get(user_id)
if not ts:
return False
if time.time() - ts > config.cache_ttl:
del KNOWN_EXTERNAL_USERS[user_id]
return False
return True
def remember_user(user_id):
KNOWN_EXTERNAL_USERS[user_id] = time.time()
def is_local_room(room_id):
try:
return room_id.split(":")[1] == config.local_domain
except:
return False
# ============================================================
# SEED
# ============================================================
def seed_known_users():
if not config.admin_token:
return
headers = {"Authorization": f"Bearer {config.admin_token}"}
seeded = 0
try:
rooms_res = requests.get(
f"{config.tuwunel_url}/_matrix/client/v3/joined_rooms",
headers=headers,
timeout=10
)
if rooms_res.status_code != 200:
return
for room_id in rooms_res.json().get("joined_rooms", []):
if not is_local_room(room_id):
continue
members_res = requests.get(
f"{config.tuwunel_url}/_matrix/client/v3/rooms/{room_id}/joined_members",
headers=headers,
timeout=10
)
if members_res.status_code != 200:
continue
members = members_res.json().get("joined", {})
for user_id in members.keys():
if is_external(user_id):
remember_user(user_id)
seeded += 1
logger.info(f"Seed refreshed: {seeded} users")
except Exception as e:
logger.error(f"Seed failed: {e}")
# ============================================================
# PERIODIC REFRESH
# ============================================================
def periodic_seed():
while True:
seed_known_users()
time.sleep(300)
# ============================================================
# FALLBACK CHECK
# ============================================================
def is_user_in_local_rooms(user_id: str) -> bool:
if not config.admin_token:
return False
try:
headers = {"Authorization": f"Bearer {config.admin_token}"}
rooms_res = requests.get(
f"{config.tuwunel_url}/_matrix/client/v3/joined_rooms",
headers=headers,
timeout=5
)
if rooms_res.status_code != 200:
return False
for room_id in rooms_res.json().get("joined_rooms", []):
if not is_local_room(room_id):
continue
members_res = requests.get(
f"{config.tuwunel_url}/_matrix/client/v3/rooms/{room_id}/joined_members",
headers=headers,
timeout=5
)
if members_res.status_code != 200:
continue
members = members_res.json().get("joined", {})
if user_id in members:
return True
except:
return False
return False
# ============================================================
# 🔥 HYBRID DM DETECTION
# ============================================================
def is_likely_dm_event(event):
content = event.get("content", {})
# 1️⃣ offizielles Flag
if content.get("is_direct") is True:
return True
# 2️⃣ Heuristik
unsigned = event.get("unsigned", {})
state = unsigned.get("invite_room_state", [])
member_events = [
e for e in state if e.get("type") == "m.room.member"
]
return len(member_events) <= 2
# ============================================================
# ROUTES
# ============================================================
@app.route("/healthz")
def health():
return {"status": "ok"}
@app.route('/_matrix/federation/v2/invite/<room_id>/<event_id>', methods=['PUT'])
def invite(room_id, event_id):
payload = request.get_json(force=True)
event = payload.get("event", {})
sender = event.get("sender", "")
if not sender:
return Response(status=400)
domain = extract_domain(sender)
# whitelist
if domain in config.domain_whitelist:
remember_user(sender)
return forward_request(
"PUT",
f"{config.tuwunel_url}/_matrix/federation/v2/invite/{room_id}/{event_id}",
request.headers,
payload
)
if config.block_external_dms and is_external(sender):
if not is_known_user(sender):
# 🔥 fallback check
if is_user_in_local_rooms(sender):
remember_user(sender)
else:
log_event(
"invite_blocked",
actor=sender,
domain=domain,
reason="unknown_external_user"
)
return Response(status=403)
remember_user(sender)
return forward_request(
"PUT",
f"{config.tuwunel_url}/_matrix/federation/v2/invite/{room_id}/{event_id}",
request.headers,
payload
)
# ============================================================
# FORWARD
# ============================================================
def forward_request(method, url, headers, body):
try:
proxy_headers = {"Content-Type": "application/json"}
if "Authorization" in headers:
proxy_headers["Authorization"] = headers["Authorization"]
res = requests.request(
method=method,
url=url,
headers=proxy_headers,
json=body,
timeout=config.http_timeout
)
return Response(res.content, res.status_code)
except Exception as e:
logger.error(f"proxy error: {e}")
return Response(status=502)
# ============================================================
# START
# ============================================================
if __name__ == '__main__':
seed_known_users()
threading.Thread(
target=periodic_seed,
daemon=True
).start()
app.run(host='0.0.0.0', port=5000)