Harden admin operations and API docs

This commit is contained in:
StasonJatham committed 2026-05-20 18:17:20 +02:00
1 parent f8e3d0de46
commit 7edc79971b
6 files changed
+511 -25

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ DDNS_CLOUDFLARE_API_TOKEN=change-me-cloudflare-token
DDNS_CLOUDFLARE_ZONE_ID=change-me-cloudflare-zone-id 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_ADMIN_RATE_LIMIT_PER_MINUTE=20
DDNS_MAX_REQUEST_BODY_BYTES=16384
DDNS_CLEANUP_CHALLENGE_HOURS=72 DDNS_CLEANUP_CHALLENGE_HOURS=72
DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=168 DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=168
DDNS_CLEANUP_INTERVAL_SECONDS=3600 DDNS_CLEANUP_INTERVAL_SECONDS=3600
+12 -3
View File
@@ -60,7 +60,7 @@ That means users always have a current hostname for VPN, remote access, self-hos
| Persistence | SQLite with WAL mode | | Persistence | SQLite with WAL mode |
| API | FastAPI, OpenAPI, Swagger UI, ReDoc | | API | FastAPI, OpenAPI, Swagger UI, ReDoc |
| Auth model | No user accounts. Private bearer links for hostnames and custom-domain claims | | 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 ## 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`. 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 ## Production Setup
For an internet-facing DynDNS service, run behind HTTPS and require a real DNS backend: 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_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_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_CHALLENGE_HOURS=72
export DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=168 export DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=168
export DDNS_CLEANUP_INTERVAL_SECONDS=3600 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 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.
- 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. - 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: Security and cleanup defaults:
| Variable | Default | Purpose | | 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_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_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. | | `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 ## 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. If you find a vulnerability, open a private advisory or contact the repository owner before publishing details.
+301 -21
View File
@@ -14,6 +14,7 @@ from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool 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_api import create_api_router
from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns
@@ -55,15 +56,43 @@ OPENAPI_TAGS = [
"name": "management", "name": "management",
"description": "Bearer-link management for generated hostnames.", "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") 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: def make_app(settings: DdnsSettings | None = None) -> FastAPI:
settings = settings or DdnsSettings.from_env() settings = settings or DdnsSettings.from_env()
if not settings.admin_password and settings.shared_secret: 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, openapi_tags=OPENAPI_TAGS,
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes)
app.include_router(create_api_router(settings, store, service)) app.include_router(create_api_router(settings, store, service))
@app.middleware("http") @app.middleware("http")
async def security_middleware(request: Request, call_next): 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}" 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: if not allowed:
return PlainTextResponse("rate limit exceeded", status_code=429) return PlainTextResponse("rate limit exceeded", status_code=429)
response = await call_next(request) 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-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY" response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin" response.headers["Referrer-Policy"] = "same-origin"
@@ -169,7 +210,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
@app.post("/magic", response_class=HTMLResponse) @app.post("/magic", response_class=HTMLResponse)
async def magic_account(request: Request) -> str: 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 username = _first_form_value(form, "username") or None
account = await run_in_threadpool(service.create_managed_account, username) account = await run_in_threadpool(service.create_managed_account, username)
return _render_public_home(service, settings, account, None) 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) @app.post("/request-domain", response_class=HTMLResponse)
async def request_domain(request: Request) -> str: async def request_domain(request: Request) -> str:
form = _parse_form(await request.body()) form = await _request_form(request, settings)
try: try:
challenge = await run_in_threadpool( challenge = await run_in_threadpool(
service.create_domain_challenge, service.create_domain_challenge,
@@ -230,7 +271,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
@app.post("/verify-domain", response_class=HTMLResponse) @app.post("/verify-domain", response_class=HTMLResponse)
async def verify_domain(request: Request) -> str: 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")) domain = normalize_domain(_first_form_value(form, "domain"))
claim_secret = _first_form_value(form, "claim_secret") claim_secret = _first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, 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) @app.post("/accounts", response_class=HTMLResponse)
async def self_service_account(request: Request) -> str: 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") mode = _first_form_value(form, "mode")
username = _first_form_value(form, "username") or None username = _first_form_value(form, "username") or None
if mode == "managed": if mode == "managed":
@@ -256,13 +297,22 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
) )
return _render_public_home(service, settings, account, None) 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: 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: 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")) require_admin_csrf(settings, _first_form_value(form, "csrf"))
hostname = normalize_hostname(_first_form_value(form, "hostname"), settings) hostname = normalize_hostname(_first_form_value(form, "hostname"), settings)
username = _first_form_value(form, "username") or None 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) account = store.create_account(hostname, username)
except sqlite3.IntegrityError as exc: except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="hostname already exists") from 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: 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")) require_admin_csrf(settings, _first_form_value(form, "csrf"))
hostname = _first_form_value(form, "hostname") hostname = _first_form_value(form, "hostname")
if 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 PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
return RedirectResponse("/admin", status_code=303) 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]]: def records(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]:
return store.list_records() 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]]: def events(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]:
return store.list_update_events() 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) 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: def _first_form_value(form: dict[str, list[str]], key: str) -> str:
return form.get(key, [""])[0].strip() 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( def _render_public_home(
service: DdnsService, service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
@@ -559,6 +708,21 @@ def _created_account_panel(service: DdnsService, created_account: dict[str, str]
return _credentials_panel(service, created_account) return _credentials_panel(service, created_account)
def _admin_secret_panel(title: str, rows: list[str]) -> str:
return f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Admin action</p>
<h2>{html.escape(title)}</h2>
<p class="section-copy">Copy this now. Secret values are only shown in this response.</p>
<div class="copy-list">
{"".join(rows)}
</div>
</div>
</section>
"""
def _copy_row(label: str, value: str) -> str: def _copy_row(label: str, value: str) -> str:
safe_label = html.escape(label) safe_label = html.escape(label)
safe_value = html.escape(value) safe_value = html.escape(value)
@@ -659,11 +823,24 @@ def _render_admin_page(
service: DdnsService, service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
accounts: list[dict[str, str | int | None]], accounts: list[dict[str, str | int | None]],
domain_challenges: list[dict[str, str | None]],
cleanup_runs: list[dict[str, str | int]],
update_events: list[dict[str, str | None]],
created_account: dict[str, str] | None, created_account: dict[str, str] | None,
notice_html: str | None,
) -> str: ) -> str:
csrf = html.escape(admin_csrf_token(settings)) csrf = html.escape(admin_csrf_token(settings))
rows = "\n".join(_account_row(account, csrf=csrf) for account in accounts) or """ rows = "\n".join(_account_row(account, csrf=csrf) for account in accounts) or """
<tr><td colspan="6" class="empty">No hostnames yet.</td></tr> <tr><td colspan="7" class="empty">No hostnames yet.</td></tr>
"""
domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """
<tr><td colspan="4" class="empty">No domain claims yet.</td></tr>
"""
cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """
<tr><td colspan="3" class="empty">No cleanup runs yet.</td></tr>
"""
event_rows = "\n".join(_event_row(event) for event in update_events) or """
<tr><td colspan="6" class="empty">No update events yet.</td></tr>
""" """
suffix_help = ( suffix_help = (
f"Short names are expanded under {html.escape(settings.hostname_suffix)}." f"Short names are expanded under {html.escape(settings.hostname_suffix)}."
@@ -684,6 +861,21 @@ def _render_admin_page(
</section> </section>
{_created_account_panel(service, created_account)} {_created_account_panel(service, created_account)}
{notice_html or ""}
<section class="section">
<div class="container tool-grid">
<div>
<p class="eyebrow">Service health</p>
<h2>Scheduled cleanup</h2>
<p class="section-copy">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.</p>
</div>
<form method="post" action="/admin/cleanup" class="tool-card" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit">Run cleanup now</button>
</form>
</div>
</section>
<section class="section"> <section class="section">
<div class="container tool-grid"> <div class="container tool-grid">
@@ -718,13 +910,64 @@ def _render_admin_page(
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th></th></tr> <tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th>Links</th><th></th></tr>
</thead> </thead>
<tbody>{rows}</tbody> <tbody>{rows}</tbody>
</table> </table>
</div> </div>
</div> </div>
</section> </section>
<section class="section">
<div class="container">
<div class="section-heading">
<div>
<p class="eyebrow">Domain claims</p>
<h2>Verification lifecycle</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Domain</th><th>Status</th><th>Created</th><th>Verified</th></tr></thead>
<tbody>{domain_rows}</tbody>
</table>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="section-heading">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Recent runs</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>Domain claims removed</th><th>Unused hostnames removed</th></tr></thead>
<tbody>{cleanup_rows}</tbody>
</table>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="section-heading">
<div>
<p class="eyebrow">Updates</p>
<h2>Recent events</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>Domain</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
</div>
</section>
</main> </main>
""", """,
) )
@@ -739,6 +982,15 @@ def _account_row(account: dict[str, str | int | None], csrf: str = "") -> str:
return f""" return f"""
<tr> <tr>
<td>{hostname}</td><td>{username}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td> <td>{hostname}</td><td>{username}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
<td>
<form method="post" action="/admin/accounts/rotate" class="table-actions">
<input type="hidden" name="csrf" value="{html.escape(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="small-button" title="Rotate update URL" aria-label="Rotate update URL for {hostname}">Update URL</button>
<button name="action" value="management" class="small-button" title="Rotate management link" aria-label="Rotate management link for {hostname}">Manage link</button>
<button name="action" value="password" class="small-button" title="Rotate router password" aria-label="Rotate router password for {hostname}">Password</button>
</form>
</td>
<td> <td>
<form method="post" action="/admin/accounts/delete"> <form method="post" action="/admin/accounts/delete">
<input type="hidden" name="csrf" value="{html.escape(csrf)}"> <input type="hidden" name="csrf" value="{html.escape(csrf)}">
@@ -750,6 +1002,31 @@ def _account_row(account: dict[str, str | int | None], csrf: str = "") -> str:
""" """
def _domain_claim_row(challenge: dict[str, str | None]) -> str:
domain = html.escape(str(challenge["domain"]))
created = html.escape(str(challenge.get("created_at") or "-"))
verified = html.escape(str(challenge.get("verified_at") or "-"))
status = "Verified" if challenge.get("verified_at") else "Pending"
return f"<tr><td>{domain}</td><td>{status}</td><td>{created}</td><td>{verified}</td></tr>"
def _cleanup_row(run: dict[str, str | int]) -> str:
created = html.escape(str(run.get("created_at") or "-"))
domains = html.escape(str(run.get("domain_challenges_deleted") or 0))
accounts = html.escape(str(run.get("unused_accounts_deleted") or 0))
return f"<tr><td>{created}</td><td>{domains}</td><td>{accounts}</td></tr>"
def _event_row(event: dict[str, str | None]) -> str:
created = html.escape(str(event.get("created_at") or "-"))
hostname = html.escape(str(event.get("hostname") or "-"))
status = html.escape(str(event.get("status") or "-"))
ipv4 = html.escape(str(event.get("ipv4") or "-"))
ipv6 = html.escape(str(event.get("ipv6") or "-"))
detail = html.escape(str(event.get("detail") or "-"))
return f"<tr><td>{created}</td><td>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{detail}</td></tr>"
def _page(title: str, body: str) -> str: def _page(title: str, body: str) -> str:
return f""" return f"""
<!doctype html> <!doctype html>
@@ -859,6 +1136,9 @@ def _page(title: str, body: str) -> str:
.empty {{ color: var(--muted); text-align: center; padding: 28px; }} .empty {{ color: var(--muted); text-align: center; padding: 28px; }}
.icon-button {{ width: 32px; min-height: 32px; padding: 0; background: #f5f5f7; color: var(--danger); }} .icon-button {{ width: 32px; min-height: 32px; padding: 0; background: #f5f5f7; color: var(--danger); }}
.icon-button:hover {{ background: #fff1f0; }} .icon-button:hover {{ background: #fff1f0; }}
.table-actions {{ display: flex; gap: 6px; flex-wrap: wrap; }}
.small-button {{ min-height: 32px; padding: 0 11px; background: #f5f5f7; color: var(--blue); font-size: 12px; }}
.small-button:hover {{ background: #e8e8ed; }}
.footer {{ padding: 28px 0; background: var(--bg); border-top: 1px solid var(--soft-line); }} .footer {{ padding: 28px 0; background: var(--bg); border-top: 1px solid var(--soft-line); }}
.footer-links {{ display: flex; justify-content: center; gap: 18px; flex-wrap: wrap; }} .footer-links {{ display: flex; justify-content: center; gap: 18px; flex-wrap: wrap; }}
.footer-links a {{ color: var(--muted); font-size: 13px; text-decoration: none; }} .footer-links a {{ color: var(--muted); font-size: 13px; text-decoration: none; }}
+4
View File
@@ -16,6 +16,8 @@ class DdnsSettings(BaseModel):
hostname_suffix: str = Field(default="") hostname_suffix: str = Field(default="")
verification_prefix: str = Field(default="_router_dyndns-ddns") verification_prefix: str = Field(default="_router_dyndns-ddns")
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000) rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
admin_rate_limit_per_minute: int = Field(default=20, ge=1, le=10_000)
max_request_body_bytes: int = Field(default=16_384, ge=1024, le=1_048_576)
trusted_proxy_ips: set[str] = Field(default_factory=set) trusted_proxy_ips: set[str] = Field(default_factory=set)
rfc2136_server: str | None = None rfc2136_server: str | None = None
rfc2136_zone: str | None = None rfc2136_zone: str | None = None
@@ -56,6 +58,8 @@ class DdnsSettings(BaseModel):
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""), hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"), verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"),
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60), rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20),
max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
trusted_proxy_ips=trusted_proxy_ips, trusted_proxy_ips=trusted_proxy_ips,
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None, rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None, rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
+87
View File
@@ -122,6 +122,21 @@ class DdnsStore:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def get_account_by_hostname(self, hostname: str) -> dict[str, str | int | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
""",
(hostname,),
).fetchone()
return dict(account) if account else None
def get_account_by_slug(self, update_slug: str) -> dict[str, str | int | None] | None: def get_account_by_slug(self, update_slug: str) -> dict[str, str | int | None] | None:
with self.connect() as conn: with self.connect() as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@@ -170,6 +185,32 @@ class DdnsStore:
conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,)) conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,))
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,)) conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
def rotate_update_slug(self, hostname: str) -> dict[str, str | int | None] | None:
update_slug = secrets.token_urlsafe(32)
with self.connect() as conn:
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
return self.get_account_by_hostname(hostname)
def rotate_management_slug(self, hostname: str) -> dict[str, str | int | None] | None:
management_slug = secrets.token_urlsafe(32)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["management_slug"] = management_slug
return account
def rotate_password(self, hostname: str) -> dict[str, str | int | None] | None:
token = secrets.token_urlsafe(24)
token_hash = hash_secret(token)
with self.connect() as conn:
conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["password"] = token
return account
def create_domain_challenge(self, domain: str) -> dict[str, str]: def create_domain_challenge(self, domain: str) -> dict[str, str]:
token = f"ff-ddns-{secrets.token_urlsafe(24)}" token = f"ff-ddns-{secrets.token_urlsafe(24)}"
claim_secret = secrets.token_urlsafe(32) claim_secret = secrets.token_urlsafe(32)
@@ -229,6 +270,20 @@ class DdnsStore:
result["claim_secret"] = claim_secret result["claim_secret"] = claim_secret
return result return result
def list_domain_challenges(self, limit: int = 100) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT domain, created_at, verified_at
FROM domain_challenges
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def is_domain_verified(self, domain: str, claim_secret: str | None = None) -> bool: def is_domain_verified(self, domain: str, claim_secret: str | None = None) -> bool:
with self.connect() as conn: with self.connect() as conn:
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?" sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
@@ -277,6 +332,20 @@ class DdnsStore:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def list_cleanup_runs(self, limit: int = 20) -> list[dict[str, str | int]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
FROM cleanup_runs
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def allow_rate_limit(self, key: str, limit: int) -> bool: def allow_rate_limit(self, key: str, limit: int) -> bool:
now = int(time.time()) now = int(time.time())
window = now // 60 window = now // 60
@@ -318,6 +387,13 @@ class DdnsStore:
(unused_account_cutoff,), (unused_account_cutoff,),
) )
conn.execute("DELETE FROM rate_limits WHERE window < ?", (int(time.time()) - 3600,)) conn.execute("DELETE FROM rate_limits WHERE window < ?", (int(time.time()) - 3600,))
conn.execute(
"""
INSERT INTO cleanup_runs(created_at, domain_challenges_deleted, unused_accounts_deleted)
VALUES (?, ?, ?)
""",
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
)
return { return {
"domain_challenges": challenge_cursor.rowcount, "domain_challenges": challenge_cursor.rowcount,
"unused_accounts": account_cursor.rowcount, "unused_accounts": account_cursor.rowcount,
@@ -352,6 +428,17 @@ class DdnsStore:
""" """
) )
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
domain_challenges_deleted INTEGER NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS rate_limits ( CREATE TABLE IF NOT EXISTS rate_limits (
+105 -1
View File
@@ -6,6 +6,7 @@ from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
from router_dyndns.ddns_security import admin_csrf_token
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None: def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
@@ -152,16 +153,24 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
def test_admin_page_shows_account_form(tmp_path: Path) -> None: def test_admin_page_shows_account_form(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_domain_challenge("example.net")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app) client = TestClient(app)
response = client.get("/admin", auth=("admin", "admin")) response = client.get("/admin", auth=("admin", "admin"))
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert "Update-URL" in response.text assert "Update-URL" in response.text
assert "Domainnamen" in response.text assert "Domainnamen" in response.text
assert "Benutzername" in response.text assert "Benutzername" in response.text
assert "Kennwort" in response.text assert "Kennwort" in response.text
assert "Scheduled cleanup" in response.text
assert "Domain claims" in response.text
assert "Recent events" in response.text
assert "example.net" in response.text
def test_admin_posts_require_csrf(tmp_path: Path) -> None: def test_admin_posts_require_csrf(tmp_path: Path) -> None:
@@ -186,6 +195,63 @@ def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
assert response.status_code == 401 assert response.status_code == 401
def test_admin_routes_are_rate_limited(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.get("/admin", auth=("admin", "wrong"))
second = client.get("/admin", auth=("admin", "wrong"))
assert first.status_code == 401
assert second.status_code == 429
def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
store = DdnsStore(settings.database_path)
account = store.create_account("home.example.net", "router")
app = make_app(settings)
client = TestClient(app)
csrf = admin_csrf_token(settings)
update = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
)
assert update.status_code == 200
assert "Update URL rotated" in update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
management = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
)
assert management.status_code == 200
assert "Management link rotated" in management.text
assert "/m/" in management.text
password = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
)
assert password.status_code == 200
assert "Router password rotated" in password.text
assert "Kennwort:" in password.text
def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None: def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None:
app = make_app( app = make_app(
DdnsSettings( DdnsSettings(
@@ -325,7 +391,11 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
assert redoc.status_code == 200 assert redoc.status_code == 200
assert "/api/v1/hostnames/magic" in schema["paths"] assert "/api/v1/hostnames/magic" in schema["paths"]
assert "/api/v1/updates/{update_slug}" in schema["paths"] assert "/api/v1/updates/{update_slug}" in schema["paths"]
assert "/admin" not in schema["paths"]
assert "/records" not in schema["paths"]
assert "/events" not in schema["paths"]
assert any(tag["name"] == "hostnames" for tag in schema["tags"]) assert any(tag["name"] == "hostnames" for tag in schema["tags"])
assert all(tag["name"] != "admin" for tag in schema["tags"])
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None: def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
@@ -341,6 +411,7 @@ def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
created = client.post("/api/v1/hostnames/magic", json={"username": "router"}) created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert created.status_code == 201 assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
body = created.json() body = created.json()
assert body["hostname"].endswith(".ddns.example.net") assert body["hostname"].endswith(".ddns.example.net")
assert body["update_url"].startswith("http://ddns.example.net/u/") assert body["update_url"].startswith("http://ddns.example.net/u/")
@@ -438,6 +509,39 @@ def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
assert second.status_code == 429 assert second.status_code == 429
def test_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
created = client.post("/magic", data={"username": "router"})
assert created.status_code == 200
assert created.headers["cache-control"] == "no-store"
assert created.headers["pragma"] == "no-cache"
def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
max_request_body_bytes=1024,
)
)
client = TestClient(app)
response = client.post("/magic", data={"username": "x" * 2000})
assert response.status_code == 413
def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> None: def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> None:
app = make_app( app = make_app(
DdnsSettings( DdnsSettings(