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_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
|
||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user