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
+2
-1
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user