Files
connettore_metadataexporter/main.py
T
2026-09-22 15:51:56 +02:00

447 lines
15 KiB
Python

#!/usr/bin/env python3
"""
connettore_metadataexporter
HTTP receiver for Rspamd metadata_exporter, compatibile con il database rqwatch.
Endpoints:
POST /api/metadata_importer_multipart — formatter = "multipart" (raccomandata)
POST /api/metadata_importer — formatter = "default" + meta_headers
"""
import email as _email
import email.header
import email.policy
import json
import logging
import os
import re
import secrets
import uuid
from datetime import date
from pathlib import Path
from typing import Optional
import pymysql
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import PlainTextResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pymysql.cursors import DictCursor
load_dotenv()
# ─── Configurazione ───────────────────────────────────────────────────────────
API_USER: str = os.getenv("RSPAMD_API_USER", "rspamd")
API_PASS: str = os.getenv("RSPAMD_API_PASS", "")
API_ACL: set[str] = {ip.strip() for ip in os.getenv("RSPAMD_API_ACL", "127.0.0.1").split(",")}
QUARANTINE_DIR: str = os.getenv("QUARANTINE_DIR", "/quarantine")
SERVER_ALIAS: str = os.getenv("MY_API_SERVER_ALIAS", "mx1")
DB_HOST: str = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
DB_NAME: str = os.getenv("DB_NAME", "rqwatch")
DB_USER: str = os.getenv("DB_USER", "rqwatch")
DB_PASS: str = os.getenv("DB_PASS", "")
MAILLOGS_TABLE: str = os.getenv("MAILLOGS_TABLE", "mail_logs")
MAIL_RECIPIENTS_TABLE: str = os.getenv("MAIL_RECIPIENTS_TABLE", "mail_log_recipients")
_store_flags: dict[str, str] = {
"no action": os.getenv("STORE_NO_ACTION", "false"),
"add header": os.getenv("STORE_ADD_HEADER", "true"),
"rewrite subject": os.getenv("STORE_REWRITE_SUBJECT", "true"),
"greylist": os.getenv("STORE_GREYLIST", "false"),
"discard": os.getenv("STORE_DISCARD", "true"),
"reject": os.getenv("STORE_REJECT", "true"),
}
STORE_ACTIONS: set[str] = {
k for k, v in _store_flags.items()
if v.strip().lower() in ("1", "true", "yes")
}
# Limiti di campo compatibili con rqwatch MailLog::FIELD_LIMITS
FIELD_LIMITS: dict[str, int] = {
"qid": 30, "server": 10, "subject": 1024, "action": 20,
"ip": 50, "mail_from": 255, "mime_from": 255,
"rcpt_to": 1024, "mime_to": 1024, "mail_location": 255, "message_id": 1024,
}
# ─── Logging ──────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [connettore] %(message)s",
)
log = logging.getLogger("connettore")
# ─── App ──────────────────────────────────────────────────────────────────────
app = FastAPI(docs_url=None, redoc_url=None)
_security = HTTPBasic()
# ─── Helper DB ────────────────────────────────────────────────────────────────
def _db() -> pymysql.Connection:
return pymysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
database=DB_NAME, charset="utf8mb4", cursorclass=DictCursor,
autocommit=False,
)
def _insert(data: dict, recipients: list[str]) -> int:
conn = _db()
try:
with conn.cursor() as cur:
cols = ", ".join(f"`{k}`" for k in data)
ph = ", ".join(["%s"] * len(data))
cur.execute(
f"INSERT INTO `{MAILLOGS_TABLE}` ({cols}) VALUES ({ph})",
list(data.values()),
)
db_id: int = cur.lastrowid
if db_id and recipients:
unique = list({r.lower().strip() for r in recipients if r.strip()})
cur.executemany(
f"INSERT INTO `{MAIL_RECIPIENTS_TABLE}` "
"(mail_log_id, recipient_email) VALUES (%s, %s)",
[(db_id, r) for r in unique],
)
conn.commit()
return db_id
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ─── Helper email ─────────────────────────────────────────────────────────────
def _decode_header(value: str) -> str:
try:
parts = _email.header.decode_header(value)
out = []
for raw, charset in parts:
if isinstance(raw, bytes):
out.append(raw.decode(charset or "utf-8", errors="replace"))
else:
out.append(raw)
return "".join(out)
except Exception:
return value
def _parse_mime(raw: bytes) -> dict:
try:
msg = _email.message_from_bytes(raw, policy=_email.policy.compat32)
def hdr(name: str) -> str:
v = msg.get(name, "")
return _decode_header(str(v)) if v else ""
raw_lines = [f"{k}: {v}" for k, v in msg.items()]
headers_raw = "\r\n".join(raw_lines).encode("utf-8", "ignore").decode("utf-8")
return {
"mime_from": hdr("From"),
"mime_to": hdr("To")[:1024],
"mime_subject": hdr("Subject"),
"message_id": hdr("Message-ID"),
"headers_raw": headers_raw,
}
except Exception as exc:
log.warning(f"MIME parse error: {exc}")
return {
"mime_from": "", "mime_to": "", "mime_subject": "",
"message_id": "", "headers_raw": "",
}
# ─── Helper quarantena ────────────────────────────────────────────────────────
def _store_email(qid: str, raw: bytes) -> Optional[str]:
q = Path(QUARANTINE_DIR)
if not q.is_dir() or not os.access(str(q), os.W_OK):
log.error(f"Quarantine dir non accessibile: {QUARANTINE_DIR}")
return None
today = date.today().isoformat()
safe = qid if re.match(r"^[a-zA-Z0-9]+$", qid) else ""
subdir = safe if safe and safe != "unknown" else f"unknown/{uuid.uuid4().hex}"
mail_dir = q / today / subdir
mail_dir.mkdir(parents=True, exist_ok=True)
dest = mail_dir / "mail.eml"
try:
dest.write_bytes(raw)
return str(dest)
except Exception as exc:
log.error(f"Scrittura fallita {dest}: {exc}")
return None
def _has_virus(symbols) -> bool:
if isinstance(symbols, dict):
return any(
isinstance(v, dict) and v.get("group") == "antivirus"
for v in symbols.values()
)
if isinstance(symbols, list):
return any(
isinstance(s, dict) and s.get("group") == "antivirus"
for s in symbols
)
return False
def _trim_fields(data: dict) -> dict:
for field, limit in FIELD_LIMITS.items():
v = data.get(field)
if isinstance(v, str) and len(v) > limit:
log.warning(f"Campo '{field}' troncato a {limit} caratteri")
data[field] = v[:limit]
return data
def _sanitize_server(s: str) -> str:
return re.sub(r"[^a-zA-Z0-9.\-]", "", s)[:10]
# ─── Logica principale ────────────────────────────────────────────────────────
def _process(
*,
qid: str,
server: str,
subject: str,
score: float,
action: str,
symbols_json: str,
fuzzy_json: str,
ip: str,
mail_from: str,
rcpt_list: list[str],
size: int,
raw_email: bytes,
) -> int:
try:
symbols = json.loads(symbols_json)
except Exception:
symbols = {}
virus = _has_virus(symbols)
mail_stored, mail_location = 0, None
if action in STORE_ACTIONS or virus:
mail_location = _store_email(qid, raw_email)
if mail_location:
mail_stored = 1
log.info(f"{qid} in quarantena: {mail_location}")
else:
log.error(f"{qid} salvataggio quarantena fallito")
if not mail_from:
mail_from = "empty-mail-from@localhost"
mime = _parse_mime(raw_email)
data = {
"qid": qid,
"server": _sanitize_server(server),
"subject": mime["mime_subject"] or subject or "",
"score": round(score, 2),
"action": action,
"symbols": symbols_json or "[]",
"has_virus": 1 if virus else 0,
"fuzzy_hashes": fuzzy_json or "[]",
"ip": ip or "",
"mail_from": (mail_from or "").lower(),
"mime_from": mime["mime_from"],
"rcpt_to": "unknown" if not rcpt_list else ", ".join(r.lower() for r in rcpt_list),
"mime_to": mime["mime_to"],
"mail_stored": mail_stored,
"mail_location": mail_location,
"size": size,
"headers": mime["headers_raw"],
"message_id": mime["message_id"],
}
data = _trim_fields(data)
return _insert(data, rcpt_list)
# ─── Dipendenza: ACL + autenticazione ─────────────────────────────────────────
async def _auth(
request: Request,
credentials: HTTPBasicCredentials = Depends(_security),
) -> None:
client_ip = request.client.host if request.client else ""
if client_ip not in API_ACL:
log.warning(f"Richiesta da {client_ip} rifiutata (non in RSPAMD_API_ACL)")
raise HTTPException(status_code=403, detail="Forbidden")
ok = (
secrets.compare_digest(credentials.username.encode(), API_USER.encode())
and secrets.compare_digest(credentials.password.encode(), API_PASS.encode())
)
if not ok:
raise HTTPException(
status_code=401,
detail="Unauthorized",
headers={"WWW-Authenticate": 'Basic realm="rqwatch-api"'},
)
# ─── Endpoint: multipart/form-data (formatter = "multipart") ─────────────────
@app.post("/api/metadata_importer_multipart", response_class=PlainTextResponse)
async def metadata_importer_multipart(
request: Request,
metadata: str = Form(...),
message: UploadFile = File(...),
_: None = Depends(_auth),
) -> str:
try:
meta: dict = json.loads(metadata)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"metadata JSON non valido: {exc}")
raw_email = await message.read()
if not raw_email:
raise HTTPException(status_code=400, detail="File message vuoto")
qid = str(meta.get("qid") or "unknown")
if not re.match(r"^[a-zA-Z0-9]+$", qid):
qid = "unknown"
score = float(meta.get("score") or 0.0)
action = str(meta.get("action") or "")
server = request.query_params.get("server", SERVER_ALIAS)
if not qid and not score and not action:
raise HTTPException(status_code=400, detail="qid, score e action mancanti")
rcpt = meta.get("rcpt", [])
if isinstance(rcpt, str) and rcpt not in ("", "unknown"):
rcpt = [rcpt]
elif not isinstance(rcpt, list):
rcpt = []
rcpt = [r.lower().strip() for r in rcpt if isinstance(r, str) and r.strip()]
fuzzy = meta.get("fuzzy")
if isinstance(fuzzy, list):
fuzzy_json = json.dumps(fuzzy, ensure_ascii=False)
elif fuzzy in (None, "", "unknown"):
fuzzy_json = "[]"
else:
fuzzy_json = str(fuzzy)
symbols = meta.get("symbols", {})
if isinstance(symbols, (dict, list)):
symbols_json = json.dumps(symbols, ensure_ascii=False)
else:
symbols_json = str(symbols) if symbols else "[]"
try:
db_id = _process(
qid=qid,
server=server,
subject=str(meta.get("subject") or ""),
score=score,
action=action,
symbols_json=symbols_json,
fuzzy_json=fuzzy_json,
ip=str(meta.get("ip") or ""),
mail_from=str(meta.get("from") or ""),
rcpt_list=rcpt,
size=int(meta.get("size") or 0),
raw_email=raw_email,
)
except Exception as exc:
log.error(f"{qid} errore DB: {exc}")
raise HTTPException(status_code=500, detail="Errore database")
log.info(f"{qid} score:{score:.2f} action:'{action}' salvato [id:{db_id}]")
return "Message saved"
# ─── Endpoint: raw body + X-Rspamd-* headers (formatter = "default") ─────────
@app.post("/api/metadata_importer", response_class=PlainTextResponse)
async def metadata_importer(
request: Request,
_: None = Depends(_auth),
) -> str:
raw_email = await request.body()
if not raw_email:
raise HTTPException(status_code=400, detail="Body vuoto")
h = request.headers
qid = h.get("x-rspamd-qid", "unknown")
if qid and not re.match(r"^[a-zA-Z0-9]+$", qid):
qid = "unknown"
action = h.get("x-rspamd-action", "")
server = request.query_params.get("server", SERVER_ALIAS)
try:
score = float(h.get("x-rspamd-score") or "0")
except ValueError:
score = 0.0
try:
size = int(h.get("x-rspamd-size") or "0")
except ValueError:
size = 0
symbols_raw = h.get("x-rspamd-symbols", "[]")
fuzzy_raw = h.get("x-rspamd-fuzzy", "[]")
fuzzy_json = "[]" if fuzzy_raw in ("unknown", "", None) else fuzzy_raw
rcpt_raw = h.get("x-rspamd-rcpt", "[]")
try:
rcpt = json.loads(rcpt_raw) if rcpt_raw not in ("", "unknown") else []
if not isinstance(rcpt, list):
rcpt = [str(rcpt)] if rcpt else []
except Exception:
rcpt = []
rcpt = [r.lower().strip() for r in rcpt if isinstance(r, str) and r.strip()]
try:
db_id = _process(
qid=qid,
server=server,
subject=h.get("x-rspamd-subject", ""),
score=score,
action=action,
symbols_json=symbols_raw or "[]",
fuzzy_json=fuzzy_json,
ip=h.get("x-rspamd-ip", ""),
mail_from=h.get("x-rspamd-from", ""),
rcpt_list=rcpt,
size=size,
raw_email=raw_email,
)
except Exception as exc:
log.error(f"{qid} errore DB: {exc}")
raise HTTPException(status_code=500, detail="Errore database")
log.info(f"{qid} score:{score:.2f} action:'{action}' salvato [id:{db_id}]")
return "Message saved"
# ─── Entry point ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=os.getenv("LISTEN_HOST", "127.0.0.1"),
port=int(os.getenv("LISTEN_PORT", "8080")),
reload=False,
)