Add scheduled cleanup for abandoned setup flows

This commit is contained in:
StasonJatham committed 2026-05-20 18:01:29 +02:00
1 parent 148918b283
commit 1eec3b8212
6 files changed
+105 -8

No files matched your search

+2 -1
View File
@@ -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
+13 -2
View File
@@ -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
+27 -1
View File
@@ -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)
+4
View File
@@ -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),
)
+20 -4
View File
@@ -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:
+39
View File
@@ -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