sijapi/sijapi/routers/cf.py

214 lines
7.3 KiB
Python

'''
IN DEVELOPMENT - Cloudflare + Caddy module. Based on a bash script that's able to rapidly deploy new Cloudflare subdomains on new Caddy reverse proxy configurations, managing everything including restarting Caddy. The Python version needs more testing before actual use.
'''
#routers/cf.py
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from fastapi.responses import PlainTextResponse, JSONResponse
from typing import Optional
from sijapi import L, CF_TOKEN, CADDYFILE_PATH, CF_API_BASE_URL, CF_IP
import httpx
import asyncio
from asyncio import sleep
import os
cf = APIRouter()
logger = L.get_module_logger("cal")
def debug(text: str): logger.debug(text)
def info(text: str): logger.info(text)
def warn(text: str): logger.warning(text)
def err(text: str): logger.error(text)
def crit(text: str): logger.critical(text)
class DNSRecordRequest(BaseModel):
full_domain: str
ip: Optional[str] = None
port: str
# Update to make get_zone_id async
async def get_zone_id(domain: str) -> str:
url = f"{CF_API_BASE_URL}/zones"
headers = {
"Authorization": f"Bearer {CF_TOKEN}",
"Content-Type": "application/json"
}
params = {"name": domain}
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
data = response.json()
if data['success']:
if len(data['result']) > 0:
return data['result'][0]['id']
else:
raise ValueError(f"No Zone ID found for domain '{domain}'")
else:
errors = ', '.join(err['message'] for err in data['errors'])
raise ValueError(f"Cloudflare API returned errors: {errors}")
async def update_caddyfile(full_domain, caddy_ip, port):
caddy_config = f"""
{full_domain} {{
reverse_proxy {caddy_ip}:{port}
tls {{
dns cloudflare {{"$CLOUDFLARE_API_TOKEN"}}
}}
}}
"""
with open(CADDYFILE_PATH, 'a') as file:
file.write(caddy_config)
# Using asyncio to create subprocess
proc = await asyncio.create_subprocess_exec("sudo", "systemctl", "restart", "caddy")
await proc.communicate()
# Retry mechanism for API calls
async def retry_request(url, headers, max_retries=5, backoff_factor=1):
for retry in range(max_retries):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response
except (httpx.HTTPError, httpx.ConnectTimeout) as e:
err(f"Request failed: {e}. Retrying {retry + 1}/{max_retries}...")
await sleep(backoff_factor * (2 ** retry))
raise HTTPException(status_code=500, detail="Max retries exceeded for Cloudflare API request")
# Helper function to load Caddyfile domains
def load_caddyfile_domains():
with open(CADDYFILE_PATH, 'r') as file:
caddyfile_content = file.read()
domains = []
for line in caddyfile_content.splitlines():
if line.strip() and not line.startswith('#'):
if "{" in line:
domain = line.split("{")[0].strip()
domains.append(domain)
return domains
# Endpoint to add new configuration to Cloudflare, Caddyfile, and cf_domains.json
@cf.post("/cf/add_config")
async def add_config(record: DNSRecordRequest):
full_domain = record.full_domain
caddy_ip = record.ip or "localhost"
port = record.port
# Extract subdomain and domain
parts = full_domain.split(".")
if len(parts) == 2:
domain = full_domain
subdomain = "@"
else:
subdomain = parts[0]
domain = ".".join(parts[1:])
zone_id = await get_zone_id(domain)
if not zone_id:
raise HTTPException(status_code=400, detail=f"Zone ID for {domain} could not be found")
# API call setup for Cloudflare A record
endpoint = f"{CF_API_BASE_URL}/zones/{zone_id}/dns_records"
headers = {
"Authorization": f"Bearer {CF_TOKEN}",
"Content-Type": "application/json"
}
data = {
"type": "A",
"name": subdomain,
"content": CF_IP,
"ttl": 120,
"proxied": True
}
async with httpx.AsyncClient() as client:
response = await client.post(endpoint, headers=headers, json=data)
result = response.json()
if not result.get("success", False):
error_message = result.get("errors", [{}])[0].get("message", "Unknown error")
error_code = result.get("errors", [{}])[0].get("code", "Unknown code")
raise HTTPException(status_code=400, detail=f"Failed to create A record: {error_message} (Code: {error_code})")
# Update Caddyfile
await update_caddyfile(full_domain, caddy_ip, port)
return {"message": "Configuration added successfully"}
@cf.get("/cf/list_zones")
async def list_zones_endpoint():
domains = await list_zones()
return JSONResponse(domains)
async def list_zones():
endpoint = f"{CF_API_BASE_URL}/zones"
headers = {
"Authorization": f"Bearer {CF_TOKEN}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient() as client: # async http call
response = await client.get(endpoint, headers=headers)
response.raise_for_status()
result = response.json()
if not result.get("success"):
raise HTTPException(status_code=400, detail="Failed to retrieve zones from Cloudflare")
zones = result.get("result", [])
domains = {}
for zone in zones:
zone_id = zone.get("id")
zone_name = zone.get("name")
domains[zone_name] = {"zone_id": zone_id}
records_endpoint = f"{CF_API_BASE_URL}/zones/{zone_id}/dns_records"
async with httpx.AsyncClient() as client: # async http call
records_response = await client.get(records_endpoint, headers=headers)
records_result = records_response.json()
if not records_result.get("success"):
raise HTTPException(status_code=400, detail=f"Failed to retrieve DNS records for zone {zone_name}")
records = records_result.get("result", [])
for record in records:
record_id = record.get("id")
domain_name = record.get("name").replace(f".{zone_name}", "")
domains[zone_name].setdefault(domain_name, {})["dns_id"] = record_id
return domains
@cf.get("/cf/compare_caddy", response_class=PlainTextResponse)
async def crossreference_caddyfile():
cf_domains_data = await list_zones()
caddyfile_domains = load_caddyfile_domains()
cf_domains_list = [
f"{sub}.{domain}" if sub != "@" else domain
for domain, data in cf_domains_data.items()
for sub in data.get("subdomains", {}).keys()
]
caddyfile_domains_set = set(caddyfile_domains)
cf_domains_set = set(cf_domains_list)
only_in_caddyfile = caddyfile_domains_set - cf_domains_set
only_in_cf_domains = cf_domains_set - caddyfile_domains_set
markdown_output = "# Cross-reference cf_domains.json and Caddyfile\n\n"
markdown_output += "## Domains only in Caddyfile:\n\n"
for domain in only_in_caddyfile:
markdown_output += f"- **{domain}**\n"
markdown_output += "\n## Domains only in cf_domains.json:\n\n"
for domain in only_in_cf_domains:
markdown_output += f"- **{domain}**\n"
return markdown_output