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.
|
- **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.
|
- **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.
|
- **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
|
## Screenshots
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ KarlDNS lets you host your own managed DynDNS service:
|
|||||||
|
|
||||||
1. A user opens your KarlDNS URL.
|
1. A user opens your KarlDNS URL.
|
||||||
2. They generate a random hostname like `a1b2c3d4.karldns.de`.
|
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.
|
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.
|
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.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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`.
|
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
|
## Production Setup
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ The generated hostname is not a secret. Treat it like any public DNS name. The u
|
|||||||
|
|
||||||
## HTTP API
|
## 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 |
|
| 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. |
|
| `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/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 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:
|
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.
|
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
|
## Docker
|
||||||
|
|
||||||
Copy the example environment file and replace every placeholder:
|
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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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`.
|
- 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.
|
- 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",
|
"name": "router",
|
||||||
"description": "Router-facing registration and IP update endpoints for simple URL clients.",
|
"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"
|
CLEANUP_TASK_NAME = "cleanup"
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep,
|
|||||||
summary="Read operator dashboard summary",
|
summary="Read operator dashboard summary",
|
||||||
description="Requires admin HTTP Basic auth. Returns the same high-level data shown in the operator console.",
|
description="Requires admin HTTP Basic auth. Returns the same high-level data shown in the operator console.",
|
||||||
tags=["admin"],
|
tags=["admin"],
|
||||||
|
include_in_schema=False,
|
||||||
responses={401: {"model": ErrorResponse}},
|
responses={401: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
async def read_admin(
|
async def read_admin(
|
||||||
@@ -161,6 +162,7 @@ async def read_admin(
|
|||||||
summary="List operator hostnames",
|
summary="List operator hostnames",
|
||||||
description="Requires admin HTTP Basic auth.",
|
description="Requires admin HTTP Basic auth.",
|
||||||
tags=["admin"],
|
tags=["admin"],
|
||||||
|
include_in_schema=False,
|
||||||
responses={401: {"model": ErrorResponse}},
|
responses={401: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
async def list_admin_hostnames(
|
async def list_admin_hostnames(
|
||||||
@@ -185,6 +187,7 @@ async def list_admin_hostnames(
|
|||||||
summary="List operator update history",
|
summary="List operator update history",
|
||||||
description="Requires admin HTTP Basic auth. Supports offset pagination or keyset pagination with before.",
|
description="Requires admin HTTP Basic auth. Supports offset pagination or keyset pagination with before.",
|
||||||
tags=["admin"],
|
tags=["admin"],
|
||||||
|
include_in_schema=False,
|
||||||
responses={401: {"model": ErrorResponse}},
|
responses={401: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
async def list_admin_events(
|
async def list_admin_events(
|
||||||
@@ -210,6 +213,7 @@ async def list_admin_events(
|
|||||||
summary="List operator DNS records",
|
summary="List operator DNS records",
|
||||||
description="Requires admin HTTP Basic auth.",
|
description="Requires admin HTTP Basic auth.",
|
||||||
tags=["admin"],
|
tags=["admin"],
|
||||||
|
include_in_schema=False,
|
||||||
responses={401: {"model": ErrorResponse}},
|
responses={401: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
async def list_admin_records(
|
async def list_admin_records(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def manage_dashboard(
|
|||||||
account = get_active_management_account(store, management_slug)
|
account = get_active_management_account(store, management_slug)
|
||||||
csrf_token = dashboard_csrf_token(management_slug, account)
|
csrf_token = dashboard_csrf_token(management_slug, account)
|
||||||
if not management_account_is_accepted(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):
|
if not has_management_access(request, settings, management_slug, account):
|
||||||
return _render_management_unlock_page(management_slug, account, csrf_token)
|
return _render_management_unlock_page(management_slug, account, csrf_token)
|
||||||
message = "Hostname registered. Add the update URL to your router." if activated else None
|
message = "Hostname registered. Add the update URL to your router." if activated else None
|
||||||
@@ -79,22 +79,26 @@ async def activate_dashboard(
|
|||||||
except BadIpError:
|
except BadIpError:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_render_pending_management_page(
|
_render_pending_management_page(
|
||||||
|
service,
|
||||||
settings,
|
settings,
|
||||||
management_slug,
|
management_slug,
|
||||||
account,
|
account,
|
||||||
dashboard_csrf_token(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.",
|
"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,
|
status_code=400,
|
||||||
)
|
)
|
||||||
except DnsProviderError:
|
except DnsProviderError:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_render_pending_management_page(
|
_render_pending_management_page(
|
||||||
|
service,
|
||||||
settings,
|
settings,
|
||||||
management_slug,
|
management_slug,
|
||||||
account,
|
account,
|
||||||
dashboard_csrf_token(management_slug, account),
|
dashboard_csrf_token(management_slug, account),
|
||||||
"DNS registration failed. The hostname was not registered.",
|
"DNS registration failed. The hostname was not registered.",
|
||||||
|
client_ip(request, settings),
|
||||||
),
|
),
|
||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
@@ -124,7 +128,15 @@ async def rotate_dashboard_secrets(
|
|||||||
account = await get_active_management_account_async(store, management_slug)
|
account = await get_active_management_account_async(store, management_slug)
|
||||||
if not management_account_is_accepted(account):
|
if not management_account_is_accepted(account):
|
||||||
return HTMLResponse(
|
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,
|
status_code=400,
|
||||||
)
|
)
|
||||||
if not has_management_access(request, settings, management_slug, account):
|
if not has_management_access(request, settings, management_slug, account):
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ async def render_management_page_async(
|
|||||||
account: ManagementAccount,
|
account: ManagementAccount,
|
||||||
csrf_token: str,
|
csrf_token: str,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
created_account: dict[str, str] | None = None,
|
|
||||||
request_ip: str | None = None,
|
request_ip: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return await run_in_threadpool(
|
return await run_in_threadpool(
|
||||||
@@ -103,7 +102,6 @@ async def render_management_page_async(
|
|||||||
account,
|
account,
|
||||||
csrf_token,
|
csrf_token,
|
||||||
message,
|
message,
|
||||||
created_account,
|
|
||||||
request_ip,
|
request_ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
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 starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from ..dependencies import ServiceDep, SettingsDep
|
from ..dependencies import ServiceDep, SettingsDep
|
||||||
@@ -13,15 +13,15 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||||
def index(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
def index(request: Request, settings: SettingsDep) -> str:
|
||||||
return _render_public_home(service, settings, None, None, client_ip(request, settings))
|
return _render_public_home(settings, None, client_ip(request, settings))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_class=HTMLResponse, include_in_schema=False)
|
@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)
|
await request_form(request, settings)
|
||||||
account = await run_in_threadpool(service.create_managed_account)
|
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)
|
@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("_", " "))
|
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_label = escape(label)
|
||||||
safe_value = escape(value)
|
safe_value = escape(value)
|
||||||
badge = ""
|
badge = ""
|
||||||
@@ -37,14 +37,15 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtm
|
|||||||
safe_sensitivity = escape(sensitivity)
|
safe_sensitivity = escape(sensitivity)
|
||||||
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
|
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
|
||||||
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
|
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
|
||||||
|
should_mask = is_secret and mask_secret
|
||||||
value_field = (
|
value_field = (
|
||||||
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
|
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>'
|
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
|
||||||
)
|
)
|
||||||
reveal_button = (
|
reveal_button = (
|
||||||
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</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 ""
|
else ""
|
||||||
)
|
)
|
||||||
return trusted(
|
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:
|
def _message_band(message: str | None) -> SafeHtml:
|
||||||
if not message:
|
if not message:
|
||||||
return trusted("")
|
return trusted("")
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|||||||
from ..services.ddns import DdnsService
|
from ..services.ddns import DdnsService
|
||||||
from ..settings import DdnsSettings
|
from ..settings import DdnsSettings
|
||||||
from .components import (
|
from .components import (
|
||||||
|
_code_window,
|
||||||
_copy_menu,
|
_copy_menu,
|
||||||
_copy_row,
|
_copy_row,
|
||||||
_event_label,
|
_event_label,
|
||||||
|
_example_update_ips,
|
||||||
_fritzbox_fields,
|
_fritzbox_fields,
|
||||||
_manual_update_commands,
|
_manual_update_commands,
|
||||||
_manual_update_panel,
|
_manual_update_panel,
|
||||||
@@ -13,7 +15,6 @@ from .components import (
|
|||||||
)
|
)
|
||||||
from .html_utils import SafeHtml, attr, escape, trusted
|
from .html_utils import SafeHtml, attr, escape, trusted
|
||||||
from .layout import _page, _top_nav
|
from .layout import _page, _top_nav
|
||||||
from .templates_public_sections import _created_hostname_panel
|
|
||||||
|
|
||||||
|
|
||||||
def _render_management_page(
|
def _render_management_page(
|
||||||
@@ -23,7 +24,6 @@ def _render_management_page(
|
|||||||
account: dict[str, str | int | None],
|
account: dict[str, str | int | None],
|
||||||
csrf_token: str,
|
csrf_token: str,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
created_account: dict[str, str] | None = None,
|
|
||||||
request_ip: str | None = None,
|
request_ip: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
account_for_url = {
|
account_for_url = {
|
||||||
@@ -43,7 +43,6 @@ def _render_management_page(
|
|||||||
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
|
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
|
||||||
"""
|
"""
|
||||||
message_html = _management_message(message)
|
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")))
|
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS management",
|
"KarlDNS management",
|
||||||
@@ -59,7 +58,6 @@ def _render_management_page(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{message_html}
|
{message_html}
|
||||||
{rotated_links}
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row g-4 align-items-start">
|
<div class="row g-4 align-items-start">
|
||||||
@@ -149,18 +147,31 @@ def _render_management_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _render_pending_management_page(
|
def _render_pending_management_page(
|
||||||
|
service: DdnsService,
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
management_slug: str,
|
management_slug: str,
|
||||||
account: dict[str, str | int | None],
|
account: dict[str, str | int | None],
|
||||||
csrf_token: str,
|
csrf_token: str,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
|
request_ip: str | None = None,
|
||||||
) -> str:
|
) -> 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)
|
message_html = _management_message(message)
|
||||||
cleanup_hours = settings.cleanup_pending_account_hours
|
cleanup_hours = settings.cleanup_pending_account_hours
|
||||||
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 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(
|
return _page(
|
||||||
"Register hostname",
|
"KarlDNS setup",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
<main id="main">
|
<main id="main">
|
||||||
{_top_nav()}
|
{_top_nav()}
|
||||||
@@ -168,7 +179,7 @@ def _render_pending_management_page(
|
|||||||
<div class="container text-center d-grid justify-items-center gap-3">
|
<div class="container text-center d-grid justify-items-center gap-3">
|
||||||
<p class="eyebrow">Pending setup</p>
|
<p class="eyebrow">Pending setup</p>
|
||||||
<h1>{hostname}</h1>
|
<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">
|
<form method="post" action="/m/{attr(management_slug)}/activate">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||||
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
||||||
@@ -176,9 +187,44 @@ def _render_pending_management_page(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{message_html}
|
{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">
|
<section class="section section-compact">
|
||||||
<div class="container narrow text-center">
|
<div class="container narrow d-grid gap-4">
|
||||||
<p class="section-copy mx-auto">Lost the first page? Create a new hostname. Private URLs are shown only once.</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ..services.ddns import DdnsService
|
|
||||||
from ..settings import DdnsSettings
|
from ..settings import DdnsSettings
|
||||||
from .components import _code_window, _example_update_ips, _message_band
|
from .components import _code_window, _example_update_ips, _message_band
|
||||||
from .html_utils import escape, trusted
|
from .html_utils import escape, trusted
|
||||||
from .layout import _page, _top_nav
|
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(
|
def _render_public_home(
|
||||||
service: DdnsService,
|
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
created_account: dict[str, str] | None,
|
|
||||||
message: str | None,
|
message: str | None,
|
||||||
request_ip: str | None = None,
|
request_ip: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -30,7 +27,6 @@ def _render_public_home(
|
|||||||
f"curl -sS '{base_url}/api/v1/router/update/<secret>'",
|
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 ""
|
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."
|
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(
|
return _page(
|
||||||
@@ -55,7 +51,6 @@ def _render_public_home(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{message_html}
|
{message_html}
|
||||||
{created_html}
|
|
||||||
<section class="section section-compact">
|
<section class="section section-compact">
|
||||||
<div class="container narrow text-center">
|
<div class="container narrow text-center">
|
||||||
<p class="eyebrow">How it works</p>
|
<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'"
|
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"
|
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"
|
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."
|
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS API",
|
"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">
|
<div class="container text-center d-grid justify-items-center gap-3">
|
||||||
<p class="eyebrow">API</p>
|
<p class="eyebrow">API</p>
|
||||||
<h1>Small API surface.</h1>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section section-compact">
|
<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">
|
<div class="api-endpoints">
|
||||||
<h2>Endpoints</h2>
|
<h2>Endpoints</h2>
|
||||||
<ol class="simple-steps text-start">
|
<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/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 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>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>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>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>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -223,15 +214,9 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Schema</h2>
|
<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")}
|
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ..security import dashboard_csrf_token
|
from .components import _code_window
|
||||||
from ..services.ddns import DdnsService
|
from .html_utils import SafeHtml, trusted
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _linux_installer_section(base_url: str) -> SafeHtml:
|
def _linux_installer_section(base_url: str) -> SafeHtml:
|
||||||
@@ -44,45 +33,3 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
|
|||||||
</section>
|
</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 "GET /api/v1/hostnames/<secret>" in docs.text
|
||||||
assert "POST /api/v1/hostnames/<secret>/rotate-update-url" in docs.text
|
assert "POST /api/v1/hostnames/<secret>/rotate-update-url" in docs.text
|
||||||
assert "Rotate update URL" in docs.text
|
assert "Rotate update URL" in docs.text
|
||||||
assert "GET /api/v1/admin/dashboard" in docs.text
|
assert "Operator API" not in docs.text
|
||||||
assert "GET /api/v1/admin/events" 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 "https://karldns.de/api/v1/hostnames" in docs.text
|
||||||
assert "http://localhost:8080/api/v1/hostnames" not in docs.text
|
assert "http://localhost:8080/api/v1/hostnames" not in docs.text
|
||||||
assert "registration_url" 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"]
|
assert "fonts.googleapis.com" not in docs.headers["content-security-policy"]
|
||||||
|
|
||||||
expected_paths = {
|
expected_paths = {
|
||||||
"/api/v1/admin/dashboard",
|
|
||||||
"/api/v1/admin/events",
|
|
||||||
"/api/v1/admin/hostnames",
|
|
||||||
"/api/v1/admin/records",
|
|
||||||
"/api/v1/hostnames",
|
"/api/v1/hostnames",
|
||||||
"/api/v1/hostnames/{dashboard_secret}",
|
"/api/v1/hostnames/{dashboard_secret}",
|
||||||
"/api/v1/hostnames/{dashboard_secret}/rotate-update-url",
|
"/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 "/events" not in schema["paths"]
|
||||||
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
||||||
assert any(tag["name"] == "router" 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:
|
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",
|
"/ops/records",
|
||||||
"/records",
|
"/records",
|
||||||
"/events",
|
"/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 client.get("/redoc").status_code == 404
|
||||||
assert forbidden_paths.isdisjoint(schema["paths"])
|
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:
|
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("/")
|
response = client.post("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
assert "/m/" in str(response.url)
|
||||||
assert "Update-URL:" in response.text
|
assert "Update-URL:" in response.text
|
||||||
assert "CNAME target:" in response.text
|
assert "CNAME target:" in response.text
|
||||||
assert "Register URL:" in response.text
|
assert "Register URL:" in response.text
|
||||||
assert "Router fields" in response.text
|
assert "Router setup." in response.text
|
||||||
assert "CNAME" in response.text
|
assert "Use your own DNS name." in response.text
|
||||||
assert "Manual updates" in response.text
|
assert "Manual updates" in response.text
|
||||||
assert "curl -sS" in response.text
|
assert "curl -sS" in response.text
|
||||||
assert "203.0.113.10" 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/update/" in response.text
|
||||||
assert "/api/v1/router/register/" in response.text
|
assert "/api/v1/router/register/" in response.text
|
||||||
assert "/m/" 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:
|
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("/")
|
response = client.post("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
assert "/m/" in str(response.url)
|
||||||
assert "Update-URL:" in response.text
|
assert "Update-URL:" in response.text
|
||||||
assert "CNAME target:" in response.text
|
assert "CNAME target:" in response.text
|
||||||
assert "Register URL:" in response.text
|
assert "Register URL:" in response.text
|
||||||
assert "Private dashboard:" in response.text
|
assert "Private dashboard:" in response.text
|
||||||
assert "Pending setup" in response.text
|
assert "Pending setup" in response.text
|
||||||
assert "Register hostname" in response.text
|
assert "Register hostname" in response.text
|
||||||
assert "Unregistered setups are removed after 24 hours." in response.text
|
assert "Pending setups are cleaned up after 24 hours." in response.text
|
||||||
assert "Router fields" in response.text
|
assert "Router fields" not in response.text
|
||||||
assert "CNAME" in response.text
|
assert "Copy setup values" not in response.text
|
||||||
assert "FRITZ!Box fills in" in response.text
|
assert "Paste this exact URL into the FRITZ!Box" in response.text
|
||||||
assert "Copy manual update commands" in response.text
|
assert "Copy manual update commands" in response.text
|
||||||
assert response.text.count("Secret") >= 3
|
assert response.text.count("Secret") >= 3
|
||||||
assert response.text.count("Public") >= 3
|
assert response.text.count("Public") >= 3
|
||||||
assert ".ddns.example.net" in response.text
|
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:
|
def test_public_examples_use_visitor_public_ip(tmp_path: Path) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user