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_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_PUBLIC_BASE_URL=https://ddns.example.net
DDNS_HOSTNAME_SUFFIX=ddns.example.net DDNS_HOSTNAME_SUFFIX=ddns.example.net
DDNS_DATABASE=/data/ddns.sqlite3 DDNS_DATABASE=/data/ddns.sqlite3
@@ -11,4 +10,6 @@ DDNS_CLOUDFLARE_ZONE_ID=change-me-cloudflare-zone-id
DDNS_TTL=60 DDNS_TTL=60
DDNS_RATE_LIMIT_PER_MINUTE=60 DDNS_RATE_LIMIT_PER_MINUTE=60
DDNS_CLEANUP_CHALLENGE_HOURS=72 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 DDNS_TRUSTED_PROXY_IPS=127.0.0.1,::1
+13 -2
View File
@@ -76,7 +76,6 @@ Run locally:
```bash ```bash
export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value' 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_PUBLIC_BASE_URL='http://localhost:8080'
export DDNS_HOSTNAME_SUFFIX='ddns.example.net' export DDNS_HOSTNAME_SUFFIX='ddns.example.net'
export DDNS_DATABASE='./ddns.sqlite3' export DDNS_DATABASE='./ddns.sqlite3'
@@ -104,6 +103,9 @@ export DDNS_DATABASE='/data/ddns.sqlite3'
export DDNS_REQUIRE_DNS_PROVIDER=1 export DDNS_REQUIRE_DNS_PROVIDER=1
export DDNS_DNS_ZONES='ddns.example.net' export DDNS_DNS_ZONES='ddns.example.net'
export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1' 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 ### Cloudflare DNS Backend
@@ -202,12 +204,21 @@ docker compose up -d --build
- Run behind HTTPS. - Run behind HTTPS.
- Keep `.env` out of git. - 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. - 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. - 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`. - Back up `/data/ddns.sqlite3`.
- Run one Uvicorn worker with SQLite. The per-host update lock is process-local. - 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: SQLite backup example:
```bash ```bash
+27 -1
View File
@@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import contextlib
import html import html
import secrets import secrets
import sqlite3 import sqlite3
import urllib.parse import urllib.parse
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Annotated 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") raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
store = DdnsStore(settings.database_path) 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) 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( app = FastAPI(
title="router_dyndns DDNS", title="router_dyndns DDNS",
summary="A small self-hosted DynDNS provider for FRITZ!Box routers.", 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", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
openapi_tags=OPENAPI_TAGS, openapi_tags=OPENAPI_TAGS,
lifespan=lifespan,
) )
app.include_router(create_api_router(settings, store, service)) app.include_router(create_api_router(settings, store, service))
@@ -335,6 +351,16 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
return app 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]]: def _parse_form(body: bytes) -> dict[str, list[str]]:
return urllib.parse.parse_qs(body.decode("utf-8"), keep_blank_values=True) 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_api_token: str = Field(default="")
cloudflare_zone_id: str = Field(default="") cloudflare_zone_id: str = Field(default="")
cleanup_challenge_hours: int = Field(default=72, ge=1, le=24 * 365) 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) ttl: int = Field(default=60, ge=60, le=86400)
@field_validator("public_base_url") @field_validator("public_base_url")
@@ -65,6 +67,8 @@ class DdnsSettings(BaseModel):
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""), cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""), cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72), 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), ttl=_env_int("DDNS_TTL", 60),
) )
+20 -4
View File
@@ -299,13 +299,29 @@ class DdnsStore:
) )
return True return True
def cleanup(self, challenge_hours: int) -> None: def cleanup(self, challenge_hours: int, unused_account_hours: int) -> dict[str, int]:
cutoff = datetime.now(UTC) - timedelta(hours=challenge_hours) 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: with self.connect() as conn:
conn.execute( challenge_cursor = conn.execute(
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?", "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: def _init_db(self) -> None:
with self.connect() as conn: with self.connect() as conn:
+39
View File
@@ -1,4 +1,5 @@
import re import re
import time
from pathlib import Path from pathlib import Path
from fastapi.testclient import TestClient 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) assert "no DNS backend" in str(exc)
else: else:
raise AssertionError("expected startup failure") 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