Archived
Harden admin operations and API docs
This commit is contained in:
1 parent
f8e3d0de46
commit
7edc79971b
6 files changed
+511
-25
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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; }}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user