diff --git a/Dockerfile b/Dockerfile index 4b68ffa..d397449 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ ENV DDNS_DATABASE=/data/ddns.sqlite3 WORKDIR /app COPY pyproject.toml README.md requirements.txt /app/ COPY karldns /app/karldns +COPY scripts /app/karldns/assets/scripts RUN pip install --no-cache-dir -r requirements.txt . \ && addgroup -S karldns \ && adduser -S -G karldns -h /app karldns \ diff --git a/README.md b/README.md index 29d2e00..4a93fdc 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ That means users always have a current hostname for VPN, remote access, self-hos ## Quick Start ```bash -git clone https://github.com/StasonJatham/karldns.de.git +git clone https://tea.karlcom.de/karl/karldns.de.git cd karldns.de python3 -m venv .venv source .venv/bin/activate @@ -231,10 +231,10 @@ System-wide install: curl -fsSL https://karldns.de/install.sh | sudo sh ``` -The same script is hosted in GitHub and can be used directly: +The same script is hosted in Gitea and can be used directly: ```bash -curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sudo sh +curl -fsSL https://tea.karlcom.de/karl/karldns.de/raw/branch/main/scripts/install-linux.sh | sudo sh ``` Use an existing KarlDNS update URL: diff --git a/karldns/app.py b/karldns/app.py index 4ed7b1e..bbbf67a 100644 --- a/karldns/app.py +++ b/karldns/app.py @@ -17,6 +17,7 @@ from .routers import admin, api, assets, management, public, updates from .services.ddns import DdnsService from .settings import DdnsSettings from .store import DdnsStore +from .web.request_capture import PostCaptureMiddleware from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware __all__ = [ @@ -92,6 +93,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: app.state.store = store app.state.service = service install_security_middleware(app, settings, store) + app.add_middleware(PostCaptureMiddleware, settings=settings) app.include_router(api.router) app.include_router(assets.router) app.include_router(public.router) diff --git a/karldns/routers/api.py b/karldns/routers/api.py index e0b70db..21933bc 100644 --- a/karldns/routers/api.py +++ b/karldns/routers/api.py @@ -51,8 +51,9 @@ AdminBeforeQuery = Annotated[str | None, Query(max_length=64)] tags=["hostnames"], responses={500: {"model": ErrorResponse}}, ) -async def create_hostname(service: ServiceDep) -> HostnameCreateResponse: +async def create_hostname(request: Request, service: ServiceDep) -> HostnameCreateResponse: account = await run_in_threadpool(service.create_managed_account) + request.state.account_created = True return _create_response(service, account) diff --git a/karldns/routers/assets.py b/karldns/routers/assets.py index 01befd5..33d6def 100644 --- a/karldns/routers/assets.py +++ b/karldns/routers/assets.py @@ -3,9 +3,12 @@ from __future__ import annotations from pathlib import Path from fastapi import APIRouter, Response -from fastapi.responses import FileResponse, RedirectResponse +from fastapi.responses import FileResponse ASSET_DIR = Path(__file__).parents[1] / "assets" +SCRIPT_DIR = ASSET_DIR / "scripts" +if not SCRIPT_DIR.is_dir(): + SCRIPT_DIR = Path(__file__).parents[2] / "scripts" router = APIRouter(include_in_schema=False) ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} @@ -125,24 +128,15 @@ def admin_css() -> FileResponse: @router.get("/install.sh") -def install_sh() -> RedirectResponse: - return RedirectResponse( - "https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh", - status_code=302, - ) +def install_sh() -> FileResponse: + return FileResponse(SCRIPT_DIR / "install-linux.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"}) @router.get("/uninstall.sh") -def uninstall_sh() -> RedirectResponse: - return RedirectResponse( - "https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh", - status_code=302, - ) +def uninstall_sh() -> FileResponse: + return FileResponse(SCRIPT_DIR / "uninstall-linux.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"}) @router.get("/karldns-update.sh") -def karldns_update_sh() -> RedirectResponse: - return RedirectResponse( - "https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/karldns-update.sh", - status_code=302, - ) +def karldns_update_sh() -> FileResponse: + return FileResponse(SCRIPT_DIR / "karldns-update.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"}) diff --git a/karldns/routers/public.py b/karldns/routers/public.py index e928120..3103466 100644 --- a/karldns/routers/public.py +++ b/karldns/routers/public.py @@ -21,6 +21,7 @@ def index(request: Request, settings: SettingsDep) -> 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) + request.state.account_created = True return RedirectResponse(service.dashboard_url(account), status_code=303) @@ -29,6 +30,77 @@ 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=` so the router supplies its own WAN address. +3. After activation, configure the client with the returned `update_url`. + FRITZ!Box uses `?myip=` and optionally `&myipv6=`. + 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
` means changed, `nochg
` +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) diff --git a/karldns/settings.py b/karldns/settings.py index 0f4aafd..4c4a069 100644 --- a/karldns/settings.py +++ b/karldns/settings.py @@ -16,6 +16,7 @@ _RESERVED_PUBLIC_PATHS = { "/favicon.ico", "/healthz", "/install.sh", + "/llms.txt", "/bootstrap-subset.css", "/logo.png", "/logo-nav.webp", diff --git a/karldns/web/layout.py b/karldns/web/layout.py index 0c8b533..e311b01 100644 --- a/karldns/web/layout.py +++ b/karldns/web/layout.py @@ -28,7 +28,10 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm if label: account_html = f'{escape(label)}' if links is None: - links = trusted('API') + links = trusted( + 'API' + 'llms.txt' + ) theme_toggle = f"""