diff --git a/.env.example b/.env.example
index dccd6d7..b359b1e 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,8 @@ DDNS_CLOUDFLARE_API_TOKEN=change-me-cloudflare-token
DDNS_CLOUDFLARE_ZONE_ID=change-me-cloudflare-zone-id
DDNS_TTL=60
DDNS_RATE_LIMIT_PER_MINUTE=60
+DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
+DDNS_MAX_REQUEST_BODY_BYTES=16384
DDNS_CLEANUP_CHALLENGE_HOURS=72
DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=168
DDNS_CLEANUP_INTERVAL_SECONDS=3600
diff --git a/README.md b/README.md
index 393a96a..fdd5c5a 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ That means users always have a current hostname for VPN, remote access, self-hos
| Persistence | SQLite with WAL mode |
| API | FastAPI, OpenAPI, Swagger UI, ReDoc |
| Auth model | No user accounts. Private bearer links for hostnames and custom-domain claims |
-| Operations | Rate limiting, update logs, cleanup jobs, Docker, Caddy example |
+| Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example |
## Quick Start
@@ -92,6 +92,8 @@ Open:
Admin auth uses HTTP Basic auth. The username can be any value; the password is `DDNS_ADMIN_PASSWORD`.
+The public OpenAPI schema documents the developer API under `/api/v1`. Admin/operator endpoints are intentionally omitted from Swagger UI and ReDoc.
+
## Production Setup
For an internet-facing DynDNS service, run behind HTTPS and require a real DNS backend:
@@ -103,6 +105,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_RATE_LIMIT_PER_MINUTE=60
+export DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
+export DDNS_MAX_REQUEST_BODY_BYTES=16384
export DDNS_CLEANUP_CHALLENGE_HOURS=72
export DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=168
export DDNS_CLEANUP_INTERVAL_SECONDS=3600
@@ -207,14 +212,18 @@ docker compose up -d --build
- 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.
+- Keep `/docs` and `/redoc` available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth.
- 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:
+Security and cleanup defaults:
| Variable | Default | Purpose |
| --- | ---: | --- |
+| `DDNS_RATE_LIMIT_PER_MINUTE` | `60` | Public update/API/form requests allowed per source IP and path. |
+| `DDNS_ADMIN_RATE_LIMIT_PER_MINUTE` | `20` | Admin/operator requests allowed per source IP and path. |
+| `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. |
| `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. |
@@ -243,6 +252,6 @@ Self-hosted DynDNS, self hosted DDNS, Dynamic DNS server, FRITZ!Box DynDNS, FRIT
## Security
-The repository intentionally contains only placeholders in `.env.example` and documentation. Do not commit a real `.env`, DNS token, TSIG secret, admin password, session secret, database, or router-generated update URL.
+The repository intentionally contains only placeholders in `.env.example` and documentation. Do not commit a real `.env`, DNS token, TSIG secret, admin password, database, magic management link, domain claim URL, or router-generated update URL.
If you find a vulnerability, open a private advisory or contact the repository owner before publishing details.
diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py
index 2cdd511..cc5d396 100644
--- a/router_dyndns/ddns.py
+++ b/router_dyndns/ddns.py
@@ -14,6 +14,7 @@ from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
from .ddns_api import create_api_router
from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns
@@ -55,15 +56,43 @@ OPENAPI_TAGS = [
"name": "management",
"description": "Bearer-link management for generated hostnames.",
},
- {
- "name": "admin",
- "description": "Operator-only HTML and JSON endpoints protected by the admin password.",
- },
]
ASSET_DIR = Path(__file__).with_name("assets")
+class RequestBodyTooLargeError(Exception):
+ pass
+
+
+class RequestBodyLimitMiddleware:
+ def __init__(self, app: ASGIApp, max_body_bytes: int) -> None:
+ self.app = app
+ self.max_body_bytes = max_body_bytes
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+
+ received = 0
+
+ async def limited_receive() -> Message:
+ nonlocal received
+ message = await receive()
+ if message["type"] == "http.request":
+ received += len(message.get("body", b""))
+ if received > self.max_body_bytes:
+ raise RequestBodyTooLargeError
+ return message
+
+ try:
+ await self.app(scope, limited_receive, send)
+ except RequestBodyTooLargeError:
+ response = PlainTextResponse("request body too large", status_code=413)
+ await response(scope, receive, send)
+
+
def make_app(settings: DdnsSettings | None = None) -> FastAPI:
settings = settings or DdnsSettings.from_env()
if not settings.admin_password and settings.shared_secret:
@@ -98,17 +127,29 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
openapi_tags=OPENAPI_TAGS,
lifespan=lifespan,
)
+ app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes)
app.include_router(create_api_router(settings, store, service))
@app.middleware("http")
async def security_middleware(request: Request, call_next):
- if is_rate_limited_path(request.url.path):
+ try:
+ content_length = int(request.headers.get("content-length", "0") or "0")
+ except ValueError:
+ content_length = 0
+ if content_length > settings.max_request_body_bytes:
+ return PlainTextResponse("request body too large", status_code=413)
+
+ if is_rate_limited_path(request.url.path) or _is_admin_path(request.url.path):
+ limit = settings.admin_rate_limit_per_minute if _is_admin_path(request.url.path) else settings.rate_limit_per_minute
key = f"{client_ip(request, settings)}:{request.url.path}"
- allowed = await run_in_threadpool(store.allow_rate_limit, key, settings.rate_limit_per_minute)
+ allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed:
return PlainTextResponse("rate limit exceeded", status_code=429)
response = await call_next(request)
+ if _is_secret_response(request):
+ response.headers["Cache-Control"] = "no-store"
+ response.headers["Pragma"] = "no-cache"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
@@ -169,7 +210,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
@app.post("/magic", response_class=HTMLResponse)
async def magic_account(request: Request) -> str:
- form = _parse_form(await request.body())
+ form = await _request_form(request, settings)
username = _first_form_value(form, "username") or None
account = await run_in_threadpool(service.create_managed_account, username)
return _render_public_home(service, settings, account, None)
@@ -218,7 +259,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
@app.post("/request-domain", response_class=HTMLResponse)
async def request_domain(request: Request) -> str:
- form = _parse_form(await request.body())
+ form = await _request_form(request, settings)
try:
challenge = await run_in_threadpool(
service.create_domain_challenge,
@@ -230,7 +271,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
@app.post("/verify-domain", response_class=HTMLResponse)
async def verify_domain(request: Request) -> str:
- form = _parse_form(await request.body())
+ form = await _request_form(request, settings)
domain = normalize_domain(_first_form_value(form, "domain"))
claim_secret = _first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
@@ -241,7 +282,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
@app.post("/accounts", response_class=HTMLResponse)
async def self_service_account(request: Request) -> str:
- form = _parse_form(await request.body())
+ form = await _request_form(request, settings)
mode = _first_form_value(form, "mode")
username = _first_form_value(form, "username") or None
if mode == "managed":
@@ -256,13 +297,22 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
)
return _render_public_home(service, settings, account, None)
- @app.get("/admin", response_class=HTMLResponse)
+ @app.get("/admin", response_class=HTMLResponse, include_in_schema=False)
def admin(_: None = Depends(authenticate_admin)) -> str:
- return _render_admin_page(service, settings, store.list_accounts(), None)
+ return _render_admin_page(
+ service,
+ settings,
+ store.list_accounts(),
+ store.list_domain_challenges(),
+ store.list_cleanup_runs(),
+ store.list_update_events(25),
+ None,
+ None,
+ )
- @app.post("/admin/accounts")
+ @app.post("/admin/accounts", include_in_schema=False)
async def admin_create_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
- form = _parse_form(await request.body())
+ form = await _request_form(request, settings)
require_admin_csrf(settings, _first_form_value(form, "csrf"))
hostname = normalize_hostname(_first_form_value(form, "hostname"), settings)
username = _first_form_value(form, "username") or None
@@ -274,11 +324,22 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
account = store.create_account(hostname, username)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="hostname already exists") from exc
- return HTMLResponse(_render_admin_page(service, settings, store.list_accounts(), account))
+ return HTMLResponse(
+ _render_admin_page(
+ service,
+ settings,
+ store.list_accounts(),
+ store.list_domain_challenges(),
+ store.list_cleanup_runs(),
+ store.list_update_events(25),
+ account,
+ None,
+ )
+ )
- @app.post("/admin/accounts/delete")
+ @app.post("/admin/accounts/delete", include_in_schema=False)
async def admin_delete_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
- form = _parse_form(await request.body())
+ form = await _request_form(request, settings)
require_admin_csrf(settings, _first_form_value(form, "csrf"))
hostname = _first_form_value(form, "hostname")
if hostname:
@@ -288,11 +349,79 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
return RedirectResponse("/admin", status_code=303)
- @app.get("/records")
+ @app.post("/admin/accounts/rotate", include_in_schema=False)
+ async def admin_rotate_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
+ form = await _request_form(request, settings)
+ require_admin_csrf(settings, _first_form_value(form, "csrf"))
+ hostname = normalize_hostname(_first_form_value(form, "hostname"), settings, expand_suffix=False)
+ action = _first_form_value(form, "action")
+ account = store.get_account_by_hostname(hostname)
+ if not account:
+ raise HTTPException(status_code=404, detail="hostname not found")
+
+ notice = None
+ if action == "update":
+ rotated = store.rotate_update_slug(hostname)
+ notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
+ elif action == "management":
+ rotated = store.rotate_management_slug(hostname)
+ notice = _admin_secret_panel("Management link rotated", [_copy_row("Magic management link:", service.magic_management_url(rotated))]) if rotated else None
+ elif action == "password":
+ rotated = store.rotate_password(hostname)
+ if rotated:
+ notice = _admin_secret_panel(
+ "Router password rotated",
+ [
+ _copy_row("Domainnamen:", str(rotated["hostname"])),
+ _copy_row("Benutzername:", str(rotated["username"])),
+ _copy_row("Kennwort:", str(rotated["password"])),
+ ],
+ )
+ else:
+ raise HTTPException(status_code=400, detail="invalid rotation action")
+ return HTMLResponse(
+ _render_admin_page(
+ service,
+ settings,
+ store.list_accounts(),
+ store.list_domain_challenges(),
+ store.list_cleanup_runs(),
+ store.list_update_events(25),
+ None,
+ notice,
+ )
+ )
+
+ @app.post("/admin/cleanup", include_in_schema=False)
+ async def admin_run_cleanup(request: Request, _: None = Depends(authenticate_admin)) -> Response:
+ form = await _request_form(request, settings)
+ require_admin_csrf(settings, _first_form_value(form, "csrf"))
+ result = await run_in_threadpool(store.cleanup, settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours)
+ notice = _admin_secret_panel(
+ "Cleanup completed",
+ [
+ _copy_row("Domain claims removed", str(result["domain_challenges"])),
+ _copy_row("Unused hostnames removed", str(result["unused_accounts"])),
+ ],
+ )
+ return HTMLResponse(
+ _render_admin_page(
+ service,
+ settings,
+ store.list_accounts(),
+ store.list_domain_challenges(),
+ store.list_cleanup_runs(),
+ store.list_update_events(25),
+ None,
+ notice,
+ )
+ )
+
+ @app.get("/records", include_in_schema=False)
def records(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]:
return store.list_records()
- @app.get("/events")
+ @app.get("/events", include_in_schema=False)
def events(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]:
return store.list_update_events()
@@ -365,10 +494,30 @@ def _parse_form(body: bytes) -> dict[str, list[str]]:
return urllib.parse.parse_qs(body.decode("utf-8"), keep_blank_values=True)
+async def _request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]:
+ body = await request.body()
+ if len(body) > settings.max_request_body_bytes:
+ raise HTTPException(status_code=413, detail="request body too large")
+ return _parse_form(body)
+
+
def _first_form_value(form: dict[str, list[str]], key: str) -> str:
return form.get(key, [""])[0].strip()
+def _is_admin_path(path: str) -> bool:
+ return path == "/admin" or path.startswith("/admin/") or path in {"/records", "/events"}
+
+
+def _is_secret_response(request: Request) -> bool:
+ path = request.url.path
+ if _is_admin_path(path) or path.startswith(("/m/", "/d/")):
+ return True
+ if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
+ return request.method in {"POST", "DELETE"}
+ return request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}
+
+
def _render_public_home(
service: DdnsService,
settings: DdnsSettings,
@@ -559,6 +708,21 @@ def _created_account_panel(service: DdnsService, created_account: dict[str, str]
return _credentials_panel(service, created_account)
+def _admin_secret_panel(title: str, rows: list[str]) -> str:
+ return f"""
+ Admin action Copy this now. Secret values are only shown in this response.{html.escape(title)}
+
Service health
+Cleanup runs every {html.escape(str(settings.cleanup_interval_seconds))} seconds. It removes unverified domain claims after {html.escape(str(settings.cleanup_challenge_hours))} hours and never-used generated hostnames after {html.escape(str(settings.cleanup_unused_account_hours))} hours.
+| Domain | User | IPv4 | IPv6 | Updated | ||
|---|---|---|---|---|---|---|
| Domain | User | IPv4 | IPv6 | Updated | Links |
Domain claims
+| Domain | Status | Created | Verified |
|---|
Cleanup
+| Time | Domain claims removed | Unused hostnames removed |
|---|
Updates
+| Time | Domain | Status | IPv4 | IPv6 | Detail |
|---|