Archived
Simplify setup flow and public API docs
This commit is contained in:
1 parent
2e401124e8
commit
97a76aa91e
12 files changed
+119
-140
No files matched your search
@@ -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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -44,7 +44,7 @@ KarlDNS lets you host your own managed DynDNS service:
|
||||
|
||||
1. A user opens your KarlDNS URL.
|
||||
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.
|
||||
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.
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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`.
|
||||
|
||||
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
|
||||
|
||||
@@ -283,7 +283,7 @@ The generated hostname is not a secret. Treat it like any public DNS name. The u
|
||||
|
||||
## 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 |
|
||||
| --- | --- |
|
||||
@@ -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. |
|
||||
| `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 /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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/<secret>.</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/<secret>.</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/<secret>.</strong> Read hostname status and recent history.</li>
|
||||
<li><strong>POST /api/v1/hostnames/<secret>/rotate-update-url.</strong> Replace the private router update URL.</li>
|
||||
<li><strong>DELETE /api/v1/hostnames/<secret>.</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>
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
)
|
||||
+14
-8
@@ -35,8 +35,9 @@ def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> No
|
||||
assert "GET /api/v1/hostnames/<secret>" in docs.text
|
||||
assert "POST /api/v1/hostnames/<secret>/rotate-update-url" in docs.text
|
||||
assert "Rotate update URL" in docs.text
|
||||
assert "GET /api/v1/admin/dashboard" in docs.text
|
||||
assert "GET /api/v1/admin/events" in docs.text
|
||||
assert "Operator API" not in docs.text
|
||||
assert "/api/v1/admin" not in docs.text
|
||||
assert "admin:<admin-password>" not 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 "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"]
|
||||
|
||||
expected_paths = {
|
||||
"/api/v1/admin/dashboard",
|
||||
"/api/v1/admin/events",
|
||||
"/api/v1/admin/hostnames",
|
||||
"/api/v1/admin/records",
|
||||
"/api/v1/hostnames",
|
||||
"/api/v1/hostnames/{dashboard_secret}",
|
||||
"/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 any(tag["name"] == "hostnames" 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:
|
||||
@@ -110,10 +112,14 @@ def test_openapi_hides_custom_operator_routes(tmp_path: Path) -> None:
|
||||
"/ops/records",
|
||||
"/records",
|
||||
"/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 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:
|
||||
|
||||
+11
-6
@@ -26,11 +26,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
||||
response = client.post("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "/m/" in str(response.url)
|
||||
assert "Update-URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Register URL:" in response.text
|
||||
assert "Router fields" in response.text
|
||||
assert "CNAME" in response.text
|
||||
assert "Router setup." in response.text
|
||||
assert "Use your own DNS name." in response.text
|
||||
assert "Manual updates" in response.text
|
||||
assert "curl -sS" 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/register/" 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:
|
||||
@@ -74,20 +77,22 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P
|
||||
response = client.post("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "/m/" in str(response.url)
|
||||
assert "Update-URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Register URL:" in response.text
|
||||
assert "Private dashboard:" in response.text
|
||||
assert "Pending setup" in response.text
|
||||
assert "Register hostname" in response.text
|
||||
assert "Unregistered setups are removed after 24 hours." in response.text
|
||||
assert "Router fields" in response.text
|
||||
assert "CNAME" in response.text
|
||||
assert "FRITZ!Box fills in" in response.text
|
||||
assert "Pending setups are cleaned up after 24 hours." in response.text
|
||||
assert "Router fields" not in response.text
|
||||
assert "Copy setup values" not in response.text
|
||||
assert "Paste this exact URL into the FRITZ!Box" in response.text
|
||||
assert "Copy manual update commands" in response.text
|
||||
assert response.text.count("Secret") >= 3
|
||||
assert response.text.count("Public") >= 3
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user