190 lines
6.3 KiB
Python
190 lines
6.3 KiB
Python
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import require_admin_auth
|
|
from app.models.domain import Domain
|
|
from app.models.report import TLSReport
|
|
from app.services.tls_report_parser import MAX_TLS_REPORT_SIZE, TLSReportParser
|
|
from app.services.tls_report_persistence import (
|
|
TLS_REPORT_PRIVACY_CONTROLS,
|
|
save_tls_report,
|
|
summarize_tls_reports,
|
|
tls_report_to_dict,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class TLSFailureResponse(BaseModel):
|
|
result_type: str
|
|
failed_session_count: int
|
|
sending_mta_ip: Optional[str] = None
|
|
receiving_mx_hostname: Optional[str] = None
|
|
receiving_mx_helo: Optional[str] = None
|
|
receiving_ip: Optional[str] = None
|
|
failure_reason_code: Optional[str] = None
|
|
additional_information: Optional[str] = None
|
|
|
|
|
|
class TLSReportResponse(BaseModel):
|
|
id: int
|
|
report_id: str
|
|
domain: Optional[str] = None
|
|
org_name: Optional[str] = None
|
|
contact_info: Optional[str] = None
|
|
policy_domain: str
|
|
policy_type: Optional[str] = None
|
|
begin_date: Optional[str] = None
|
|
end_date: Optional[str] = None
|
|
total_successful_sessions: int
|
|
total_failure_sessions: int
|
|
processed_at: Optional[str] = None
|
|
failures: List[TLSFailureResponse] = Field(default_factory=list)
|
|
|
|
|
|
class TLSReportListResponse(BaseModel):
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
reports: List[TLSReportResponse]
|
|
privacy: Dict[str, Any]
|
|
|
|
|
|
class TLSReportUploadResponse(BaseModel):
|
|
success: bool
|
|
report_id: str
|
|
policies_created: int
|
|
policies_skipped: int
|
|
duplicate: bool = False
|
|
message: str
|
|
privacy: Dict[str, Any]
|
|
|
|
|
|
class TLSSummaryResponse(BaseModel):
|
|
domain: Optional[str] = None
|
|
days: int
|
|
totals: Dict[str, Any]
|
|
trends: List[Dict[str, Any]] = Field(default_factory=list)
|
|
top_failures: List[Dict[str, Any]] = Field(default_factory=list)
|
|
affected_domains: List[Dict[str, Any]] = Field(default_factory=list)
|
|
privacy: Dict[str, Any]
|
|
|
|
|
|
def _validate_upload(file: UploadFile, content: bytes) -> None:
|
|
filename = file.filename or ""
|
|
if not filename:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
|
|
if len(content) == 0:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")
|
|
if len(content) > MAX_TLS_REPORT_SIZE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail="File too large",
|
|
)
|
|
if not filename.lower().endswith((".json", ".json.gz", ".gzip", ".zip")):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid file type. Upload a TLS report as .json, .json.gz, or .zip.",
|
|
)
|
|
|
|
|
|
def _filtered_tls_query(db: Session, *, domain: Optional[str] = None):
|
|
query = db.query(TLSReport).options(
|
|
selectinload(TLSReport.domain), selectinload(TLSReport.failures)
|
|
)
|
|
if domain:
|
|
normalized = domain.lower().strip(".")
|
|
query = query.outerjoin(Domain).filter(
|
|
(Domain.name == normalized) | (TLSReport.policy_domain == normalized)
|
|
)
|
|
return query
|
|
|
|
|
|
@router.post("/upload", response_model=TLSReportUploadResponse)
|
|
async def upload_tls_report(
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
_auth: dict = Depends(require_admin_auth),
|
|
):
|
|
"""Upload and store an SMTP TLS Reporting aggregate."""
|
|
try:
|
|
content = await file.read()
|
|
_validate_upload(file, content)
|
|
parsed = TLSReportParser.parse_file(content, file.filename or "")
|
|
result = save_tls_report(db, parsed)
|
|
db.commit()
|
|
return TLSReportUploadResponse(
|
|
success=True,
|
|
report_id=parsed["report_id"],
|
|
policies_created=result["created"],
|
|
policies_skipped=result["skipped"],
|
|
duplicate=result["created"] == 0 and result["skipped"] > 0,
|
|
message=(
|
|
"TLS report imported."
|
|
if result["created"]
|
|
else "TLS report had already been imported."
|
|
),
|
|
privacy=TLS_REPORT_PRIVACY_CONTROLS,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(exc) or "Invalid TLS report format.",
|
|
) from exc
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
logger.error("Unexpected TLS report upload failure for %s: %s", file.filename, exc)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Error processing TLS report.",
|
|
) from exc
|
|
|
|
|
|
@router.get("", response_model=TLSReportListResponse)
|
|
async def list_tls_reports(
|
|
domain: Optional[str] = Query(default=None),
|
|
page: int = Query(default=1, ge=1),
|
|
page_size: int = Query(default=50, ge=1, le=200),
|
|
db: Session = Depends(get_db),
|
|
_auth: dict = Depends(require_admin_auth),
|
|
):
|
|
"""List stored SMTP TLS reports, newest first."""
|
|
query = _filtered_tls_query(db, domain=domain)
|
|
total = query.count()
|
|
rows = (
|
|
query.order_by(TLSReport.begin_date.desc().nullslast(), TLSReport.id.desc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
total_pages = (total + page_size - 1) // page_size if total else 0
|
|
return TLSReportListResponse(
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=total_pages,
|
|
reports=[TLSReportResponse(**tls_report_to_dict(row)) for row in rows],
|
|
privacy=TLS_REPORT_PRIVACY_CONTROLS,
|
|
)
|
|
|
|
|
|
@router.get("/summary", response_model=TLSSummaryResponse)
|
|
async def tls_report_summary(
|
|
domain: Optional[str] = Query(default=None),
|
|
days: int = Query(default=30, ge=1, le=365),
|
|
limit: int = Query(default=10, ge=1, le=50),
|
|
db: Session = Depends(get_db),
|
|
_auth: dict = Depends(require_admin_auth),
|
|
):
|
|
"""Summarize TLS reports into trends and top failure causes."""
|
|
return TLSSummaryResponse(**summarize_tls_reports(db, domain=domain, days=days, limit=limit))
|