This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/karldns/routers/public.py
T
StasonJatham 5c46b47937 chore(repo): move current KarlDNS source to Gitea
Preserve the GitHub history and the newer deployed source from ct_134_karldns. Exclude runtime data, credentials, AppleDouble files and local backup copies. Make Gitea the canonical source for documentation, package metadata and project links.
2026-10-28 08:47:52 +01:00

150 lines
6.6 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
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)
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) -> RedirectResponse:
await request_form(request, settings)
account = await run_in_threadpool(service.create_managed_account)
request.state.account_created = True
return RedirectResponse(service.dashboard_url(account), status_code=303)
@router.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
def healthz() -> str:
return "ok"
@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.
"""
@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")