5416ae7565
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
133 lines
4.3 KiB
Python
133 lines
4.3 KiB
Python
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from typing import AsyncGenerator
|
|
|
|
from fastapi import FastAPI, Depends, HTTPException
|
|
from fastapi.responses import Response
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, make_url
|
|
|
|
from models import Base, Config, Subscription, ExportLog, make_engine, make_session_factory
|
|
from mihomo import MihomoClient
|
|
from expander import expand_config, build_mihomo_config
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:////data/db/app.db")
|
|
MIHOMO_API = os.environ.get("MIHOMO_API", "http://mihomo:9090")
|
|
MIHOMO_SECRET = os.environ.get("MIHOMO_SECRET", "")
|
|
MIHOMO_CONFIG_DIR = os.environ.get("MIHOMO_CONFIG_DIR", "/data/mihomo")
|
|
|
|
engine = make_engine(DATABASE_URL)
|
|
SessionLocal = make_session_factory(engine)
|
|
mihomo_client = MihomoClient(MIHOMO_API, MIHOMO_SECRET)
|
|
templates = Jinja2Templates(
|
|
directory=os.path.join(os.path.dirname(__file__), "templates")
|
|
)
|
|
|
|
|
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
async with SessionLocal() as session:
|
|
yield session
|
|
|
|
|
|
async def write_and_reload_mihomo(db: AsyncSession) -> None:
|
|
result = await db.execute(select(Config))
|
|
configs = result.scalars().all()
|
|
config_yaml = build_mihomo_config([c.base_yaml for c in configs], MIHOMO_SECRET)
|
|
config_path = os.path.join(MIHOMO_CONFIG_DIR, "config.yaml")
|
|
tmp_path = config_path + ".tmp"
|
|
os.makedirs(MIHOMO_CONFIG_DIR, exist_ok=True)
|
|
with open(tmp_path, "w") as f:
|
|
f.write(config_yaml)
|
|
os.replace(tmp_path, config_path)
|
|
logger.info("Wrote Mihomo config to %s", config_path)
|
|
await mihomo_client.reload_config()
|
|
logger.info("Mihomo config reloaded")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI): # type: ignore[type-arg]
|
|
os.makedirs(MIHOMO_CONFIG_DIR, exist_ok=True)
|
|
db_url = make_url(DATABASE_URL)
|
|
db_path = db_url.database
|
|
if db_path and db_path != ":memory:":
|
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
await mihomo_client.wait_ready()
|
|
|
|
async with SessionLocal() as db:
|
|
await write_and_reload_mihomo(db)
|
|
|
|
yield
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
|
|
@app.get("/config/{token}.yaml")
|
|
async def get_config(
|
|
token: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Response:
|
|
result = await db.execute(select(Config).where(Config.token == token))
|
|
config = result.scalar_one_or_none()
|
|
if not config:
|
|
raise HTTPException(status_code=404, detail="Config not found")
|
|
|
|
result = await db.execute(
|
|
select(Subscription).where(Subscription.config_id == config.id)
|
|
)
|
|
subscriptions = result.scalars().all()
|
|
|
|
provider_proxies: dict[str, list[dict]] = {}
|
|
errors: list[str] = []
|
|
|
|
for sub in subscriptions:
|
|
try:
|
|
proxies = await mihomo_client.refresh_and_collect(sub.name, timeout=30)
|
|
provider_proxies[sub.name] = proxies
|
|
sub.last_fetched_at = datetime.utcnow()
|
|
except Exception as exc:
|
|
logger.error("Failed to refresh provider %s: %s", sub.name, exc)
|
|
errors.append(f"{sub.name}: {exc}")
|
|
|
|
try:
|
|
expanded = expand_config(config.base_yaml, provider_proxies)
|
|
except Exception as exc:
|
|
logger.error("Config expansion failed for token (redacted): %s", exc, exc_info=True)
|
|
db.add(
|
|
ExportLog(
|
|
config_id=config.id,
|
|
node_count=0,
|
|
success=False,
|
|
error_message=str(exc),
|
|
)
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(status_code=500, detail="Config expansion failed")
|
|
|
|
node_count = sum(len(p) for p in provider_proxies.values())
|
|
error_msg = "; ".join(errors) if errors else None
|
|
db.add(
|
|
ExportLog(
|
|
config_id=config.id,
|
|
node_count=node_count,
|
|
success=not bool(errors),
|
|
error_message=error_msg,
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
return Response(content=expanded, media_type="application/x-yaml")
|