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

-4
View File
@@ -41,10 +41,6 @@ OPENAPI_TAGS = [
"name": "router",
"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"
+4
View File
@@ -127,6 +127,7 @@ async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep,
summary="Read operator dashboard summary",
description="Requires admin HTTP Basic auth. Returns the same high-level data shown in the operator console.",
tags=["admin"],
include_in_schema=False,
responses={401: {"model": ErrorResponse}},
)
async def read_admin(
@@ -161,6 +162,7 @@ async def read_admin(
summary="List operator hostnames",
description="Requires admin HTTP Basic auth.",
tags=["admin"],
include_in_schema=False,
responses={401: {"model": ErrorResponse}},
)
async def list_admin_hostnames(
@@ -185,6 +187,7 @@ async def list_admin_hostnames(
summary="List operator update history",
description="Requires admin HTTP Basic auth. Supports offset pagination or keyset pagination with before.",
tags=["admin"],
include_in_schema=False,
responses={401: {"model": ErrorResponse}},
)
async def list_admin_events(
@@ -210,6 +213,7 @@ async def list_admin_events(
summary="List operator DNS records",
description="Requires admin HTTP Basic auth.",
tags=["admin"],
include_in_schema=False,
responses={401: {"model": ErrorResponse}},
)
async def list_admin_records(
+14 -2
View File
@@ -50,7 +50,7 @@ def manage_dashboard(
account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, 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):
return _render_management_unlock_page(management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None
@@ -79,22 +79,26 @@ async def activate_dashboard(
except BadIpError:
return HTMLResponse(
_render_pending_management_page(
service,
settings,
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.",
client_ip(request, settings),
),
status_code=400,
)
except DnsProviderError:
return HTMLResponse(
_render_pending_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"DNS registration failed. The hostname was not registered.",
client_ip(request, settings),
),
status_code=500,
)
@@ -124,7 +128,15 @@ async def rotate_dashboard_secrets(
account = await get_active_management_account_async(store, management_slug)
if not management_account_is_accepted(account):
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,
)
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,
csrf_token: str,
message: str | None = None,
created_account: dict[str, str] | None = None,
request_ip: str | None = None,
) -> str:
return await run_in_threadpool(
@@ -103,7 +102,6 @@ async def render_management_page_async(
account,
csrf_token,
message,
created_account,
request_ip,
)
+5 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
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 ..dependencies import ServiceDep, SettingsDep
@@ -13,15 +13,15 @@ router = APIRouter()
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
def index(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
return _render_public_home(service, settings, None, None, client_ip(request, settings))
def index(request: Request, settings: SettingsDep) -> str:
return _render_public_home(settings, None, client_ip(request, settings))
@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)
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)
+4 -13
View File
@@ -28,7 +28,7 @@ def _event_label(status: object) -> str:
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_value = escape(value)
badge = ""
@@ -37,14 +37,15 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtm
safe_sensitivity = escape(sensitivity)
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
should_mask = is_secret and mask_secret
value_field = (
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>'
)
reveal_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 ""
)
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:
if not message:
return trusted("")
+55 -9
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import (
_code_window,
_copy_menu,
_copy_row,
_event_label,
_example_update_ips,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
@@ -13,7 +15,6 @@ from .components import (
)
from .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav
from .templates_public_sections import _created_hostname_panel
def _render_management_page(
@@ -23,7 +24,6 @@ def _render_management_page(
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
created_account: dict[str, str] | None = None,
request_ip: str | None = None,
) -> str:
account_for_url = {
@@ -43,7 +43,6 @@ def _render_management_page(
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
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")))
return _page(
"KarlDNS management",
@@ -59,7 +58,6 @@ def _render_management_page(
</div>
</section>
{message_html}
{rotated_links}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
@@ -149,18 +147,31 @@ def _render_management_page(
)
def _render_pending_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
) -> 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)
cleanup_hours = settings.cleanup_pending_account_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(
"Register hostname",
"KarlDNS setup",
trusted(f"""
<main id="main">
{_top_nav()}
@@ -168,7 +179,7 @@ def _render_pending_management_page(
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Pending setup</p>
<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">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
@@ -176,9 +187,44 @@ def _render_pending_management_page(
</div>
</section>
{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">
<div class="container narrow text-center">
<p class="section-copy mx-auto">Lost the first page? Create a new hostname. Private URLs are shown only once.</p>
<div class="container narrow d-grid gap-4">
<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>
</section>
</main>
+4 -19
View File
@@ -1,17 +1,14 @@
from __future__ import annotations
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import _code_window, _example_update_ips, _message_band
from .html_utils import escape, trusted
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(
service: DdnsService,
settings: DdnsSettings,
created_account: dict[str, str] | None,
message: str | None,
request_ip: str | None = None,
) -> str:
@@ -30,7 +27,6 @@ def _render_public_home(
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 ""
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(
@@ -55,7 +51,6 @@ def _render_public_home(
</div>
</section>
{message_html}
{created_html}
<section class="section section-compact">
<div class="container narrow text-center">
<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'"
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"
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."
return _page(
"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">
<p class="eyebrow">API</p>
<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>
</section>
<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">
<h2>Endpoints</h2>
<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/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>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>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>
</div>
<div>
@@ -223,15 +214,9 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
</div>
<div>
<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")}
</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>
</section>
</main>
+2 -55
View File
@@ -1,18 +1,7 @@
from __future__ import annotations
from ..security import dashboard_csrf_token
from ..services.ddns import DdnsService
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
from .components import _code_window
from .html_utils import SafeHtml, trusted
def _linux_installer_section(base_url: str) -> SafeHtml:
@@ -44,45 +33,3 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
</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>
"""
)