Archived
Add scheduled cleanup for abandoned setup flows
This commit is contained in:
1 parent
148918b283
commit
1eec3b8212
6 files changed
+105
-8
No files matched your search
+27
-1
@@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import html
|
||||
import secrets
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@@ -68,8 +72,19 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
||||
raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
|
||||
|
||||
store = DdnsStore(settings.database_path)
|
||||
store.cleanup(settings.cleanup_challenge_hours)
|
||||
store.cleanup(settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours)
|
||||
service = DdnsService(settings, store)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
cleanup_task = asyncio.create_task(_cleanup_loop(store, settings))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
cleanup_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await cleanup_task
|
||||
|
||||
app = FastAPI(
|
||||
title="router_dyndns DDNS",
|
||||
summary="A small self-hosted DynDNS provider for FRITZ!Box routers.",
|
||||
@@ -81,6 +96,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.include_router(create_api_router(settings, store, service))
|
||||
|
||||
@@ -335,6 +351,16 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
async def _cleanup_loop(store: DdnsStore, settings: DdnsSettings) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(settings.cleanup_interval_seconds)
|
||||
await run_in_threadpool(
|
||||
store.cleanup,
|
||||
settings.cleanup_challenge_hours,
|
||||
settings.cleanup_unused_account_hours,
|
||||
)
|
||||
|
||||
|
||||
def _parse_form(body: bytes) -> dict[str, list[str]]:
|
||||
return urllib.parse.parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ class DdnsSettings(BaseModel):
|
||||
cloudflare_api_token: str = Field(default="")
|
||||
cloudflare_zone_id: str = Field(default="")
|
||||
cleanup_challenge_hours: int = Field(default=72, ge=1, le=24 * 365)
|
||||
cleanup_unused_account_hours: int = Field(default=168, ge=1, le=24 * 365)
|
||||
cleanup_interval_seconds: int = Field(default=3600, ge=1, le=24 * 60 * 60)
|
||||
ttl: int = Field(default=60, ge=60, le=86400)
|
||||
|
||||
@field_validator("public_base_url")
|
||||
@@ -65,6 +67,8 @@ class DdnsSettings(BaseModel):
|
||||
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
|
||||
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
|
||||
cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72),
|
||||
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 168),
|
||||
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
|
||||
ttl=_env_int("DDNS_TTL", 60),
|
||||
)
|
||||
|
||||
|
||||
@@ -299,13 +299,29 @@ class DdnsStore:
|
||||
)
|
||||
return True
|
||||
|
||||
def cleanup(self, challenge_hours: int) -> None:
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=challenge_hours)
|
||||
def cleanup(self, challenge_hours: int, unused_account_hours: int) -> dict[str, int]:
|
||||
challenge_cutoff = (datetime.now(UTC) - timedelta(hours=challenge_hours)).isoformat()
|
||||
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
challenge_cursor = conn.execute(
|
||||
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?",
|
||||
(cutoff.isoformat(),),
|
||||
(challenge_cutoff,),
|
||||
)
|
||||
account_cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM accounts
|
||||
WHERE created_at < ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
|
||||
)
|
||||
""",
|
||||
(unused_account_cutoff,),
|
||||
)
|
||||
conn.execute("DELETE FROM rate_limits WHERE window < ?", (int(time.time()) - 3600,))
|
||||
return {
|
||||
"domain_challenges": challenge_cursor.rowcount,
|
||||
"unused_accounts": account_cursor.rowcount,
|
||||
}
|
||||
|
||||
def _init_db(self) -> None:
|
||||
with self.connect() as conn:
|
||||
|
||||
Reference in new issue
Block a user