2026-05-21 17:23:08 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Request
|
2026-05-21 17:58:20 +02:00
|
|
|
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
|
2026-05-21 17:23:08 +02:00
|
|
|
from starlette.concurrency import run_in_threadpool
|
|
|
|
|
|
|
|
|
|
from ..dependencies import ServiceDep, SettingsDep
|
|
|
|
|
from ..security import client_ip
|
|
|
|
|
from ..web.forms import request_form
|
|
|
|
|
from ..web.templates_public import _render_api_docs_page, _render_privacy_page, _render_public_home
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
2026-05-21 17:58:20 +02:00
|
|
|
def index(request: Request, settings: SettingsDep) -> str:
|
|
|
|
|
return _render_public_home(settings, None, client_ip(request, settings))
|
2026-05-21 17:23:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/", response_class=HTMLResponse, include_in_schema=False)
|
2026-05-21 17:58:20 +02:00
|
|
|
async def create_hostname(request: Request, settings: SettingsDep, service: ServiceDep) -> RedirectResponse:
|
2026-05-21 17:23:08 +02:00
|
|
|
await request_form(request, settings)
|
|
|
|
|
account = await run_in_threadpool(service.create_managed_account)
|
2026-10-28 08:47:52 +01:00
|
|
|
request.state.account_created = True
|
2026-05-21 17:58:20 +02:00
|
|
|
return RedirectResponse(service.dashboard_url(account), status_code=303)
|
2026-05-21 17:23:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
|
|
|
|
|
def healthz() -> str:
|
|
|
|
|
return "ok"
|
|
|
|
|
|
|
|
|
|
|
2026-10-28 08:47:52 +01:00
|
|
|
@router.get("/llms.txt", response_class=PlainTextResponse, include_in_schema=False)
|
|
|
|
|
def llms_txt(settings: SettingsDep) -> str:
|
|
|
|
|
base_url = settings.public_base_url.rstrip("/")
|
|
|
|
|
return f"""# KarlDNS
|
|
|
|
|
|
|
|
|
|
> KarlDNS provides anonymous dynamic DNS hostnames for routers and other clients,
|
|
|
|
|
> with IPv4 (A) and IPv6 (AAAA) updates through an HTTP API and a private web dashboard.
|
|
|
|
|
|
|
|
|
|
The public service base URL is `{base_url}`. Hostnames are generated under
|
|
|
|
|
`{settings.hostname_suffix}`; clients cannot choose a custom hostname.
|
|
|
|
|
Use HTTPS for this public service. No account login or API key is needed to create
|
|
|
|
|
a hostname. Returned private URLs are bearer credentials: save them securely and
|
|
|
|
|
never publish them, put them in shared logs, or substitute them into documentation.
|
|
|
|
|
Examples below use placeholders, never working credentials.
|
|
|
|
|
|
|
|
|
|
Create and activate a hostname:
|
|
|
|
|
|
|
|
|
|
1. Send `POST {base_url}/api/v1/hostnames` with no request body. HTTP 201 JSON
|
|
|
|
|
includes `hostname`, `status` (initially `pending`), `dashboard_url`,
|
|
|
|
|
`registration_url`, and `update_url`. Save all three private URLs first.
|
|
|
|
|
2. Call the returned `registration_url` using GET or POST, supplying the public
|
|
|
|
|
address as `myip` (IPv4) and/or `myipv6` (IPv6). HTTP 200 `good registered`
|
|
|
|
|
means activation succeeded. For FRITZ!Box, let the router perform registration
|
|
|
|
|
using `?myip=<ipaddr>` so the router supplies its own WAN address.
|
|
|
|
|
3. After activation, configure the client with the returned `update_url`.
|
|
|
|
|
FRITZ!Box uses `?myip=<ipaddr>` and optionally `&myipv6=<ip6addr>`.
|
|
|
|
|
Do not send those router placeholders literally from a general HTTP client.
|
|
|
|
|
|
|
|
|
|
Router requests use `GET` or `POST /api/v1/router/register/{{dashboard_secret}}`
|
|
|
|
|
and `GET` or `POST /api/v1/router/update/{{update_slug}}`. They accept query
|
|
|
|
|
parameters `myip`/`ipaddr` for IPv4 and `myipv6`/`ip6addr` for IPv6. POST also
|
|
|
|
|
accepts JSON or URL-encoded bodies with string values. Prefer explicit public
|
|
|
|
|
addresses; otherwise source-address detection is used. Never use a health probe
|
|
|
|
|
to call these routes: even GET requests can change DNS.
|
|
|
|
|
|
|
|
|
|
Updates return plain text: `good <address>` means changed, `nochg <address>`
|
|
|
|
|
means already current. Invalid IPs return HTTP 400 `badip ...`; DNS-provider
|
|
|
|
|
failures return HTTP 500 `911 ...`. Unknown, disabled, or unregistered update
|
|
|
|
|
credentials return HTTP 404. Check HTTP status as well as the response body;
|
|
|
|
|
back off on HTTP 429 or transient server failures instead of retrying rapidly.
|
|
|
|
|
|
|
|
|
|
Read status with `GET /api/v1/hostnames/{{dashboard_secret}}`, using the last
|
|
|
|
|
path segment of `dashboard_url`. Optional `history_limit` is 0 through 100
|
|
|
|
|
(default 10). The response is JSON and includes lifecycle state and DNS addresses.
|
|
|
|
|
Rotate an active hostname's update credential with
|
|
|
|
|
`POST /api/v1/hostnames/{{dashboard_secret}}/rotate-update-url`; save and configure
|
|
|
|
|
the returned replacement `update_url` immediately because the old one stops working.
|
|
|
|
|
`DELETE /api/v1/hostnames/{{dashboard_secret}}` removes the hostname and its DNS
|
|
|
|
|
records and returns HTTP 204. Delete only when the owner requests it.
|
|
|
|
|
|
|
|
|
|
On the website, GET `/` displays the homepage; submitting its form creates a
|
|
|
|
|
hostname and redirects to the private dashboard. Keep that dashboard URL: it is
|
|
|
|
|
the management credential. Dashboard passwords protect only the web dashboard,
|
|
|
|
|
not API access using the dashboard secret. Use the API for automation instead of
|
|
|
|
|
scraping private pages. Unregistered hostnames are cleaned up automatically.
|
|
|
|
|
GET `/healthz` is a read-only health check returning HTTP 200 `ok`.
|
|
|
|
|
|
|
|
|
|
## API and Website
|
|
|
|
|
|
|
|
|
|
- [OpenAPI schema]({base_url}/openapi.json): Machine-readable request and response definitions.
|
|
|
|
|
- [API guide]({base_url}/docs): Human-readable API examples and router setup guidance.
|
|
|
|
|
- [Homepage]({base_url}/): Create a hostname through the browser form.
|
|
|
|
|
- [Health]({base_url}/healthz): Read-only service availability probe.
|
|
|
|
|
|
|
|
|
|
## Optional
|
|
|
|
|
|
|
|
|
|
- [Privacy]({base_url}/privacy): Data handling information.
|
|
|
|
|
- [Project README](https://tea.karlcom.de/karl/karldns.de/raw/branch/main/README.md): Self-hosting and clients.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 17:23:08 +02:00
|
|
|
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
|
|
|
|
|
def privacy(settings: SettingsDep) -> str:
|
|
|
|
|
return _render_privacy_page(settings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/docs", response_class=HTMLResponse, include_in_schema=False)
|
|
|
|
|
def api_docs(request: Request, settings: SettingsDep) -> str:
|
|
|
|
|
return _render_api_docs_page(settings, client_ip(request, settings))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/robots.txt", response_class=PlainTextResponse, include_in_schema=False)
|
|
|
|
|
def robots_txt(settings: SettingsDep) -> str:
|
|
|
|
|
base_url = settings.public_base_url.rstrip("/")
|
|
|
|
|
admin_path = settings.admin_path.rstrip("/")
|
|
|
|
|
return "\n".join(
|
|
|
|
|
[
|
|
|
|
|
"User-agent: *",
|
|
|
|
|
f"Disallow: {admin_path}/",
|
|
|
|
|
"Disallow: /m/",
|
|
|
|
|
"Disallow: /api/",
|
|
|
|
|
"Disallow: /redoc",
|
|
|
|
|
"Disallow: /openapi.json",
|
|
|
|
|
f"Sitemap: {base_url}/sitemap.xml",
|
|
|
|
|
"",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sitemap.xml", include_in_schema=False)
|
|
|
|
|
def sitemap_xml(settings: SettingsDep) -> Response:
|
|
|
|
|
base_url = settings.public_base_url.rstrip("/")
|
|
|
|
|
body = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
|
|
|
<url>
|
|
|
|
|
<loc>{base_url}/</loc>
|
|
|
|
|
<changefreq>weekly</changefreq>
|
|
|
|
|
<priority>1.0</priority>
|
|
|
|
|
</url>
|
|
|
|
|
<url>
|
|
|
|
|
<loc>{base_url}/privacy</loc>
|
|
|
|
|
<changefreq>monthly</changefreq>
|
|
|
|
|
<priority>0.3</priority>
|
|
|
|
|
</url>
|
|
|
|
|
</urlset>
|
|
|
|
|
"""
|
|
|
|
|
return Response(body, media_type="application/xml")
|