Simplify setup flow and public API docs

This commit is contained in:
StasonJatham committed 2026-05-21 17:58:20 +02:00
1 parent 2e401124e8
commit 97a76aa91e
12 files changed
+119 -140

No files matched your search

+6 -17
View File
@@ -17,7 +17,7 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control
- **CNAME alias support**: users create one KarlDNS hostname, then point `home.example.com` to it at their own DNS provider. - **CNAME alias support**: users create one KarlDNS hostname, then point `home.example.com` to it at their own DNS provider.
- **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support. - **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support.
- **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform. - **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
- **API first**: small built-in API guide, OpenAPI JSON, and endpoints under `/api/v1`. - **API first**: small built-in API guide, public OpenAPI JSON, and endpoints under `/api/v1`.
## Screenshots ## Screenshots
@@ -44,7 +44,7 @@ KarlDNS lets you host your own managed DynDNS service:
1. A user opens your KarlDNS URL. 1. A user opens your KarlDNS URL.
2. They generate a random hostname like `a1b2c3d4.karldns.de`. 2. They generate a random hostname like `a1b2c3d4.karldns.de`.
3. KarlDNS shows the exact router settings and one-time secrets. 3. KarlDNS shows the exact router settings and private values.
4. The user confirms they saved the values, which registers the hostname. 4. The user confirms they saved the values, which registers the hostname.
5. The router periodically calls the update URL with its current public IPv4/IPv6 address. 5. The router periodically calls the update URL with its current public IPv4/IPv6 address.
6. KarlDNS publishes the matching DNS records through your configured DNS backend. 6. KarlDNS publishes the matching DNS records through your configured DNS backend.
@@ -71,7 +71,7 @@ That means users always have a current hostname for VPN, remote access, self-hos
| Generated hostnames | Random provider-owned names that can be used directly or as CNAME targets | | Generated hostnames | Random provider-owned names that can be used directly or as CNAME targets |
| User-owned DNS names | CNAME only. KarlDNS publishes the generated provider-owned hostname; the user's DNS provider serves the alias | | User-owned DNS names | CNAME only. KarlDNS publishes the generated provider-owned hostname; the user's DNS provider serves the alias |
| Persistence | SQLite with WAL mode | | Persistence | SQLite with WAL mode |
| API | FastAPI, small API docs page, OpenAPI JSON | | API | FastAPI, small API docs page, public OpenAPI JSON |
| Auth model | No user accounts. Private bearer links for hostnames and dashboards | | Auth model | No user accounts. Private bearer links for hostnames and dashboards |
| Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example | | Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example |
@@ -106,7 +106,7 @@ 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 API guide documents the public endpoints plus the authenticated operator API. OpenAPI JSON remains available for tools. The API guide and OpenAPI JSON document the user and router endpoints only. Admin endpoints stay authenticated and are not exposed in the public schema.
## Production Setup ## Production Setup
@@ -283,7 +283,7 @@ The generated hostname is not a secret. Treat it like any public DNS name. The u
## HTTP API ## HTTP API
The JSON API is available under `/api/v1`. The public API has three surfaces: admin data, user dashboard data with history, and router registration/update calls. The JSON API is available under `/api/v1`. The public API has two surfaces: user dashboard data with history and router registration/update calls.
| Endpoint | Purpose | | Endpoint | Purpose |
| --- | --- | | --- | --- |
@@ -293,10 +293,6 @@ The JSON API is available under `/api/v1`. The public API has three surfaces: ad
| `DELETE /api/v1/hostnames/{dashboard_secret}` | Delete a hostname and its DNS records. | | `DELETE /api/v1/hostnames/{dashboard_secret}` | Delete a hostname and its DNS records. |
| `GET or POST /api/v1/router/register/{dashboard_secret}` | Register a pending hostname after secrets are saved. | | `GET or POST /api/v1/router/register/{dashboard_secret}` | Register a pending hostname after secrets are saved. |
| `GET or POST /api/v1/router/update/{update_secret}` | Update a hostname from FRITZ!Box, another router, curl, or a cron script. | | `GET or POST /api/v1/router/update/{update_secret}` | Update a hostname from FRITZ!Box, another router, curl, or a cron script. |
| `GET /api/v1/admin/dashboard` | Authenticated operator summary, metrics, cleanup state, and recent history. |
| `GET /api/v1/admin/events` | Authenticated paginated update history. |
| `GET /api/v1/admin/hostnames` | Authenticated paginated hostname inventory. |
| `GET /api/v1/admin/records` | Authenticated paginated DNS record inventory. |
Example: Example:
@@ -314,13 +310,6 @@ curl -sS -X POST https://karldns.de/api/v1/hostnames/<dashboard-secret>/rotate-u
The response contains a replacement `update_url`. The old update URL stops working immediately. The response contains a replacement `update_url`. The old update URL stops working immediately.
Operator API calls use the same HTTP Basic auth password as the admin UI:
```bash
curl -sS -u admin:<admin-password> https://karldns.de/api/v1/admin/dashboard
curl -sS -u admin:<admin-password> 'https://karldns.de/api/v1/admin/events?limit=25'
```
## Docker ## Docker
Copy the example environment file and replace every placeholder: Copy the example environment file and replace every placeholder:
@@ -349,7 +338,7 @@ The default container command runs one Uvicorn worker. Keep that deployment shap
- Use a least-privilege DNS API token or TSIG key. - Use a least-privilege DNS API token or TSIG key.
- Set `DDNS_TRUSTED_HOSTS` only when the app should answer for hostnames beyond `DDNS_PUBLIC_BASE_URL`. - Set `DDNS_TRUSTED_HOSTS` only when the app should answer for hostnames beyond `DDNS_PUBLIC_BASE_URL`.
- 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` available if you want the small developer surface. Operator API routes are documented and require Basic auth; admin UI form routes stay hidden. - Keep `/docs` available if you want the small developer surface. It documents only the public user and router endpoints.
- Leave cleanup enabled. The cleanup scheduler is stored in SQLite with a task row, next run time, status, last error, and a database lease so only one app process runs a due cleanup job. - Leave cleanup enabled. The cleanup scheduler is stored in SQLite with a task row, next run time, status, last error, and a database lease so only one app process runs a due cleanup job.
- Back up `/data/ddns.sqlite3`. - Back up `/data/ddns.sqlite3`.
- Run one Uvicorn worker with SQLite unless you have tested your deployment under load. Scheduled cleanup is lease-protected across workers, but rate-limit state and per-host update locks are process-local. - Run one Uvicorn worker with SQLite unless you have tested your deployment under load. Scheduled cleanup is lease-protected across workers, but rate-limit state and per-host update locks are process-local.
-4
View File
@@ -41,10 +41,6 @@ OPENAPI_TAGS = [
"name": "router", "name": "router",
"description": "Router-facing registration and IP update endpoints for simple URL clients.", "description": "Router-facing registration and IP update endpoints for simple URL clients.",
}, },
{
"name": "admin",
"description": "Operator API endpoints protected by the same admin HTTP Basic auth as the web admin console.",
},
] ]
CLEANUP_TASK_NAME = "cleanup" CLEANUP_TASK_NAME = "cleanup"
+4
View File
@@ -127,6 +127,7 @@ async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep,
summary="Read operator dashboard summary", summary="Read operator dashboard summary",
description="Requires admin HTTP Basic auth. Returns the same high-level data shown in the operator console.", description="Requires admin HTTP Basic auth. Returns the same high-level data shown in the operator console.",
tags=["admin"], tags=["admin"],
include_in_schema=False,
responses={401: {"model": ErrorResponse}}, responses={401: {"model": ErrorResponse}},
) )
async def read_admin( async def read_admin(
@@ -161,6 +162,7 @@ async def read_admin(
summary="List operator hostnames", summary="List operator hostnames",
description="Requires admin HTTP Basic auth.", description="Requires admin HTTP Basic auth.",
tags=["admin"], tags=["admin"],
include_in_schema=False,
responses={401: {"model": ErrorResponse}}, responses={401: {"model": ErrorResponse}},
) )
async def list_admin_hostnames( async def list_admin_hostnames(
@@ -185,6 +187,7 @@ async def list_admin_hostnames(
summary="List operator update history", summary="List operator update history",
description="Requires admin HTTP Basic auth. Supports offset pagination or keyset pagination with before.", description="Requires admin HTTP Basic auth. Supports offset pagination or keyset pagination with before.",
tags=["admin"], tags=["admin"],
include_in_schema=False,
responses={401: {"model": ErrorResponse}}, responses={401: {"model": ErrorResponse}},
) )
async def list_admin_events( async def list_admin_events(
@@ -210,6 +213,7 @@ async def list_admin_events(
summary="List operator DNS records", summary="List operator DNS records",
description="Requires admin HTTP Basic auth.", description="Requires admin HTTP Basic auth.",
tags=["admin"], tags=["admin"],
include_in_schema=False,
responses={401: {"model": ErrorResponse}}, responses={401: {"model": ErrorResponse}},
) )
async def list_admin_records( async def list_admin_records(
+14 -2
View File
@@ -50,7 +50,7 @@ def manage_dashboard(
account = get_active_management_account(store, management_slug) account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account) csrf_token = dashboard_csrf_token(management_slug, account)
if not management_account_is_accepted(account): if not management_account_is_accepted(account):
return _render_pending_management_page(settings, management_slug, account, csrf_token) return _render_pending_management_page(service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings))
if not has_management_access(request, settings, management_slug, account): if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token) return _render_management_unlock_page(management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None message = "Hostname registered. Add the update URL to your router." if activated else None
@@ -79,22 +79,26 @@ async def activate_dashboard(
except BadIpError: except BadIpError:
return HTMLResponse( return HTMLResponse(
_render_pending_management_page( _render_pending_management_page(
service,
settings, settings,
management_slug, management_slug,
account, account,
dashboard_csrf_token(management_slug, account), dashboard_csrf_token(management_slug, account),
"Registration needs a public source IP. Try again from your home connection or use the API with myip.", "Registration needs a public source IP. Try again from your home connection or use the API with myip.",
client_ip(request, settings),
), ),
status_code=400, status_code=400,
) )
except DnsProviderError: except DnsProviderError:
return HTMLResponse( return HTMLResponse(
_render_pending_management_page( _render_pending_management_page(
service,
settings, settings,
management_slug, management_slug,
account, account,
dashboard_csrf_token(management_slug, account), dashboard_csrf_token(management_slug, account),
"DNS registration failed. The hostname was not registered.", "DNS registration failed. The hostname was not registered.",
client_ip(request, settings),
), ),
status_code=500, status_code=500,
) )
@@ -124,7 +128,15 @@ async def rotate_dashboard_secrets(
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
if not management_account_is_accepted(account): if not management_account_is_accepted(account):
return HTMLResponse( return HTMLResponse(
_render_pending_management_page(settings, management_slug, account, dashboard_csrf_token(management_slug, account), "Register this hostname first."), _render_pending_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"Register this hostname first.",
client_ip(request, settings),
),
status_code=400, status_code=400,
) )
if not has_management_access(request, settings, management_slug, account): if not has_management_access(request, settings, management_slug, account):
-2
View File
@@ -92,7 +92,6 @@ async def render_management_page_async(
account: ManagementAccount, account: ManagementAccount,
csrf_token: str, csrf_token: str,
message: str | None = None, message: str | None = None,
created_account: dict[str, str] | None = None,
request_ip: str | None = None, request_ip: str | None = None,
) -> str: ) -> str:
return await run_in_threadpool( return await run_in_threadpool(
@@ -103,7 +102,6 @@ async def render_management_page_async(
account, account,
csrf_token, csrf_token,
message, message,
created_account,
request_ip, request_ip,
) )
+5 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, Response from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, SettingsDep from ..dependencies import ServiceDep, SettingsDep
@@ -13,15 +13,15 @@ router = APIRouter()
@router.get("/", response_class=HTMLResponse, include_in_schema=False) @router.get("/", response_class=HTMLResponse, include_in_schema=False)
def index(request: Request, settings: SettingsDep, service: ServiceDep) -> str: def index(request: Request, settings: SettingsDep) -> str:
return _render_public_home(service, settings, None, None, client_ip(request, settings)) return _render_public_home(settings, None, client_ip(request, settings))
@router.post("/", response_class=HTMLResponse, include_in_schema=False) @router.post("/", response_class=HTMLResponse, include_in_schema=False)
async def create_hostname(request: Request, settings: SettingsDep, service: ServiceDep) -> str: async def create_hostname(request: Request, settings: SettingsDep, service: ServiceDep) -> RedirectResponse:
await request_form(request, settings) await request_form(request, settings)
account = await run_in_threadpool(service.create_managed_account) account = await run_in_threadpool(service.create_managed_account)
return _render_public_home(service, settings, account, None, client_ip(request, settings)) return RedirectResponse(service.dashboard_url(account), status_code=303)
@router.get("/healthz", response_class=PlainTextResponse, include_in_schema=False) @router.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
+4 -13
View File
@@ -28,7 +28,7 @@ def _event_label(status: object) -> str:
return _EVENT_LABELS.get(value, value.replace("_", " ")) return _EVENT_LABELS.get(value, value.replace("_", " "))
def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtml: def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
safe_label = escape(label) safe_label = escape(label)
safe_value = escape(value) safe_value = escape(value)
badge = "" badge = ""
@@ -37,14 +37,15 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtm
safe_sensitivity = escape(sensitivity) safe_sensitivity = escape(sensitivity)
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary" badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>' badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
should_mask = is_secret and mask_secret
value_field = ( value_field = (
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>' f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
if is_secret if should_mask
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>' else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
) )
reveal_button = ( reveal_button = (
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>' f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>'
if is_secret if should_mask
else "" else ""
) )
return trusted( return trusted(
@@ -194,16 +195,6 @@ def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
) )
def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the private URLs now. Newly created or rotated secrets are shown only once.
</div>
"""
)
def _message_band(message: str | None) -> SafeHtml: def _message_band(message: str | None) -> SafeHtml:
if not message: if not message:
return trusted("") return trusted("")
+55 -9
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
from ..services.ddns import DdnsService from ..services.ddns import DdnsService
from ..settings import DdnsSettings from ..settings import DdnsSettings
from .components import ( from .components import (
_code_window,
_copy_menu, _copy_menu,
_copy_row, _copy_row,
_event_label, _event_label,
_example_update_ips,
_fritzbox_fields, _fritzbox_fields,
_manual_update_commands, _manual_update_commands,
_manual_update_panel, _manual_update_panel,
@@ -13,7 +15,6 @@ from .components import (
) )
from .html_utils import SafeHtml, attr, escape, trusted from .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav from .layout import _page, _top_nav
from .templates_public_sections import _created_hostname_panel
def _render_management_page( def _render_management_page(
@@ -23,7 +24,6 @@ def _render_management_page(
account: dict[str, str | int | None], account: dict[str, str | int | None],
csrf_token: str, csrf_token: str,
message: str | None = None, message: str | None = None,
created_account: dict[str, str] | None = None,
request_ip: str | None = None, request_ip: str | None = None,
) -> str: ) -> str:
account_for_url = { account_for_url = {
@@ -43,7 +43,6 @@ def _render_management_page(
<tr><td colspan="6" class="empty">No router updates yet.</td></tr> <tr><td colspan="6" class="empty">No router updates yet.</td></tr>
""" """
message_html = _management_message(message) message_html = _management_message(message)
rotated_links = _created_hostname_panel(service, created_account, request_ip)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash"))) dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
return _page( return _page(
"KarlDNS management", "KarlDNS management",
@@ -59,7 +58,6 @@ def _render_management_page(
</div> </div>
</section> </section>
{message_html} {message_html}
{rotated_links}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="row g-4 align-items-start"> <div class="row g-4 align-items-start">
@@ -149,18 +147,31 @@ def _render_management_page(
) )
def _render_pending_management_page( def _render_pending_management_page(
service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
management_slug: str, management_slug: str,
account: dict[str, str | int | None], account: dict[str, str | int | None],
csrf_token: str, csrf_token: str,
message: str | None = None, message: str | None = None,
request_ip: str | None = None,
) -> str: ) -> str:
hostname = escape(account["hostname"]) raw_hostname = str(account["hostname"])
account_for_url = {
"hostname": raw_hostname,
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
registration_url = service.registration_url({"management_slug": management_slug})
dashboard_url = service.dashboard_url({"management_slug": management_slug})
hostname = escape(raw_hostname)
message_html = _management_message(message) message_html = _management_message(message)
cleanup_hours = settings.cleanup_pending_account_hours cleanup_hours = settings.cleanup_pending_account_hours
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours" cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
manual_commands = _manual_update_commands(update_url, request_ip)
example_ipv4, example_ipv6 = _example_update_ips(request_ip)
return _page( return _page(
"Register hostname", "KarlDNS setup",
trusted(f""" trusted(f"""
<main id="main"> <main id="main">
{_top_nav()} {_top_nav()}
@@ -168,7 +179,7 @@ def _render_pending_management_page(
<div class="container text-center d-grid justify-items-center gap-3"> <div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Pending setup</p> <p class="eyebrow">Pending setup</p>
<h1>{hostname}</h1> <h1>{hostname}</h1>
<p class="lead">Register this hostname after you have copied the private values. Unregistered setups are cleaned up after {escape(cleanup_text)}.</p> <p class="lead">Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.</p>
<form method="post" action="/m/{attr(management_slug)}/activate"> <form method="post" action="/m/{attr(management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}"> <input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button> <button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
@@ -176,9 +187,44 @@ def _render_pending_management_page(
</div> </div>
</section> </section>
{message_html} {message_html}
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">Save</p>
<h2>Your values.</h2>
<p class="section-copy">This page is private. You can revisit it while the setup is pending. After registration, the dashboard hides secrets by default.</p>
<p class="section-copy"><strong>Public:</strong> hostname and CNAME target. <strong>Secret:</strong> update URL, register URL, and private dashboard.</p>
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
{_copy_row("Domainnamen:", raw_hostname, "Public")}
{_copy_row("Register URL:", registration_url, "Secret", mask_secret=False)}
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
{_copy_row("CNAME target:", raw_hostname, "Public")}
</div>
<form method="post" action="/m/{attr(management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
</div>
</section>
<section class="section section-compact"> <section class="section section-compact">
<div class="container narrow text-center"> <div class="container narrow d-grid gap-4">
<p class="section-copy mx-auto">Lost the first page? Create a new hostname. Private URLs are shown only once.</p> <div>
<p class="eyebrow">FRITZ!Box</p>
<h2>Router setup.</h2>
<p class="section-copy">Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field. Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>. No KarlDNS username or password is needed; if your router requires them, enter any placeholder.</p>
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
</div>
<div>
<h2>Manual updates.</h2>
<p class="section-copy">Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP. Your current public examples use <code>{escape(example_ipv4)}</code> and <code>{escape(example_ipv6)}</code> where available.</p>
{_code_window(manual_commands, copy_label="manual update commands", class_name="mt-3")}
</div>
<div>
<h2>Use your own DNS name.</h2>
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. KarlDNS only updates this generated hostname.</p>
</div>
</div> </div>
</section> </section>
</main> </main>
+4 -19
View File
@@ -1,17 +1,14 @@
from __future__ import annotations from __future__ import annotations
from ..services.ddns import DdnsService
from ..settings import DdnsSettings from ..settings import DdnsSettings
from .components import _code_window, _example_update_ips, _message_band from .components import _code_window, _example_update_ips, _message_band
from .html_utils import escape, trusted from .html_utils import escape, trusted
from .layout import _page, _top_nav from .layout import _page, _top_nav
from .templates_public_sections import _created_hostname_panel, _linux_installer_section from .templates_public_sections import _linux_installer_section
def _render_public_home( def _render_public_home(
service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
created_account: dict[str, str] | None,
message: str | None, message: str | None,
request_ip: str | None = None, request_ip: str | None = None,
) -> str: ) -> str:
@@ -30,7 +27,6 @@ def _render_public_home(
f"curl -sS '{base_url}/api/v1/router/update/<secret>'", f"curl -sS '{base_url}/api/v1/router/update/<secret>'",
] ]
) )
created_html = _created_hostname_panel(service, created_account, request_ip)
message_html = _message_band(message) if message else "" message_html = _message_band(message) if message else ""
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks." description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
return _page( return _page(
@@ -55,7 +51,6 @@ def _render_public_home(
</div> </div>
</section> </section>
{message_html} {message_html}
{created_html}
<section class="section section-compact"> <section class="section section-compact">
<div class="container narrow text-center"> <div class="container narrow text-center">
<p class="eyebrow">How it works</p> <p class="eyebrow">How it works</p>
@@ -169,8 +164,6 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<dashboard-secret>?history_limit=10'" status_example = f"curl -sS '{base_url}/api/v1/hostnames/<dashboard-secret>?history_limit=10'"
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<dashboard-secret>/rotate-update-url" rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<dashboard-secret>/rotate-update-url"
schema_example = f"curl -sS {base_url}/openapi.json" schema_example = f"curl -sS {base_url}/openapi.json"
admin_dashboard_example = f"curl -sS -u admin:<admin-password> {base_url}/api/v1/admin/dashboard"
admin_history_example = f"curl -sS -u admin:<admin-password> '{base_url}/api/v1/admin/events?limit=25'"
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames." description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
return _page( return _page(
"KarlDNS API", "KarlDNS API",
@@ -181,7 +174,7 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
<div class="container text-center d-grid justify-items-center gap-3"> <div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">API</p> <p class="eyebrow">API</p>
<h1>Small API surface.</h1> <h1>Small API surface.</h1>
<p class="lead">Three surfaces: admin API, user dashboard data, and router registration/update calls.</p> <p class="lead">Create a hostname, register it, update it, and read its status.</p>
</div> </div>
</section> </section>
<section class="section section-compact"> <section class="section section-compact">
@@ -189,14 +182,12 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
<div class="api-endpoints"> <div class="api-endpoints">
<h2>Endpoints</h2> <h2>Endpoints</h2>
<ol class="simple-steps text-start"> <ol class="simple-steps text-start">
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and one-time secrets. The response includes <code>registration_url</code>.</li> <li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and private setup values. The response includes <code>registration_url</code>.</li>
<li><strong>GET or POST /api/v1/router/register/&lt;secret&gt;.</strong> Register the hostname after secrets are saved. Uses the request IP, or <code>myip</code>/<code>myipv6</code>, for the initial DNS record.</li> <li><strong>GET or POST /api/v1/router/register/&lt;secret&gt;.</strong> Register the hostname after secrets are saved. Uses the request IP, or <code>myip</code>/<code>myipv6</code>, for the initial DNS record.</li>
<li><strong>GET or POST /api/v1/router/update/&lt;secret&gt;.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li> <li><strong>GET or POST /api/v1/router/update/&lt;secret&gt;.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
<li><strong>GET /api/v1/hostnames/&lt;secret&gt;.</strong> Read hostname status and recent history.</li> <li><strong>GET /api/v1/hostnames/&lt;secret&gt;.</strong> Read hostname status and recent history.</li>
<li><strong>POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li> <li><strong>POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li>
<li><strong>DELETE /api/v1/hostnames/&lt;secret&gt;.</strong> Delete hostname and DNS records.</li> <li><strong>DELETE /api/v1/hostnames/&lt;secret&gt;.</strong> Delete hostname and DNS records.</li>
<li><strong>GET /api/v1/admin/dashboard.</strong> Read operator summary, metrics, recent hostnames, cleanup state, and recent update history with admin Basic auth.</li>
<li><strong>GET /api/v1/admin/events.</strong> Read paginated update history with admin Basic auth. Also available: <code>/api/v1/admin/hostnames</code> and <code>/api/v1/admin/records</code>.</li>
</ol> </ol>
</div> </div>
<div> <div>
@@ -223,15 +214,9 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
</div> </div>
<div> <div>
<h2>Schema</h2> <h2>Schema</h2>
<p class="section-copy">Machine-readable OpenAPI is still available. Operator API routes are included and require admin Basic auth.</p> <p class="section-copy">Machine-readable OpenAPI is available for the public hostname and router endpoints.</p>
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")} {_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
</div> </div>
<div>
<h2>Operator API</h2>
<p class="section-copy">Admin endpoints use the same HTTP Basic auth as the operator console.</p>
{_code_window(admin_dashboard_example, copy_label="admin dashboard command", class_name="mt-3")}
{_code_window(admin_history_example, copy_label="admin history command", class_name="mt-3")}
</div>
</div> </div>
</section> </section>
</main> </main>
+2 -55
View File
@@ -1,18 +1,7 @@
from __future__ import annotations from __future__ import annotations
from ..security import dashboard_csrf_token from .components import _code_window
from ..services.ddns import DdnsService from .html_utils import SafeHtml, trusted
from .components import (
_code_window,
_copy_menu,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_secret_notice,
)
from .html_utils import SafeHtml, attr, escape, trusted
def _linux_installer_section(base_url: str) -> SafeHtml: def _linux_installer_section(base_url: str) -> SafeHtml:
@@ -44,45 +33,3 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
</section> </section>
""" """
) )
def _created_hostname_panel(service: DdnsService, created_account: dict[str, str] | None, request_ip: str | None = None) -> SafeHtml:
if not created_account:
return trusted("")
hostname = created_account["hostname"]
update_url = service.fritz_update_url(created_account)
management_url = service.dashboard_url(created_account)
registration_url = service.registration_url(created_account)
csrf_token = dashboard_csrf_token(created_account["management_slug"], created_account)
management_slug = attr(created_account["management_slug"])
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
fritzbox_fields = _fritzbox_fields(update_url, hostname, management_url)
manual_commands = _manual_update_commands(update_url, request_ip)
return trusted(
f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Pending setup</p>
<h2>{escape(hostname)}</h2>
<p class="section-copy">Copy these values now. Then register the hostname so router updates can start. Unregistered setups are removed after 24 hours.</p>
{_one_time_secret_notice()}
{_secret_notice(include_registration=True)}
<div class="copy-actions my-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Register URL", registration_url), ("CNAME", cname_record)])}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Register URL:", registration_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Private dashboard:", management_url, "Secret")}
</div>
<form class="mt-4" method="post" action="/m/{management_slug}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
{_manual_update_panel(manual_commands)}
</div>
</section>
"""
)
+14 -8
View File
@@ -35,8 +35,9 @@ def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> No
assert "GET /api/v1/hostnames/&lt;secret&gt;" in docs.text assert "GET /api/v1/hostnames/&lt;secret&gt;" in docs.text
assert "POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url" in docs.text assert "POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url" in docs.text
assert "Rotate update URL" in docs.text assert "Rotate update URL" in docs.text
assert "GET /api/v1/admin/dashboard" in docs.text assert "Operator API" not in docs.text
assert "GET /api/v1/admin/events" in docs.text assert "/api/v1/admin" not in docs.text
assert "admin:&lt;admin-password&gt;" not in docs.text
assert "https://karldns.de/api/v1/hostnames" in docs.text assert "https://karldns.de/api/v1/hostnames" in docs.text
assert "http://localhost:8080/api/v1/hostnames" not in docs.text assert "http://localhost:8080/api/v1/hostnames" not in docs.text
assert "registration_url" in docs.text assert "registration_url" in docs.text
@@ -52,10 +53,6 @@ def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> No
assert "fonts.googleapis.com" not in docs.headers["content-security-policy"] assert "fonts.googleapis.com" not in docs.headers["content-security-policy"]
expected_paths = { expected_paths = {
"/api/v1/admin/dashboard",
"/api/v1/admin/events",
"/api/v1/admin/hostnames",
"/api/v1/admin/records",
"/api/v1/hostnames", "/api/v1/hostnames",
"/api/v1/hostnames/{dashboard_secret}", "/api/v1/hostnames/{dashboard_secret}",
"/api/v1/hostnames/{dashboard_secret}/rotate-update-url", "/api/v1/hostnames/{dashboard_secret}/rotate-update-url",
@@ -70,7 +67,12 @@ def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> No
assert "/events" 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 any(tag["name"] == "router" for tag in schema["tags"]) assert any(tag["name"] == "router" for tag in schema["tags"])
assert any(tag["name"] == "admin" for tag in schema["tags"]) assert all(tag["name"] != "admin" for tag in schema["tags"])
schema_names = set(schema["components"]["schemas"])
assert "AdminDashboardResponse" not in schema_names
assert "AdminHostnamesResponse" not in schema_names
assert "AdminEventsResponse" not in schema_names
assert "AdminRecordsResponse" not in schema_names
def test_custom_api_docs_use_visitor_public_ip(tmp_path: Path) -> None: def test_custom_api_docs_use_visitor_public_ip(tmp_path: Path) -> None:
@@ -110,10 +112,14 @@ def test_openapi_hides_custom_operator_routes(tmp_path: Path) -> None:
"/ops/records", "/ops/records",
"/records", "/records",
"/events", "/events",
"/api/v1/admin/dashboard",
"/api/v1/admin/events",
"/api/v1/admin/hostnames",
"/api/v1/admin/records",
} }
assert client.get("/redoc").status_code == 404 assert client.get("/redoc").status_code == 404
assert forbidden_paths.isdisjoint(schema["paths"]) assert forbidden_paths.isdisjoint(schema["paths"])
assert "/api/v1/admin/dashboard" in schema["paths"] assert not any(path.startswith("/api/v1/admin") for path in schema["paths"])
def test_openapi_marks_generated_links_as_secret(tmp_path: Path) -> None: def test_openapi_marks_generated_links_as_secret(tmp_path: Path) -> None:
+11 -6
View File
@@ -26,11 +26,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
response = client.post("/") response = client.post("/")
assert response.status_code == 200 assert response.status_code == 200
assert "/m/" in str(response.url)
assert "Update-URL:" in response.text assert "Update-URL:" in response.text
assert "CNAME target:" in response.text assert "CNAME target:" in response.text
assert "Register URL:" in response.text assert "Register URL:" in response.text
assert "Router fields" in response.text assert "Router setup." in response.text
assert "CNAME" in response.text assert "Use your own DNS name." in response.text
assert "Manual updates" in response.text assert "Manual updates" in response.text
assert "curl -sS" in response.text assert "curl -sS" in response.text
assert "203.0.113.10" in response.text assert "203.0.113.10" in response.text
@@ -41,6 +42,8 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert "/api/v1/router/update/" in response.text assert "/api/v1/router/update/" in response.text
assert "/api/v1/router/register/" in response.text assert "/api/v1/router/register/" in response.text
assert "/m/" in response.text assert "/m/" in response.text
assert 'type="password"' not in response.text
assert "Copy setup values" not in response.text
def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None: def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
@@ -74,20 +77,22 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P
response = client.post("/") response = client.post("/")
assert response.status_code == 200 assert response.status_code == 200
assert "/m/" in str(response.url)
assert "Update-URL:" in response.text assert "Update-URL:" in response.text
assert "CNAME target:" in response.text assert "CNAME target:" in response.text
assert "Register URL:" in response.text assert "Register URL:" in response.text
assert "Private dashboard:" in response.text assert "Private dashboard:" in response.text
assert "Pending setup" in response.text assert "Pending setup" in response.text
assert "Register hostname" in response.text assert "Register hostname" in response.text
assert "Unregistered setups are removed after 24 hours." in response.text assert "Pending setups are cleaned up after 24 hours." in response.text
assert "Router fields" in response.text assert "Router fields" not in response.text
assert "CNAME" in response.text assert "Copy setup values" not in response.text
assert "FRITZ!Box fills in" in response.text assert "Paste this exact URL into the FRITZ!Box" in response.text
assert "Copy manual update commands" in response.text assert "Copy manual update commands" in response.text
assert response.text.count("Secret") >= 3 assert response.text.count("Secret") >= 3
assert response.text.count("Public") >= 3 assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text assert ".ddns.example.net" in response.text
assert 'type="password"' not in response.text
def test_public_examples_use_visitor_public_ip(tmp_path: Path) -> None: def test_public_examples_use_visitor_public_ip(tmp_path: Path) -> None: