diff --git a/README.md b/README.md index 07543ea..4f0be24 100644 --- a/README.md +++ b/README.md @@ -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//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: https://karldns.de/api/v1/admin/dashboard -curl -sS -u admin: '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. diff --git a/karldns/app.py b/karldns/app.py index dc0be1b..093e233 100644 --- a/karldns/app.py +++ b/karldns/app.py @@ -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" diff --git a/karldns/routers/api.py b/karldns/routers/api.py index 5649245..1a2e4ab 100644 --- a/karldns/routers/api.py +++ b/karldns/routers/api.py @@ -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( diff --git a/karldns/routers/management.py b/karldns/routers/management.py index 178f6c8..6daa4da 100644 --- a/karldns/routers/management.py +++ b/karldns/routers/management.py @@ -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): diff --git a/karldns/routers/management_shared.py b/karldns/routers/management_shared.py index 6198aea..cc3e62b 100644 --- a/karldns/routers/management_shared.py +++ b/karldns/routers/management_shared.py @@ -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, ) diff --git a/karldns/routers/public.py b/karldns/routers/public.py index cb34150..e928120 100644 --- a/karldns/routers/public.py +++ b/karldns/routers/public.py @@ -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) diff --git a/karldns/web/components.py b/karldns/web/components.py index 9f70680..a6c4586 100644 --- a/karldns/web/components.py +++ b/karldns/web/components.py @@ -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'{safe_sensitivity}' + should_mask = is_secret and mask_secret value_field = ( f'' - if is_secret + if should_mask else f'{safe_value}' ) reveal_button = ( f'' - 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( - """ - - """ - ) - - def _message_band(message: str | None) -> SafeHtml: if not message: return trusted("") diff --git a/karldns/web/templates_management.py b/karldns/web/templates_management.py index 00118f4..7a05576 100644 --- a/karldns/web/templates_management.py +++ b/karldns/web/templates_management.py @@ -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( No router updates yet. """ 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( {message_html} - {rotated_links}
@@ -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"""
{_top_nav()} @@ -168,7 +179,7 @@ def _render_pending_management_page(

Pending setup

{hostname}

-

Register this hostname after you have copied the private values. Unregistered setups are cleaned up after {escape(cleanup_text)}.

+

Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.

@@ -176,9 +187,44 @@ def _render_pending_management_page(
{message_html} +
+
+
+

Save

+

Your values.

+

This page is private. You can revisit it while the setup is pending. After registration, the dashboard hides secrets by default.

+

Public: hostname and CNAME target. Secret: update URL, register URL, and private dashboard.

+
+
+ {_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")} +
+ + + + +
+
-
-

Lost the first page? Create a new hostname. Private URLs are shown only once.

+
+
+

FRITZ!Box

+

Router setup.

+

Paste this exact URL into the FRITZ!Box Update-URL field. Use {hostname} as Domainnamen. No KarlDNS username or password is needed; if your router requires them, enter any placeholder.

+ {_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")} +
+
+

Manual updates.

+

Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP. Your current public examples use {escape(example_ipv4)} and {escape(example_ipv6)} where available.

+ {_code_window(manual_commands, copy_label="manual update commands", class_name="mt-3")} +
+
+

Use your own DNS name.

+

Create a CNAME at your DNS provider that points to {hostname}. KarlDNS only updates this generated hostname.

+
diff --git a/karldns/web/templates_public.py b/karldns/web/templates_public.py index 9dc181e..34a4bff 100644 --- a/karldns/web/templates_public.py +++ b/karldns/web/templates_public.py @@ -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/'", ] ) - 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( {message_html} - {created_html}

How it works

@@ -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/?history_limit=10'" rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames//rotate-update-url" schema_example = f"curl -sS {base_url}/openapi.json" - admin_dashboard_example = f"curl -sS -u admin: {base_url}/api/v1/admin/dashboard" - admin_history_example = f"curl -sS -u admin: '{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)

API

Small API surface.

-

Three surfaces: admin API, user dashboard data, and router registration/update calls.

+

Create a hostname, register it, update it, and read its status.

@@ -189,14 +182,12 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)

Endpoints

    -
  1. POST /api/v1/hostnames. Create a pending hostname and one-time secrets. The response includes registration_url.
  2. +
  3. POST /api/v1/hostnames. Create a pending hostname and private setup values. The response includes registration_url.
  4. GET or POST /api/v1/router/register/<secret>. Register the hostname after secrets are saved. Uses the request IP, or myip/myipv6, for the initial DNS record.
  5. GET or POST /api/v1/router/update/<secret>. Router-friendly update endpoint. Supports myip, ipaddr, myipv6, and ip6addr.
  6. GET /api/v1/hostnames/<secret>. Read hostname status and recent history.
  7. POST /api/v1/hostnames/<secret>/rotate-update-url. Replace the private router update URL.
  8. DELETE /api/v1/hostnames/<secret>. Delete hostname and DNS records.
  9. -
  10. GET /api/v1/admin/dashboard. Read operator summary, metrics, recent hostnames, cleanup state, and recent update history with admin Basic auth.
  11. -
  12. GET /api/v1/admin/events. Read paginated update history with admin Basic auth. Also available: /api/v1/admin/hostnames and /api/v1/admin/records.
@@ -223,15 +214,9 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)

Schema

-

Machine-readable OpenAPI is still available. Operator API routes are included and require admin Basic auth.

+

Machine-readable OpenAPI is available for the public hostname and router endpoints.

{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
-
-

Operator API

-

Admin endpoints use the same HTTP Basic auth as the operator console.

- {_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")} -
diff --git a/karldns/web/templates_public_sections.py b/karldns/web/templates_public_sections.py index 9695481..f6e1f21 100644 --- a/karldns/web/templates_public_sections.py +++ b/karldns/web/templates_public_sections.py @@ -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: """ ) - - -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""" -
-
-

Pending setup

-

{escape(hostname)}

-

Copy these values now. Then register the hostname so router updates can start. Unregistered setups are removed after 24 hours.

- {_one_time_secret_notice()} - {_secret_notice(include_registration=True)} -
- {_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Register URL", registration_url), ("CNAME", cname_record)])} -
-
- {_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")} -
-
- - -
- {_manual_update_panel(manual_commands)} -
-
- """ - ) diff --git a/tests/test_api.py b/tests/test_api.py index 9dd0aef..319a3ed 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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: diff --git a/tests/test_ui.py b/tests/test_ui.py index 23d1652..45c0be5 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -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: