From 1eec3b8212fec4d4860efc46e4a014b3f29e89ad Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 18:01:29 +0200 Subject: [PATCH] Add scheduled cleanup for abandoned setup flows --- .env.example | 3 ++- README.md | 15 ++++++++++++-- router_dyndns/ddns.py | 28 +++++++++++++++++++++++++- router_dyndns/ddns_models.py | 4 ++++ router_dyndns/ddns_store.py | 24 ++++++++++++++++++---- tests/test_ddns.py | 39 ++++++++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 0efd832..dccd6d7 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password -DDNS_SESSION_SECRET=change-me-long-random-session-secret DDNS_PUBLIC_BASE_URL=https://ddns.example.net DDNS_HOSTNAME_SUFFIX=ddns.example.net DDNS_DATABASE=/data/ddns.sqlite3 @@ -11,4 +10,6 @@ DDNS_CLOUDFLARE_ZONE_ID=change-me-cloudflare-zone-id DDNS_TTL=60 DDNS_RATE_LIMIT_PER_MINUTE=60 DDNS_CLEANUP_CHALLENGE_HOURS=72 +DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=168 +DDNS_CLEANUP_INTERVAL_SECONDS=3600 DDNS_TRUSTED_PROXY_IPS=127.0.0.1,::1 diff --git a/README.md b/README.md index 9f3eeb0..393a96a 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,6 @@ Run locally: ```bash export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value' -export DDNS_SESSION_SECRET='replace-with-a-second-long-random-value' export DDNS_PUBLIC_BASE_URL='http://localhost:8080' export DDNS_HOSTNAME_SUFFIX='ddns.example.net' export DDNS_DATABASE='./ddns.sqlite3' @@ -104,6 +103,9 @@ export DDNS_DATABASE='/data/ddns.sqlite3' export DDNS_REQUIRE_DNS_PROVIDER=1 export DDNS_DNS_ZONES='ddns.example.net' export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1' +export DDNS_CLEANUP_CHALLENGE_HOURS=72 +export DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=168 +export DDNS_CLEANUP_INTERVAL_SECONDS=3600 ``` ### Cloudflare DNS Backend @@ -202,12 +204,21 @@ docker compose up -d --build - Run behind HTTPS. - Keep `.env` out of git. -- Use long random values for `DDNS_ADMIN_PASSWORD` and `DDNS_SESSION_SECRET`. +- Use a long random value for `DDNS_ADMIN_PASSWORD`. - Use a least-privilege DNS API token or TSIG key. - Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers. +- Leave cleanup enabled. The app periodically removes abandoned TXT challenges and generated hostnames that were never updated by a router. - Back up `/data/ddns.sqlite3`. - Run one Uvicorn worker with SQLite. The per-host update lock is process-local. +Cleanup defaults: + +| Variable | Default | Purpose | +| --- | ---: | --- | +| `DDNS_CLEANUP_CHALLENGE_HOURS` | `72` | Delete unverified DNS TXT claims after this many hours. | +| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `168` | Delete generated hostnames that never received a router update after this many hours. | +| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Run the in-app cleanup scheduler at this interval. | + SQLite backup example: ```bash diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index bb1e2a8..88049ec 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -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) diff --git a/router_dyndns/ddns_models.py b/router_dyndns/ddns_models.py index 45f30b4..fa07f1a 100644 --- a/router_dyndns/ddns_models.py +++ b/router_dyndns/ddns_models.py @@ -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), ) diff --git a/router_dyndns/ddns_store.py b/router_dyndns/ddns_store.py index a2dc349..b760a7f 100644 --- a/router_dyndns/ddns_store.py +++ b/router_dyndns/ddns_store.py @@ -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: diff --git a/tests/test_ddns.py b/tests/test_ddns.py index ded4f2d..13d4469 100644 --- a/tests/test_ddns.py +++ b/tests/test_ddns.py @@ -1,4 +1,5 @@ import re +import time from pathlib import Path from fastapi.testclient import TestClient @@ -532,3 +533,41 @@ def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> N assert "no DNS backend" in str(exc) else: raise AssertionError("expected startup failure") + + +def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None: + database_path = tmp_path / "ddns.sqlite3" + app = make_app( + DdnsSettings( + database_path=database_path, + admin_password="admin", + cleanup_challenge_hours=1, + cleanup_unused_account_hours=1, + cleanup_interval_seconds=1, + ) + ) + store = DdnsStore(database_path) + + with TestClient(app): + abandoned_claim = store.create_domain_challenge("abandoned.example.net") + verified_claim = store.create_domain_challenge("verified.example.net") + abandoned_account = store.create_account("abandoned.ddns.example.net", "router") + active_account = store.create_account("active.ddns.example.net", "router") + store.upsert("active.ddns.example.net", "203.0.113.10", None) + + old = "2000-01-01T00:00:00+00:00" + with store.connect() as conn: + conn.execute("UPDATE domain_challenges SET created_at = ? WHERE domain = ?", (old, abandoned_claim["domain"])) + conn.execute( + "UPDATE domain_challenges SET created_at = ?, verified_at = ? WHERE domain = ?", + (old, old, verified_claim["domain"]), + ) + conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"])) + conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"])) + + time.sleep(1.3) + + assert store.get_domain_challenge("abandoned.example.net") is None + assert store.get_domain_challenge("verified.example.net") is not None + assert store.get_account_by_slug(abandoned_account["update_slug"]) is None + assert store.get_account_by_slug(active_account["update_slug"]) is not None