feat: add Cloudflare DNS integration

Closes #31
This commit is contained in:
Christian Krakau-Louis
2026-05-23 01:13:06 +02:00
parent a060bc59c2
commit 521dbc92d0
13 changed files with 1644 additions and 46 deletions
+165 -3
View File
@@ -13,6 +13,14 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.domain import Domain
from app.services.cloudflare_dns import (
analyze_dns_records,
discover_cloudflare_zones,
get_zone_for_domain,
import_cloudflare_domains,
list_dns_record_changes,
sync_dns_record_changes,
)
from app.services.dns_cache import resolve_domain_dns_cached
from app.services.dns_resolver import (
DomainDNSResult,
@@ -69,6 +77,48 @@ class DNSRecordResponse(BaseModel):
checkedAt: Optional[str] = None
class CloudflareZoneResponse(BaseModel):
"""Cloudflare zone available for import."""
id: str
name: str
status: Optional[str] = None
account_name: Optional[str] = None
imported: bool = False
class CloudflareImportRequest(BaseModel):
"""Optional list of Cloudflare domains to import."""
domains: Optional[List[str]] = None
class CloudflareImportResponse(BaseModel):
"""Cloudflare domain import summary."""
imported: List[str]
existing: List[str]
skipped: List[str]
total_discovered: int
class CloudflareDNSAnalysisResponse(BaseModel):
"""Cloudflare-managed DNS analysis and recent change details."""
zone: Dict[str, Any]
records: List[Dict[str, Any]]
checks: Dict[str, Any]
suggestions: List[Dict[str, str]]
changes: List[Dict[str, Any]]
history: List[Dict[str, Any]]
class DNSChangeHistoryResponse(BaseModel):
"""Recent DNS record changes for a domain."""
history: List[Dict[str, Any]]
class TimelinePoint(BaseModel):
"""Data point for compliance timeline"""
@@ -199,6 +249,40 @@ def _get_domain_selectors_map_from_db(db: Session, domain_names: List[str]) -> D
return selectors_by_domain
def _policy_enforcement_suggestions(
dmarc_policy: Optional[str],
summary: Dict[str, Any],
) -> List[Dict[str, str]]:
"""Suggest policy enforcement when report history supports moving beyond monitoring."""
if dmarc_policy != "none":
return []
total_count = int(summary.get("total_count", 0) or 0)
compliance_rate = float(summary.get("compliance_rate", 0.0) or 0.0)
if total_count >= 100 and compliance_rate >= 98.0:
return [
{
"type": "policy_enforcement_ready",
"severity": "info",
"message": (
"Recent reports show very high DMARC compliance. Consider moving from "
"p=none to p=quarantine with a limited pct value."
),
}
]
if total_count >= 100 and compliance_rate >= 90.0:
return [
{
"type": "policy_enforcement_review",
"severity": "info",
"message": (
"DMARC compliance is trending high. Review remaining failures before "
"moving the domain policy beyond p=none."
),
}
]
return []
@router.get("/summary", response_model=DomainSummaryResponse)
async def get_domains_summary(db: Session = Depends(get_db)):
"""
@@ -215,7 +299,7 @@ async def get_domains_summary(db: Session = Depends(get_db)):
summaries = store.get_all_domain_summaries()
# Perform DNS checks for all domains, reusing fresh cached results.
provider = get_default_provider()
provider = get_default_provider(db)
manual_selectors_by_domain = _get_domain_selectors_map_from_db(db, domains)
async def _dns_for_domain(domain_name: str) -> DomainDNSResult:
@@ -413,7 +497,7 @@ async def get_domain_dns_records(
report_selectors = _get_selectors_from_reports(store, domain_id)
combined_selectors = list(dict.fromkeys(manual_selectors + report_selectors))
provider = get_default_provider()
provider = get_default_provider(db)
result, cached, checked_at = await resolve_domain_dns_cached(
db,
provider,
@@ -434,6 +518,84 @@ async def get_domain_dns_records(
)
@router.get("/cloudflare/discover", response_model=List[CloudflareZoneResponse])
async def discover_cloudflare_domains(db: Session = Depends(get_db)):
"""Discover active Cloudflare zones visible to the configured API token."""
try:
return await discover_cloudflare_zones(db)
except LookupError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
@router.post("/cloudflare/import", response_model=CloudflareImportResponse)
async def import_cloudflare_domain_zones(
payload: CloudflareImportRequest,
db: Session = Depends(get_db),
):
"""Import selected, or all, Cloudflare zones as monitored domains."""
try:
return await import_cloudflare_domains(db, requested_domains=payload.domains)
except LookupError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
@router.get("/{domain_id}/dns/cloudflare", response_model=CloudflareDNSAnalysisResponse)
async def get_cloudflare_domain_dns_analysis(
domain_id: str = Path(..., title="The domain ID or name"),
db: Session = Depends(get_db),
):
"""Analyze Cloudflare-managed DNS records and persist detected changes."""
try:
zone_data = await get_zone_for_domain(db, domain_id)
except LookupError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
records = zone_data["records"]
changes = sync_dns_record_changes(
db,
domain=domain_id,
zone_id=zone_data["id"],
records=records,
)
analysis = analyze_dns_records(domain_id, records)
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
analysis["suggestions"].extend(
_policy_enforcement_suggestions(
analysis["checks"].get("dmarc_policy"),
store.get_domain_summary(domain_id),
)
)
history = list_dns_record_changes(db, domain_id)
return CloudflareDNSAnalysisResponse(
zone={"id": zone_data["id"], "name": zone_data["name"]},
records=analysis["records"],
checks=analysis["checks"],
suggestions=analysis["suggestions"],
changes=changes,
history=history,
)
@router.get("/{domain_id}/dns/history", response_model=DNSChangeHistoryResponse)
async def get_domain_dns_change_history(
domain_id: str = Path(..., title="The domain ID or name"),
limit: int = Query(50, title="Maximum number of change events to return"),
db: Session = Depends(get_db),
):
"""Return recent provider-backed DNS record changes for a domain."""
return DNSChangeHistoryResponse(history=list_dns_record_changes(db, domain_id, limit=limit))
@router.get("/{domain_id}/reports", response_model=DomainReportsResponse)
async def get_domain_reports(
domain_id: str = Path(..., title="The domain ID or name"),
@@ -800,7 +962,7 @@ async def get_domain_sources(
)
sources = store.get_domain_sources(domain_id, days=days)
provider = get_default_provider()
provider = get_default_provider(db)
ips = [s.get("source_ip", "unknown") for s in sources]
hostnames = await asyncio.gather(*[_safe_ptr_lookup(provider, ip) for ip in ips])