Initial Source code version
This commit is contained in:
parent
90f5f38812
commit
d979374cbc
23 changed files with 2628 additions and 0 deletions
53
README.md
Normal file
53
README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Task Queue System – v0.3.0
|
||||||
|
|
||||||
|
**FastAPI + PostgreSQL + RabbitMQ** mit produktionsreifen Mail-Prüfungen:
|
||||||
|
- MXValidation, SPFValidation, **TLSCheck**, **CertValidity**, **MTASTSCheck**, **TLSRPTCheck**
|
||||||
|
- **Task-Abhängigkeiten**: z. B. TLS & CertValidity warten auf MXValidation
|
||||||
|
- TLS-Checks mit STARTTLS (Port 25), TLS-Version/Cipher, Zertifikatsdaten & Gültigkeit
|
||||||
|
- MTA-STS (Policy-Datei + TXT-Id) und TLS-RPT (rua)
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
export DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/tasks"
|
||||||
|
export RABBITMQ_URL="amqp://guest:guest@localhost/"
|
||||||
|
export RABBITMQ_EXCHANGE="tasks"
|
||||||
|
export MAX_RETRIES=3
|
||||||
|
export DNS_TIMEOUT=3.0; export DNS_LIFETIME=5.0; export SMTP_TIMEOUT=10.0
|
||||||
|
|
||||||
|
# API
|
||||||
|
uvicorn app.api:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
# Worker
|
||||||
|
python -m app.workers.mail_mx_worker
|
||||||
|
python -m app.workers.mail_spf_worker
|
||||||
|
python -m app.workers.mail_tls_worker
|
||||||
|
python -m app.workers.mail_cert_worker
|
||||||
|
python -m app.workers.mail_mtasts_worker
|
||||||
|
python -m app.workers.mail_tlsrpt_worker
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
python -m app.cli create-job MailCheck --domain example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tasks & Dependencies
|
||||||
|
```
|
||||||
|
MailCheck
|
||||||
|
├─ MXValidation (no deps)
|
||||||
|
├─ SPFValidation (no deps)
|
||||||
|
├─ TLSCheck (depends on: MXValidation)
|
||||||
|
├─ CertValidity (depends on: MXValidation)
|
||||||
|
├─ MTASTSCheck (no deps)
|
||||||
|
└─ TLSRPTCheck (no deps)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `POST /jobs` → `{ job_type: "MailCheck", domain: "example.com" }`
|
||||||
|
- `GET /jobs/{job_id}`
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
- Für Produktion: Migrationen mit Alembic.
|
||||||
|
- MTA-STS-Datei: `https://mta-sts.<domain>/.well-known/mta-sts.txt`
|
||||||
|
- TLS nur Port 25 (STARTTLS). 465/587 nicht erforderlich laut Anforderung.
|
||||||
|
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
69
app/api.py
Normal file
69
app/api.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
from fastapi import FastAPI, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.db import Base, engine, SessionLocal
|
||||||
|
from app.schemas import CreateJobRequest, JobInfo, TaskInfo
|
||||||
|
from app.services import create_job, get_job_with_tasks
|
||||||
|
from app.models import Job, Task
|
||||||
|
from typing import List
|
||||||
|
from uuid import UUID
|
||||||
|
import re, idna
|
||||||
|
|
||||||
|
app = FastAPI(title="Task Queue API", version="0.3.0")
|
||||||
|
|
||||||
|
async def get_session() -> AsyncSession:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def on_startup():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z0-9-]{1,63})+$")
|
||||||
|
|
||||||
|
def _validate_domain(domain: str) -> str:
|
||||||
|
try:
|
||||||
|
_ = idna.encode(domain).decode()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid domain (IDNA)")
|
||||||
|
if not DOMAIN_RE.match(domain):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid domain format")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
@app.post("/jobs", response_model=JobInfo)
|
||||||
|
async def create_job_endpoint(req: CreateJobRequest, session: AsyncSession = Depends(get_session)):
|
||||||
|
if not req.job_type:
|
||||||
|
raise HTTPException(status_code=400, detail="job_type is required")
|
||||||
|
domain = _validate_domain(req.domain.strip().lower())
|
||||||
|
job = await create_job(session, req.job_type, domain, req.payload)
|
||||||
|
job = await get_job_with_tasks(session, job.id)
|
||||||
|
return _job_to_schema(job)
|
||||||
|
|
||||||
|
@app.get("/jobs/{job_id}", response_model=JobInfo)
|
||||||
|
async def get_job_endpoint(job_id: UUID, session: AsyncSession = Depends(get_session)):
|
||||||
|
job = await get_job_with_tasks(session, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return _job_to_schema(job)
|
||||||
|
|
||||||
|
def _job_to_schema(job: Job) -> JobInfo:
|
||||||
|
tasks: List[TaskInfo] = [
|
||||||
|
TaskInfo(
|
||||||
|
id=t.id,
|
||||||
|
name=t.name,
|
||||||
|
status=t.status.value if hasattr(t.status, "value") else str(t.status),
|
||||||
|
retries=t.retries,
|
||||||
|
max_retries=t.max_retries,
|
||||||
|
result=t.result,
|
||||||
|
error=t.error,
|
||||||
|
)
|
||||||
|
for t in job.tasks
|
||||||
|
]
|
||||||
|
return JobInfo(
|
||||||
|
id=job.id,
|
||||||
|
job_type=job.job_type,
|
||||||
|
status=job.status.value if hasattr(job.status, "value") else str(job.status),
|
||||||
|
domain=job.domain,
|
||||||
|
payload=job.payload,
|
||||||
|
tasks=tasks,
|
||||||
|
)
|
||||||
83
app/cli.py
Normal file
83
app/cli.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
|
||||||
|
# app/cli.py
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
DEFAULT_API_URL = os.environ.get("TASK_QUEUE_API_URL", "http://127.0.0.1:8000")
|
||||||
|
|
||||||
|
def cmd_create_job(args: argparse.Namespace) -> int:
|
||||||
|
"""
|
||||||
|
Legt einen neuen Job an (MailCheck) und gibt Status/Tasks aus.
|
||||||
|
"""
|
||||||
|
payload = {} # ggf. später erweitern
|
||||||
|
body = {"job_type": args.job_type, "domain": args.domain, "payload": payload}
|
||||||
|
try:
|
||||||
|
with httpx.Client(base_url=args.api_url, timeout=15) as client:
|
||||||
|
resp = client.post("/jobs", json=body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
job = resp.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
print(f"[ERROR] API-Request fehlgeschlagen: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Job {job['id']} ({job['job_type']}) Domain: {job['domain']} Status: {job['status']}")
|
||||||
|
for t in job.get("tasks", []):
|
||||||
|
print(f"- Task {t['name']}: {t['status']} (id={t['id']})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def cmd_get_job(args: argparse.Namespace) -> int:
|
||||||
|
"""
|
||||||
|
Holt einen bestehenden Job und zeigt Status/Tasks.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with httpx.Client(base_url=args.api_url, timeout=15) as client:
|
||||||
|
resp = client.get(f"/jobs/{args.job_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
job = resp.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
print(f"[ERROR] API-Request fehlgeschlagen: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(json.dumps(job, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="tq",
|
||||||
|
description="Task-Queue CLI (FastAPI + PostgreSQL + RabbitMQ) – RHEL-freundlich mit argparse",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-url", default=DEFAULT_API_URL,
|
||||||
|
help=f"Basis-URL der API (Default: {DEFAULT_API_URL})"
|
||||||
|
)
|
||||||
|
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
# create-job
|
||||||
|
p_create = sub.add_parser("create-job", help="Neuen Job anlegen")
|
||||||
|
p_create.add_argument("job_type", help="JobType, z. B. MailCheck")
|
||||||
|
p_create.add_argument("--domain", "-d", required=True, help="Domain für den Job (z. B. example.com)")
|
||||||
|
p_create.set_defaults(func=cmd_create_job)
|
||||||
|
|
||||||
|
# get-job
|
||||||
|
p_get = sub.add_parser("get-job", help="Job-Status abrufen")
|
||||||
|
p_get.add_argument("job_id", help="Job-ID (UUID)")
|
||||||
|
p_get.set_defaults(func=cmd_get_job)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
# Subcommand dispatch
|
||||||
|
if hasattr(args, "func"):
|
||||||
|
return args.func(args)
|
||||||
|
parser.print_help()
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
15
app/config.py
Normal file
15
app/config.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import os
|
||||||
|
|
||||||
|
class Settings(BaseModel):
|
||||||
|
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/tasks")
|
||||||
|
RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://guest:guest@localhost/")
|
||||||
|
RABBITMQ_EXCHANGE: str = os.getenv("RABBITMQ_EXCHANGE", "tasks")
|
||||||
|
RABBITMQ_EXCHANGE_TYPE: str = "topic"
|
||||||
|
MAX_RETRIES: int = int(os.getenv("MAX_RETRIES", "3"))
|
||||||
|
DNS_TIMEOUT: float = float(os.getenv("DNS_TIMEOUT", "3.0"))
|
||||||
|
DNS_LIFETIME: float = float(os.getenv("DNS_LIFETIME", "5.0"))
|
||||||
|
SMTP_TIMEOUT: float = float(os.getenv("SMTP_TIMEOUT", "10.0"))
|
||||||
|
HTTP_TIMEOUT: float = float(os.getenv("HTTP_TIMEOUT", "8.0"))
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
9
app/db.py
Normal file
9
app/db.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=False, future=True)
|
||||||
|
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
22
app/job_registry.py
Normal file
22
app/job_registry.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
# Directed Acyclic Graph (DAG): task -> [dependencies]
|
||||||
|
JOB_GRAPH: Dict[str, Dict[str, List[str]]] = {
|
||||||
|
"MailCheck": {
|
||||||
|
"MXValidation": [],
|
||||||
|
"SPFValidation": [],
|
||||||
|
"TLSCheck": ["MXValidation"],
|
||||||
|
"CertValidity": ["MXValidation"],
|
||||||
|
"MTASTSCheck": [],
|
||||||
|
"TLSRPTCheck": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def routing_key(job_type: str, task_name: str) -> str:
|
||||||
|
return f"{job_type}.{task_name}"
|
||||||
|
|
||||||
|
def list_tasks_for_job(job_type: str) -> List[str]:
|
||||||
|
return list(JOB_GRAPH.get(job_type, {}).keys())
|
||||||
|
|
||||||
|
def list_dependencies(job_type: str, task_name: str) -> List[str]:
|
||||||
|
return JOB_GRAPH.get(job_type, {}).get(task_name, [])
|
||||||
54
app/models.py
Normal file
54
app/models.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import enum, uuid
|
||||||
|
from sqlalchemy import String, Enum, ForeignKey, Integer, DateTime, JSON
|
||||||
|
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
class JobStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
class TaskStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
class Job(Base):
|
||||||
|
__tablename__ = "jobs"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
job_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.PENDING, index=True)
|
||||||
|
domain: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
tasks: Mapped[list["Task"]] = relationship("Task", back_populates="job", cascade="all, delete-orphan", lazy="selectin")
|
||||||
|
|
||||||
|
class Task(Base):
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="CASCADE"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), default=TaskStatus.PENDING, index=True)
|
||||||
|
routing_key: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
retries: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
max_retries: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
worker_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
started_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
job: Mapped["Job"] = relationship("Job", back_populates="tasks")
|
||||||
|
|
||||||
|
class TaskDependency(Base):
|
||||||
|
__tablename__ = "task_dependencies"
|
||||||
|
task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
depends_on_task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True)
|
||||||
27
app/rabbitmq.py
Normal file
27
app/rabbitmq.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import json
|
||||||
|
import aio_pika
|
||||||
|
from typing import Any
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
async def get_connection():
|
||||||
|
return await aio_pika.connect_robust(settings.RABBITMQ_URL)
|
||||||
|
|
||||||
|
async def publish_task_message(routing_key: str, message: dict[str, Any]):
|
||||||
|
connection = await get_connection()
|
||||||
|
async with connection:
|
||||||
|
channel = await connection.channel()
|
||||||
|
exchange = await channel.declare_exchange(
|
||||||
|
settings.RABBITMQ_EXCHANGE,
|
||||||
|
aio_pika.ExchangeType.TOPIC,
|
||||||
|
durable=True,
|
||||||
|
)
|
||||||
|
body = json.dumps(message).encode("utf-8")
|
||||||
|
await exchange.publish(
|
||||||
|
aio_pika.Message(
|
||||||
|
body=body,
|
||||||
|
content_type="application/json",
|
||||||
|
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||||
|
correlation_id=str(message.get("task_id")),
|
||||||
|
),
|
||||||
|
routing_key=routing_key,
|
||||||
|
)
|
||||||
25
app/schemas.py
Normal file
25
app/schemas.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
class CreateJobRequest(BaseModel):
|
||||||
|
job_type: str = Field(examples=["MailCheck"])
|
||||||
|
domain: str = Field(examples=["example.com"])
|
||||||
|
payload: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
class TaskInfo(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
retries: int
|
||||||
|
max_retries: int
|
||||||
|
result: Optional[dict] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
class JobInfo(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
job_type: str
|
||||||
|
status: str
|
||||||
|
domain: str
|
||||||
|
payload: dict | None
|
||||||
|
tasks: List[TaskInfo]
|
||||||
110
app/services.py
Normal file
110
app/services.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy import select, update, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from datetime import datetime
|
||||||
|
from app.models import Job, Task, TaskStatus, JobStatus, TaskDependency
|
||||||
|
from app.job_registry import list_tasks_for_job, list_dependencies, routing_key
|
||||||
|
from app.rabbitmq import publish_task_message
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
async def create_job(session: AsyncSession, job_type: str, domain: str, payload: dict) -> Job:
|
||||||
|
job = Job(job_type=job_type, domain=domain, payload=payload, status=JobStatus.PENDING)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# 1) Tasks anlegen
|
||||||
|
name_to_task: dict[str, Task] = {}
|
||||||
|
for name in list_tasks_for_job(job_type):
|
||||||
|
t = Task(
|
||||||
|
job_id=job.id,
|
||||||
|
name=name,
|
||||||
|
status=TaskStatus.PENDING,
|
||||||
|
routing_key=routing_key(job_type, name),
|
||||||
|
max_retries=settings.MAX_RETRIES,
|
||||||
|
)
|
||||||
|
session.add(t)
|
||||||
|
await session.flush()
|
||||||
|
name_to_task[name] = t
|
||||||
|
|
||||||
|
# 2) Dependencies anlegen
|
||||||
|
for name, task in name_to_task.items():
|
||||||
|
for dep_name in list_dependencies(job_type, name):
|
||||||
|
dep_task = name_to_task[dep_name]
|
||||||
|
session.add(TaskDependency(task_id=task.id, depends_on_task_id=dep_task.id))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# 3) Nur Tasks ohne Dependencies veröffentlichen
|
||||||
|
for name, task in name_to_task.items():
|
||||||
|
deps = list_dependencies(job_type, name)
|
||||||
|
if not deps:
|
||||||
|
await _publish_task(session, job, task)
|
||||||
|
|
||||||
|
await refresh_job_status(session, job.id)
|
||||||
|
await session.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def _publish_task(session: AsyncSession, job: Job, task: Task):
|
||||||
|
await publish_task_message(
|
||||||
|
routing_key=task.routing_key,
|
||||||
|
message={
|
||||||
|
"job_id": str(job.id),
|
||||||
|
"task_id": str(task.id),
|
||||||
|
"task_name": task.name,
|
||||||
|
"job_type": job.job_type,
|
||||||
|
"payload": {"domain": job.domain, **(job.payload or {})},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.execute(update(Task).where(Task.id == task.id).values(status=TaskStatus.QUEUED))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def release_dependent_tasks(session: AsyncSession, completed_task_id: UUID):
|
||||||
|
# Finde Tasks, die von completed_task_id abhängen
|
||||||
|
res = await session.execute(select(TaskDependency.task_id).where(TaskDependency.depends_on_task_id == completed_task_id))
|
||||||
|
candidate_ids = [row[0] for row in res.all()]
|
||||||
|
if not candidate_ids:
|
||||||
|
return
|
||||||
|
# Für jeden Kandidaten prüfen, ob ALLE Dependencies SUCCESS sind
|
||||||
|
for task_id in candidate_ids:
|
||||||
|
# task + job laden
|
||||||
|
res_task = await session.execute(select(Task, Job).join(Job, Task.job_id == Job.id).where(Task.id == task_id))
|
||||||
|
row = res_task.first()
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
task, job = row
|
||||||
|
# Alle deps status
|
||||||
|
res_deps = await session.execute(
|
||||||
|
select(func.count()).select_from(TaskDependency)
|
||||||
|
.join(Task, TaskDependency.depends_on_task_id == Task.id)
|
||||||
|
.where(TaskDependency.task_id == task.id, Task.status != TaskStatus.SUCCESS)
|
||||||
|
)
|
||||||
|
remaining = res_deps.scalar_one()
|
||||||
|
if remaining == 0 and task.status == TaskStatus.PENDING:
|
||||||
|
await _publish_task(session, job, task)
|
||||||
|
|
||||||
|
async def get_job(session: AsyncSession, job_id: UUID) -> Job | None:
|
||||||
|
res = await session.execute(select(Job).where(Job.id == job_id))
|
||||||
|
return res.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_job_with_tasks(session: AsyncSession, job_id: UUID) -> Job | None:
|
||||||
|
return await get_job(session, job_id)
|
||||||
|
|
||||||
|
async def refresh_job_status(session: AsyncSession, job_id: UUID) -> None:
|
||||||
|
res = await session.execute(select(Task).where(Task.job_id == job_id))
|
||||||
|
tasks = res.scalars().all()
|
||||||
|
if not tasks:
|
||||||
|
await session.execute(update(Job).where(Job.id == job_id).values(status=JobStatus.PENDING))
|
||||||
|
return
|
||||||
|
if all(t.status == TaskStatus.SUCCESS for t in tasks):
|
||||||
|
new_status = JobStatus.SUCCESS
|
||||||
|
elif any(t.status == TaskStatus.FAILED for t in tasks) and not any(
|
||||||
|
t.status in (TaskStatus.RUNNING, TaskStatus.QUEUED, TaskStatus.PENDING) for t in tasks
|
||||||
|
):
|
||||||
|
new_status = JobStatus.FAILED
|
||||||
|
elif any(t.status == TaskStatus.RUNNING for t in tasks):
|
||||||
|
new_status = JobStatus.RUNNING
|
||||||
|
elif any(t.status == TaskStatus.QUEUED for t in tasks):
|
||||||
|
new_status = JobStatus.QUEUED
|
||||||
|
else:
|
||||||
|
new_status = JobStatus.PENDING
|
||||||
|
await session.execute(update(Job).where(Job.id == job_id).values(status=new_status, updated_at=datetime.utcnow()))
|
||||||
0
app/workers/__init__.py
Normal file
0
app/workers/__init__.py
Normal file
95
app/workers/base_worker.py
Normal file
95
app/workers/base_worker.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
import aio_pika
|
||||||
|
from sqlalchemy import update, select
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import Task, TaskStatus
|
||||||
|
from app.services import refresh_job_status, release_dependent_tasks
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
class BaseWorker:
|
||||||
|
def __init__(self, routing_key: str, worker_id: str):
|
||||||
|
self.routing_key = routing_key
|
||||||
|
self.worker_id = worker_id
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
connection = await aio_pika.connect_robust(settings.RABBITMQ_URL)
|
||||||
|
async with connection:
|
||||||
|
channel = await connection.channel()
|
||||||
|
await channel.set_qos(prefetch_count=1)
|
||||||
|
exchange = await channel.declare_exchange(
|
||||||
|
settings.RABBITMQ_EXCHANGE, aio_pika.ExchangeType.TOPIC, durable=True
|
||||||
|
)
|
||||||
|
queue = await channel.declare_queue(
|
||||||
|
name=f"q.{self.routing_key}",
|
||||||
|
durable=True,
|
||||||
|
arguments={
|
||||||
|
"x-dead-letter-exchange": f"{settings.RABBITMQ_EXCHANGE}.dlx"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await queue.bind(exchange, routing_key=self.routing_key)
|
||||||
|
print(f"[{self.worker_id}] listening on {self.routing_key} ...")
|
||||||
|
await queue.consume(self._on_message, no_ack=False)
|
||||||
|
await asyncio.Future()
|
||||||
|
|
||||||
|
async def _on_message(self, message: aio_pika.IncomingMessage):
|
||||||
|
async with message.process(requeue=False):
|
||||||
|
data = json.loads(message.body.decode("utf-8"))
|
||||||
|
task_id = UUID(data["task_id"])
|
||||||
|
job_id = UUID(data["job_id"])
|
||||||
|
payload = data.get("payload", {})
|
||||||
|
try:
|
||||||
|
await self._set_status(task_id, TaskStatus.RUNNING, started_at=datetime.utcnow())
|
||||||
|
result = await self.process(payload, job_id=job_id, task_id=task_id)
|
||||||
|
await self._set_status(task_id, TaskStatus.SUCCESS, result=result, finished_at=datetime.utcnow())
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await release_dependent_tasks(session, task_id)
|
||||||
|
await refresh_job_status(session, job_id)
|
||||||
|
await session.commit()
|
||||||
|
print(f"[{self.worker_id}] SUCCESS {task_id}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{self.worker_id}] ERROR: {e}")
|
||||||
|
await self._handle_failure(message, str(e), job_id)
|
||||||
|
|
||||||
|
async def _handle_failure(self, message: aio_pika.IncomingMessage, err: str, job_id):
|
||||||
|
data = json.loads(message.body.decode("utf-8"))
|
||||||
|
task_id = UUID(data["task_id"])
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
res = await session.execute(select(Task).where(Task.id == task_id))
|
||||||
|
task = res.scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
new_retries = (task.retries or 0) + 1
|
||||||
|
if new_retries <= (task.max_retries or settings.MAX_RETRIES):
|
||||||
|
await session.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(Task.id == task_id)
|
||||||
|
.values(status=TaskStatus.QUEUED, retries=new_retries, error=f"Retry {new_retries}: {err}")
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await asyncio.sleep(min(2 ** new_retries, 30))
|
||||||
|
connection = await aio_pika.connect_robust(settings.RABBITMQ_URL)
|
||||||
|
async with connection:
|
||||||
|
ch = await connection.channel()
|
||||||
|
ex = await ch.declare_exchange(settings.RABBITMQ_EXCHANGE, aio_pika.ExchangeType.TOPIC, durable=True)
|
||||||
|
await ex.publish(aio_pika.Message(body=message.body), routing_key=message.routing_key)
|
||||||
|
else:
|
||||||
|
await session.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(Task.id == task_id)
|
||||||
|
.values(status=TaskStatus.FAILED, error=f"Max retries exceeded: {err}", finished_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await refresh_job_status(session, job_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _set_status(self, task_id: UUID, status: TaskStatus, **extra):
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(
|
||||||
|
update(Task).where(Task.id == task_id).values(status=status, worker_id=self.worker_id, **extra)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def process(self, payload: dict, **meta) -> dict:
|
||||||
|
raise NotImplementedError
|
||||||
70
app/workers/mail_cert_worker.py
Normal file
70
app/workers/mail_cert_worker.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import Task
|
||||||
|
from app.workers.base_worker import BaseWorker
|
||||||
|
from app.job_registry import routing_key
|
||||||
|
from app.workers.utils import resolve_mx, smtp_starttls_probe, parse_cert_time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
class CertValidityWorker(BaseWorker):
|
||||||
|
async def process(self, payload: dict, **meta) -> dict:
|
||||||
|
domain = payload.get("domain")
|
||||||
|
job_id = meta.get('job_id')
|
||||||
|
if not domain:
|
||||||
|
raise ValueError("payload.domain is required")
|
||||||
|
# Hol MX aus MXValidation
|
||||||
|
mx_hosts: List[str] = []
|
||||||
|
if job_id:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
res = await session.execute(select(Task).where(Task.job_id == job_id, Task.name == 'MXValidation'))
|
||||||
|
mx_task = res.scalar_one_or_none()
|
||||||
|
if mx_task and mx_task.result and 'mx_records' in mx_task.result:
|
||||||
|
mx_hosts = [r['exchange'] for r in mx_task.result.get('mx_records', [])]
|
||||||
|
if not mx_hosts:
|
||||||
|
mx = await resolve_mx(domain)
|
||||||
|
mx_hosts = [m.exchange for m in mx]
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for host in mx_hosts:
|
||||||
|
probe = await smtp_starttls_probe(host, 25)
|
||||||
|
cert = probe.get('cert')
|
||||||
|
entry: Dict[str, Any] = {'host': host, 'tls_established': probe.get('tls_established', False)}
|
||||||
|
if cert:
|
||||||
|
nb = parse_cert_time(cert.get('notBefore')) if cert.get('notBefore') else None
|
||||||
|
na = parse_cert_time(cert.get('notAfter')) if cert.get('notAfter') else None
|
||||||
|
is_valid = None
|
||||||
|
days_remaining = None
|
||||||
|
if nb and na:
|
||||||
|
# Hinweis: parse_cert_time gibt naive UTC basierend auf 'GMT'; wir machen sie timezone-aware UTC
|
||||||
|
nb = nb.replace(tzinfo=timezone.utc)
|
||||||
|
na = na.replace(tzinfo=timezone.utc)
|
||||||
|
is_valid = (nb <= now <= na)
|
||||||
|
days_remaining = int((na - now).total_seconds() // 86400)
|
||||||
|
entry.update({
|
||||||
|
'not_before': nb.isoformat() if nb else None,
|
||||||
|
'not_after': na.isoformat() if na else None,
|
||||||
|
'valid_now': is_valid,
|
||||||
|
'days_remaining': days_remaining,
|
||||||
|
'subject': cert.get('subject'),
|
||||||
|
'issuer': cert.get('issuer'),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
entry['error'] = probe.get('error') or ('no cert (starttls_advertised=' + str(probe.get('starttls_advertised')) + ')')
|
||||||
|
results.append(entry)
|
||||||
|
return {
|
||||||
|
'certs': results,
|
||||||
|
'summary': {
|
||||||
|
'total_hosts': len(results),
|
||||||
|
'valid_now': sum(1 for r in results if r.get('valid_now')),
|
||||||
|
'expiring_soon_14d': sum(1 for r in results if isinstance(r.get('days_remaining'), int) and r['days_remaining'] <= 14),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
worker = CertValidityWorker(routing_key("MailCheck", "CertValidity"), worker_id="cert-worker-1")
|
||||||
|
await worker.run()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio as _a
|
||||||
|
_a.run(main())
|
||||||
68
app/workers/mail_mtasts_worker.py
Normal file
68
app/workers/mail_mtasts_worker.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import httpx
|
||||||
|
from app.workers.base_worker import BaseWorker
|
||||||
|
from app.job_registry import routing_key
|
||||||
|
from app.config import settings
|
||||||
|
from app.workers.utils import resolve_txt
|
||||||
|
|
||||||
|
class MTASTSWorker(BaseWorker):
|
||||||
|
async def process(self, payload: dict, **meta) -> dict:
|
||||||
|
domain = payload.get('domain')
|
||||||
|
if not domain:
|
||||||
|
raise ValueError('payload.domain is required')
|
||||||
|
txt_name = f"_mta-sts.{domain}"
|
||||||
|
txts = await resolve_txt(txt_name)
|
||||||
|
txt_policy = None
|
||||||
|
for t in txts:
|
||||||
|
if t.lower().startswith('v=stsv1'):
|
||||||
|
txt_policy = t
|
||||||
|
break
|
||||||
|
# Fetch policy file
|
||||||
|
url = f"https://mta-sts.{domain}/.well-known/mta-sts.txt"
|
||||||
|
policy = {
|
||||||
|
'version': None,
|
||||||
|
'mode': None,
|
||||||
|
'max_age': None,
|
||||||
|
'mx': [],
|
||||||
|
}
|
||||||
|
content = None
|
||||||
|
try:
|
||||||
|
timeout = httpx.Timeout(settings.HTTP_TIMEOUT)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
content = resp.text
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
if content:
|
||||||
|
for raw in content.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
if ':' in line:
|
||||||
|
k, v = [s.strip() for s in line.split(':', 1)]
|
||||||
|
lk = k.lower()
|
||||||
|
if lk == 'version':
|
||||||
|
policy['version'] = v
|
||||||
|
elif lk == 'mode':
|
||||||
|
policy['mode'] = v
|
||||||
|
elif lk == 'max_age':
|
||||||
|
try:
|
||||||
|
policy['max_age'] = int(v)
|
||||||
|
except ValueError:
|
||||||
|
policy['max_age'] = None
|
||||||
|
elif lk == 'mx':
|
||||||
|
policy['mx'].append(v)
|
||||||
|
return {
|
||||||
|
'txt_record': txt_policy,
|
||||||
|
'policy_url': url,
|
||||||
|
'policy': policy,
|
||||||
|
'has_policy': bool(policy['version']),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
worker = MTASTSWorker(routing_key("MailCheck", "MTASTSCheck"), worker_id="mtasts-worker-1")
|
||||||
|
await worker.run()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import asyncio as _a
|
||||||
|
_a.run(main())
|
||||||
24
app/workers/mail_mx_worker.py
Normal file
24
app/workers/mail_mx_worker.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import asyncio
|
||||||
|
from app.workers.base_worker import BaseWorker
|
||||||
|
from app.job_registry import routing_key
|
||||||
|
from app.workers.utils import resolve_mx
|
||||||
|
|
||||||
|
class MXValidationWorker(BaseWorker):
|
||||||
|
async def process(self, payload: dict, **meta) -> dict:
|
||||||
|
domain = payload.get("domain")
|
||||||
|
if not domain:
|
||||||
|
raise ValueError("payload.domain is required")
|
||||||
|
mx = await resolve_mx(domain)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return {
|
||||||
|
"mx_records": [{"exchange": m.exchange, "preference": m.preference} for m in mx],
|
||||||
|
"count": len(mx),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
worker = MXValidationWorker(routing_key("MailCheck", "MXValidation"), worker_id="mx-worker-1")
|
||||||
|
await worker.run()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio as _a
|
||||||
|
_a.run(main())
|
||||||
22
app/workers/mail_spf_worker.py
Normal file
22
app/workers/mail_spf_worker.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import asyncio
|
||||||
|
from app.workers.base_worker import BaseWorker
|
||||||
|
from app.job_registry import routing_key
|
||||||
|
from app.workers.utils import resolve_txt, parse_spf
|
||||||
|
|
||||||
|
class SPFValidationWorker(BaseWorker):
|
||||||
|
async def process(self, payload: dict, **meta) -> dict:
|
||||||
|
domain = payload.get("domain")
|
||||||
|
if not domain:
|
||||||
|
raise ValueError("payload.domain is required")
|
||||||
|
txts = await resolve_txt(domain)
|
||||||
|
parsed = parse_spf(txts)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
worker = SPFValidationWorker(routing_key("MailCheck", "SPFValidation"), worker_id="spf-worker-1")
|
||||||
|
await worker.run()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio as _a
|
||||||
|
_a.run(main())
|
||||||
50
app/workers/mail_tls_worker.py
Normal file
50
app/workers/mail_tls_worker.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import Task
|
||||||
|
from app.workers.base_worker import BaseWorker
|
||||||
|
from app.job_registry import routing_key
|
||||||
|
from app.workers.utils import resolve_mx, smtp_starttls_probe
|
||||||
|
|
||||||
|
class TLSCheckWorker(BaseWorker):
|
||||||
|
async def process(self, payload: dict, **meta) -> dict:
|
||||||
|
domain = payload.get("domain")
|
||||||
|
job_id = meta.get('job_id')
|
||||||
|
if not domain:
|
||||||
|
raise ValueError("payload.domain is required")
|
||||||
|
# Versuche MX aus MXValidation-Resultat zu lesen
|
||||||
|
mx_hosts = []
|
||||||
|
if job_id:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
res = await session.execute(select(Task).where(Task.job_id == job_id, Task.name == 'MXValidation'))
|
||||||
|
mx_task = res.scalar_one_or_none()
|
||||||
|
if mx_task and mx_task.result and 'mx_records' in mx_task.result:
|
||||||
|
mx_hosts = [r['exchange'] for r in mx_task.result.get('mx_records', [])]
|
||||||
|
if not mx_hosts:
|
||||||
|
mx = await resolve_mx(domain)
|
||||||
|
mx_hosts = [m.exchange for m in mx]
|
||||||
|
if not mx_hosts:
|
||||||
|
return {"mx_hosts": [], "summary": {"total": 0, "starttls_supported": 0, "tls_ok": 0}}
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
for host in mx_hosts:
|
||||||
|
probe = await smtp_starttls_probe(host, 25)
|
||||||
|
results.append(probe)
|
||||||
|
starttls_supported = sum(1 for r in results if r.get('starttls_advertised'))
|
||||||
|
tls_ok = sum(1 for r in results if r.get('tls_established'))
|
||||||
|
return {
|
||||||
|
"mx_hosts": results,
|
||||||
|
"summary": {
|
||||||
|
"total": len(results),
|
||||||
|
"starttls_supported": starttls_supported,
|
||||||
|
"tls_ok": tls_ok,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
worker = TLSCheckWorker(routing_key("MailCheck", "TLSCheck"), worker_id="tls-worker-1")
|
||||||
|
await worker.run()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio as _a
|
||||||
|
_a.run(main())
|
||||||
37
app/workers/mail_tlsrpt_worker.py
Normal file
37
app/workers/mail_tlsrpt_worker.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from app.workers.base_worker import BaseWorker
|
||||||
|
from app.job_registry import routing_key
|
||||||
|
from app.workers.utils import resolve_txt
|
||||||
|
|
||||||
|
class TLSRPTWorker(BaseWorker):
|
||||||
|
async def process(self, payload: dict, **meta) -> dict:
|
||||||
|
domain = payload.get('domain')
|
||||||
|
if not domain:
|
||||||
|
raise ValueError('payload.domain is required')
|
||||||
|
name = f"_smtp._tls.{domain}"
|
||||||
|
txts = await resolve_txt(name)
|
||||||
|
record = None
|
||||||
|
rua = []
|
||||||
|
for t in txts:
|
||||||
|
tl = t.lower()
|
||||||
|
if tl.startswith('v=tlsrptv1'):
|
||||||
|
record = t
|
||||||
|
# parse rua=mailto:... , mailto:...
|
||||||
|
parts = [p.strip() for p in t.split(';')]
|
||||||
|
for p in parts:
|
||||||
|
if p.strip().lower().startswith('rua='):
|
||||||
|
v = p.split('=',1)[1]
|
||||||
|
rua = [x.strip() for x in v.split(',') if x.strip()]
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
'txt_record': record,
|
||||||
|
'rua': rua,
|
||||||
|
'enabled': record is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
worker = TLSRPTWorker(routing_key("MailCheck", "TLSRPTCheck"), worker_id="tlsrpt-worker-1")
|
||||||
|
await worker.run()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import asyncio as _a
|
||||||
|
_a.run(main())
|
||||||
116
app/workers/utils.py
Normal file
116
app/workers/utils.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import smtplib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
import dns.resolver
|
||||||
|
from app.config import settings
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MXRecord:
|
||||||
|
preference: int
|
||||||
|
exchange: str
|
||||||
|
|
||||||
|
async def resolve_mx(domain: str) -> List[MXRecord]:
|
||||||
|
def _sync() -> List[MXRecord]:
|
||||||
|
r = dns.resolver.Resolver()
|
||||||
|
r.timeout = settings.DNS_TIMEOUT
|
||||||
|
r.lifetime = settings.DNS_LIFETIME
|
||||||
|
answers = r.resolve(domain, 'MX')
|
||||||
|
recs = []
|
||||||
|
for rr in answers:
|
||||||
|
recs.append(MXRecord(preference=int(rr.preference), exchange=str(rr.exchange).rstrip('.')))
|
||||||
|
recs.sort(key=lambda x: x.preference)
|
||||||
|
return recs
|
||||||
|
return await asyncio.to_thread(_sync)
|
||||||
|
|
||||||
|
async def resolve_txt(domain: str) -> List[str]:
|
||||||
|
def _sync() -> List[str]:
|
||||||
|
r = dns.resolver.Resolver()
|
||||||
|
r.timeout = settings.DNS_TIMEOUT
|
||||||
|
r.lifetime = settings.DNS_LIFETIME
|
||||||
|
try:
|
||||||
|
answers = r.resolve(domain, 'TXT')
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for rr in answers:
|
||||||
|
s = ''.join(part.decode('utf-8') if isinstance(part, bytes) else str(part) for part in getattr(rr, 'strings', []))
|
||||||
|
if not s:
|
||||||
|
s = str(rr)
|
||||||
|
s = s.strip('"')
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
return await asyncio.to_thread(_sync)
|
||||||
|
|
||||||
|
def parse_spf(txt_records: List[str]) -> Dict[str, Any]:
|
||||||
|
spf_records = [t for t in txt_records if t.lower().startswith('v=spf1')]
|
||||||
|
details = []
|
||||||
|
for spf in spf_records:
|
||||||
|
tokens = spf.split()
|
||||||
|
mechanisms = [t for t in tokens[1:]]
|
||||||
|
qualifier = next((t for t in tokens if t.endswith('all')), None)
|
||||||
|
details.append({
|
||||||
|
'record': spf,
|
||||||
|
'mechanisms': mechanisms,
|
||||||
|
'has_all': any(tok.endswith('all') for tok in tokens),
|
||||||
|
'all_qualifier': qualifier,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'present': len(spf_records) > 0,
|
||||||
|
'records': spf_records,
|
||||||
|
'parsed': details,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def smtp_starttls_probe(host: str, port: int = 25) -> Dict[str, Any]:
|
||||||
|
def _sync() -> Dict[str, Any]:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
context.check_hostname = True
|
||||||
|
context.verify_mode = ssl.CERT_REQUIRED
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
'host': host,
|
||||||
|
'port': port,
|
||||||
|
'ehlo': False,
|
||||||
|
'starttls_advertised': False,
|
||||||
|
'tls_established': False,
|
||||||
|
'tls_version': None,
|
||||||
|
'cipher': None,
|
||||||
|
'cert': None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP(host=host, port=port, timeout=settings.SMTP_TIMEOUT) as server:
|
||||||
|
code, _ = server.ehlo()
|
||||||
|
result['ehlo'] = (200 <= code < 400)
|
||||||
|
if 'starttls' in (server.esmtp_features or {}):
|
||||||
|
result['starttls_advertised'] = True
|
||||||
|
code, _ = server.starttls(context=context)
|
||||||
|
if 200 <= code < 400:
|
||||||
|
result['tls_established'] = True
|
||||||
|
server.ehlo()
|
||||||
|
try:
|
||||||
|
sslobj = server.sock
|
||||||
|
result['tls_version'] = getattr(sslobj, 'version', lambda: None)()
|
||||||
|
result['cipher'] = getattr(sslobj, 'cipher', lambda: None)()
|
||||||
|
try:
|
||||||
|
cert = sslobj.getpeercert()
|
||||||
|
result['cert'] = cert
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
result['starttls_advertised'] = False
|
||||||
|
except (ssl.SSLError, smtplib.SMTPException, OSError, socket.error) as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
return result
|
||||||
|
return await asyncio.to_thread(_sync)
|
||||||
|
|
||||||
|
# Cert date parsing (OpenSSL style: 'Jun 5 12:00:00 2025 GMT')
|
||||||
|
def parse_cert_time(s: str) -> datetime | None:
|
||||||
|
try:
|
||||||
|
norm = ' '.join(s.split())
|
||||||
|
return datetime.strptime(norm, '%b %d %H:%M:%S %Y %Z')
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: tasks
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:3-management
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: guest
|
||||||
|
RABBITMQ_DEFAULT_PASS: guest
|
||||||
|
ports:
|
||||||
|
- "5672:5672"
|
||||||
|
- "15672:15672"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
1620
poetry.lock
generated
Normal file
1620
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
35
pyproject.toml
Normal file
35
pyproject.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
name = "task-queue-system"
|
||||||
|
version = "0.3.0"
|
||||||
|
description = "Async task queue with FastAPI, PostgreSQL, RabbitMQ; MailCheck workers incl. MX, SPF, TLS, CertValidity, MTA-STS, TLS-RPT with dependencies"
|
||||||
|
authors = ["Frank Agerholm <you@example.com>"]
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = ">=3.12,<3.13"
|
||||||
|
fastapi = "^0.115"
|
||||||
|
uvicorn = {version = "^0.30", extras = ["standard"]}
|
||||||
|
sqlalchemy = "^2.0"
|
||||||
|
asyncpg = "^0.29"
|
||||||
|
pydantic = "^2.6"
|
||||||
|
aio-pika = "^9.4"
|
||||||
|
httpx = "^0.27"
|
||||||
|
python-dotenv = "^1.0"
|
||||||
|
dnspython = "^2.6"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
black = "^24.10"
|
||||||
|
ruff = "^0.6"
|
||||||
|
|
||||||
|
[tool.poetry.scripts]
|
||||||
|
# optional: CLI entrypoint (statt python -m app.cli)
|
||||||
|
tq = "app.cli:main"
|
||||||
|
|
||||||
|
[tool.poetry.urls]
|
||||||
|
"Homepage" = "https://example.com"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=1.6.0"]
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue