Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+49 -3
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import logging
import time
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
@@ -9,6 +12,8 @@ from ..ddns_models import DdnsSettings
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..ddns_store import DdnsStore
ACCESS_LOGGER = logging.getLogger("router_dyndns.access")
class RequestBodyTooLargeError(Exception):
pass
@@ -46,6 +51,7 @@ class RequestBodyLimitMiddleware:
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
started_at = time.perf_counter()
try:
content_length = int(request.headers.get("content-length", "0") or "0")
except ValueError:
@@ -62,12 +68,15 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
key = f"{client_ip(request, settings)}:{rate_limit_bucket(request.url.path)}"
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed:
await run_in_threadpool(store.increment_metric, "rate_limit_hits_total")
return PlainTextResponse("rate limit exceeded", status_code=429)
response = await call_next(request)
if is_secret_response(request):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
if is_noindex_response(request):
response.headers["X-Robots-Tag"] = "noindex, nofollow"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
@@ -75,6 +84,15 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
response.headers["Content-Security-Policy"] = content_security_policy(request.url.path)
if settings.public_base_url.startswith("https://"):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
if settings.redacted_access_log:
elapsed_ms = (time.perf_counter() - started_at) * 1000
ACCESS_LOGGER.info(
"%s %s status=%s duration_ms=%.2f",
request.method,
redacted_access_path(request.url.path),
response.status_code,
elapsed_ms,
)
return response
@@ -96,6 +114,17 @@ def is_secret_response(request: Request) -> bool:
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
def is_noindex_response(request: Request) -> bool:
path = request.url.path
if request.method != "GET":
return True
if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}:
return True
if path.startswith(("/api/", "/m/", "/d/", "/u/", "/nic/")):
return True
return is_admin_path(path, request.app.state.settings)
def content_security_policy(path: str) -> str:
if path in {"/docs", "/redoc", "/openapi.json"}:
return (
@@ -108,13 +137,30 @@ def content_security_policy(path: str) -> str:
"form-action 'self'; "
"frame-ancestors 'none'"
)
script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
return (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' https://cdn.jsdelivr.net; "
f"{script_src}"
"style-src 'self'; "
"img-src 'self' data:; "
"font-src 'self' https://cdn.jsdelivr.net; "
"font-src 'self'; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
def redacted_access_path(path: str) -> str:
secret_prefixes = {
"/u": "/u/<secret>",
"/m": "/m/<secret>",
"/d": "/d/<secret>",
"/api/v1/updates": "/api/v1/updates/<secret>",
"/api/v1/management": "/api/v1/management/<secret>",
}
for prefix, replacement in secret_prefixes.items():
if path == prefix or path.startswith(f"{prefix}/"):
return replacement
if path == "/nic/update":
return "/nic/update?<redacted>"
return path
+179 -119
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
APP_CSS = """
BASE_CSS = """
:root {
color-scheme: light;
--bs-primary: #000000;
@@ -70,6 +70,10 @@ body {
font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
code {
color: var(--rp-ink);
}
.justify-items-center {
justify-items: center;
}
@@ -158,7 +162,29 @@ body {
}
.theme-toggle {
min-width: 58px;
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 40px;
min-width: 40px;
padding: 0;
}
.theme-icon {
display: block;
}
.theme-icon-sun {
display: none;
}
[data-bs-theme="dark"] .theme-icon-moon {
display: none;
}
[data-bs-theme="dark"] .theme-icon-sun {
display: block;
}
.hero-band {
@@ -367,9 +393,9 @@ input:focus,
}
.btn-sm {
min-width: 34px;
min-height: 34px;
padding-inline: 14px;
min-width: 40px;
min-height: 40px;
padding-inline: 16px;
border-radius: 9999px;
}
@@ -424,9 +450,13 @@ select:focus-visible {
.btn-outline-danger {
--bs-btn-color: var(--rp-danger);
--bs-btn-border-color: var(--rp-line);
--bs-btn-bg: var(--rp-field);
--bs-btn-hover-color: #ffffff;
--bs-btn-hover-bg: var(--rp-danger);
--bs-btn-hover-border-color: var(--rp-danger);
--bs-btn-active-color: #ffffff;
--bs-btn-active-bg: var(--rp-danger);
--bs-btn-active-border-color: var(--rp-danger);
}
.text-primary {
@@ -461,6 +491,10 @@ select:focus-visible {
border-radius: 12px;
}
.alert code {
color: var(--rp-ink);
}
.copy-field {
min-width: 0;
max-width: 100%;
@@ -538,53 +572,6 @@ select:focus-visible {
font: inherit;
}
.admin-table-scroll {
max-height: 560px;
overflow: auto;
background: var(--rp-surface);
border-color: var(--rp-line) !important;
border-radius: 16px !important;
-webkit-overflow-scrolling: touch;
}
.admin-table-scroll thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--rp-surface-alt);
}
.table-page-size {
width: auto;
min-width: 76px;
}
.admin-table-shell .form-control {
max-width: 360px;
}
.status-table-shell {
max-width: 100%;
border: 1px solid var(--rp-line);
border-radius: 16px;
background: var(--rp-surface);
}
.pagination {
--bs-pagination-color: var(--rp-muted);
--bs-pagination-bg: var(--rp-surface);
--bs-pagination-border-color: var(--rp-line);
--bs-pagination-hover-color: var(--rp-ink);
--bs-pagination-hover-bg: var(--rp-field);
--bs-pagination-hover-border-color: var(--rp-line);
--bs-pagination-active-color: #ffffff;
--bs-pagination-active-bg: #000000;
--bs-pagination-active-border-color: #000000;
--bs-pagination-disabled-color: var(--rp-mute);
--bs-pagination-disabled-bg: var(--rp-surface);
--bs-pagination-disabled-border-color: var(--rp-line);
}
.site-footer {
padding: 32px 0;
background: var(--rp-canvas);
@@ -594,64 +581,6 @@ select:focus-visible {
color: var(--rp-ink) !important;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: var(--rp-ink);
}
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--rp-ink);
--bs-table-border-color: var(--rp-soft-line);
--bs-table-hover-bg: var(--rp-field);
margin-bottom: 0;
}
th,
td {
border-bottom: 1px solid var(--rp-soft-line);
padding: 14px 10px;
text-align: left;
white-space: nowrap;
}
td {
max-width: 38rem;
overflow: hidden;
text-overflow: ellipsis;
}
.status-table {
min-width: min(100%, 420px);
}
.status-table th,
.status-table td {
white-space: normal;
overflow-wrap: anywhere;
}
tr:last-child th,
tr:last-child td {
border-bottom: 0;
}
th {
color: var(--rp-muted);
font-size: 12px;
font-weight: 600;
background: var(--rp-surface-alt);
}
.empty {
color: var(--rp-muted);
text-align: center;
padding: 28px;
}
@media (max-width: 860px) {
.section,
.hero-band {
@@ -750,6 +679,146 @@ th {
border-radius: 0 0 14px 14px;
}
h1 {
font-size: 28px;
line-height: 1.15;
}
h2 {
font-size: 24px;
}
}
""".strip()
ADMIN_CSS = """
.metric-card {
display: grid;
gap: 6px;
padding: 16px;
border: 1px solid var(--rp-line);
border-radius: 16px;
background: var(--rp-surface);
}
.metric-card span {
color: var(--rp-muted);
font-size: 13px;
font-weight: 500;
}
.metric-card strong {
color: var(--rp-ink);
font-size: 20px;
font-weight: 600;
}
.admin-table-scroll {
max-height: 560px;
overflow: auto;
background: var(--rp-surface);
border-color: var(--rp-line) !important;
border-radius: 16px !important;
-webkit-overflow-scrolling: touch;
}
.admin-table-scroll thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--rp-surface-alt);
}
.table-page-size {
width: auto;
min-width: 76px;
}
.admin-table-shell .form-control {
max-width: 360px;
}
.status-table-shell {
max-width: 100%;
border: 1px solid var(--rp-line);
border-radius: 16px;
background: var(--rp-surface);
}
.pagination {
--bs-pagination-color: var(--rp-muted);
--bs-pagination-bg: var(--rp-surface);
--bs-pagination-border-color: var(--rp-line);
--bs-pagination-hover-color: var(--rp-ink);
--bs-pagination-hover-bg: var(--rp-field);
--bs-pagination-hover-border-color: var(--rp-line);
--bs-pagination-active-color: #ffffff;
--bs-pagination-active-bg: #000000;
--bs-pagination-active-border-color: #000000;
--bs-pagination-disabled-color: var(--rp-mute);
--bs-pagination-disabled-bg: var(--rp-surface);
--bs-pagination-disabled-border-color: var(--rp-line);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: var(--rp-ink);
}
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--rp-ink);
--bs-table-border-color: var(--rp-soft-line);
--bs-table-hover-bg: var(--rp-field);
margin-bottom: 0;
}
th,
td {
border-bottom: 1px solid var(--rp-soft-line);
padding: 14px 10px;
text-align: left;
white-space: nowrap;
}
td {
max-width: 38rem;
overflow: hidden;
text-overflow: ellipsis;
}
.status-table {
min-width: min(100%, 420px);
}
.status-table th,
.status-table td {
white-space: normal;
overflow-wrap: anywhere;
}
tr:last-child th,
tr:last-child td {
border-bottom: 0;
}
th {
color: var(--rp-muted);
font-size: 12px;
font-weight: 600;
background: var(--rp-surface-alt);
}
.empty {
color: var(--rp-muted);
text-align: center;
padding: 28px;
}
@media (max-width: 860px) {
.admin-table-shell .form-control {
max-width: none;
}
@@ -767,14 +836,5 @@ th {
td {
padding: 12px 9px;
}
h1 {
font-size: 28px;
line-height: 1.15;
}
h2 {
font-size: 24px;
}
}
""".strip()
+79 -15
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import json
from .html_utils import SafeHtml, attr, escape, join_html, trusted
@@ -9,14 +11,22 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">Docs</a><a class="nav-link px-0" href="/redoc">API</a>')
account_html += '<a class="btn btn-sm btn-primary rounded-pill" href="/#create">Generate</a>'
theme_toggle = '<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch color theme">Light</button>'
theme_toggle = """
<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
<svg class="theme-icon theme-icon-moon" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 0a6.8 6.8 0 0 0-.8 3.2A7 7 0 0 0 12.2 10a6.8 6.8 0 0 0 3-.7A8 8 0 1 1 6 0Z"/>
</svg>
<svg class="theme-icon theme-icon-sun" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 11.2A3.2 3.2 0 1 0 8 4.8a3.2 3.2 0 0 0 0 6.4Zm0-9.8a.7.7 0 0 1-.7-.7V.2a.7.7 0 0 1 1.4 0v.5a.7.7 0 0 1-.7.7Zm0 14.6a.7.7 0 0 1-.7-.7v-.5a.7.7 0 1 1 1.4 0v.5a.7.7 0 0 1-.7.7ZM1.4 8a.7.7 0 0 1-.7.7H.2a.7.7 0 0 1 0-1.4h.5a.7.7 0 0 1 .7.7Zm14.6 0a.7.7 0 0 1-.7.7h-.5a.7.7 0 1 1 0-1.4h.5a.7.7 0 0 1 .7.7ZM3.3 3.3a.7.7 0 0 1-1 0L2 3a.7.7 0 0 1 1-1l.3.3a.7.7 0 0 1 0 1Zm10.7 10.7a.7.7 0 0 1-1 0l-.3-.3a.7.7 0 1 1 1-1l.3.3a.7.7 0 0 1 0 1Zm-10.7-1a.7.7 0 0 1 0 1l-.3.3a.7.7 0 1 1-1-1l.3-.3a.7.7 0 0 1 1 0ZM14 2a.7.7 0 0 1 0 1l-.3.3a.7.7 0 1 1-1-1L13 2a.7.7 0 0 1 1 0Z"/>
</svg>
</button>
"""
return trusted(
f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo.png" alt="" width="28" height="28">KarlDNS</a>
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
{links}
{account_html}
@@ -71,7 +81,7 @@ def _manual_update_commands(update_url: str) -> str:
[
f"curl -sS '{update_base}?myip=203.0.113.10'",
f"curl -sS '{update_base}?myipv6=2001:db8::10'",
f"curl -sS '{update_base}'",
f"curl -sS '{update_base}' # use request IP",
]
)
@@ -83,10 +93,10 @@ def _manual_update_panel(commands: str) -> SafeHtml:
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box replaces <code>&lt;ipaddr&gt;</code> and <code>&lt;ip6addr&gt;</code>, then sends a GET request to the Update-URL. Scripts and other routers can do the same with curl. If no IP parameter is sent, KarlDNS uses the request source IP.</p>
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
</div>
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
{_copy_button("Copy curl examples", commands)}
{_copy_button("Copy curl", commands)}
</div>
"""
)
@@ -107,7 +117,7 @@ def _fritzbox_fields(
f"Kennwort: {password}",
]
if management_url:
lines.append(f"Private status page: {management_url}")
lines.append(f"Private dashboard: {management_url}")
return "\n".join(lines)
@@ -115,9 +125,9 @@ def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Domainnamen and CNAME target can be shared in DNS.
<strong>Secret:</strong> Update-URL, one-box update URL, optional Kennwort, private dashboard URL, and domain claim links control the hostname.
Secrets are hidden by default on screen; Copy still copies the real value.
<strong>Public:</strong> Domain name and CNAME target can be shared.
<strong>Secret:</strong> Update URLs, router password, dashboard link, and claim links can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
)
@@ -127,7 +137,7 @@ def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the router password and one-box update URL now. They are only shown when generated or rotated and are not recoverable from the dashboard later.
Copy the router password and full update URL now. They are only shown when created or rotated.
</div>
"""
)
@@ -165,9 +175,55 @@ def _site_footer() -> SafeHtml:
)
def _page(title: str, body: SafeHtml) -> str:
def _page(
title: str,
body: SafeHtml,
extra_styles: SafeHtml | None = None,
extra_scripts: SafeHtml | None = None,
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
preload_logo: bool = False,
canonical_url: str | None = None,
image_url: str | None = None,
image_alt: str | None = None,
structured_data: dict[str, object] | None = None,
) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("")
extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or ""
social_image = image_url or ""
social_html = trusted("")
if social_url:
social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title
social_image_html = f"""
<meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
""" if social_image else ""
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(description)}">
<meta property="og:url" content="{attr(social_url)}">
{social_image_html}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
structured_data_html = trusted("")
if structured_data:
structured_data_html = trusted(
'<script type="application/ld+json">'
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
+ "</script>"
)
return f"""
<!doctype html>
<html lang="en">
@@ -175,15 +231,23 @@ def _page(title: str, body: SafeHtml) -> str:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<meta name="description" content="{attr(description)}">
{canonical_html}
{social_html}
<title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<script src="/app.js" defer></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/app.css" rel="stylesheet">
{preload_logo_html}
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=2" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=2" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
</head>
<body>{body}{_site_footer()}</body>
</html>
+124 -68
View File
@@ -11,10 +11,8 @@ from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_cleanup_row,
_domain_claim_row,
_event_row,
_scheduled_task_row,
)
@@ -24,7 +22,7 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
<div class="container">
<p class="eyebrow">Admin action</p>
<h2>{escape(title)}</h2>
<p class="section-copy">Copy this now. Secret values are only shown in this response.</p>
<p class="section-copy">Copy these values now. Secret values are only shown once.</p>
<div class="d-grid gap-2">
{"".join(rows)}
</div>
@@ -40,6 +38,7 @@ def _render_admin_page(
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
service_metrics: dict[str, float],
table_counts: dict[str, int],
table_offsets: dict[str, int],
table_limit: int,
@@ -56,19 +55,13 @@ def _render_admin_page(
domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """
<tr class="empty-row"><td colspan="5" class="empty">No domain claims yet.</td></tr>
"""
cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """
<tr class="empty-row"><td colspan="3" class="empty">No cleanup runs yet.</td></tr>
"""
scheduled_task_rows = "\n".join(_scheduled_task_row(task) for task in scheduled_tasks) or """
<tr class="empty-row"><td colspan="6" class="empty">No scheduled tasks yet.</td></tr>
"""
event_rows = "\n".join(_event_row(event) for event in update_events) or """
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
"""
accounts_count = escape(table_counts.get("accounts", len(accounts)))
domain_count = escape(table_counts.get("domain_challenges", len(domain_challenges)))
cleanup_count = escape(table_counts.get("cleanup_runs", len(cleanup_runs)))
event_count = escape(table_counts.get("update_events", len(update_events)))
metrics_html = _metrics_panel(service_metrics)
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
domain_pager = _server_page_links(
admin_path,
@@ -77,18 +70,18 @@ def _render_admin_page(
table_limit,
table_counts.get("domain_challenges", 0),
)
cleanup_pager = _server_page_links(admin_path, "cleanup_runs_offset", table_offsets.get("cleanup_runs", 0), table_limit, table_counts.get("cleanup_runs", 0))
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator console", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
{_top_nav("Operator", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
<section class="section admin-heading">
<div class="container">
<p class="eyebrow">KarlDNS aktiv</p>
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
<p class="lead">Inspect hostnames, verify domain claims, rotate links, run cleanup, and review router update events.</p>
<p class="lead">Review hostnames, domain checks, updates, cleanup, and secret changes.</p>
</div>
</section>
@@ -96,17 +89,15 @@ def _render_admin_page(
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<p class="eyebrow">Service health</p>
<h2>Scheduled cleanup</h2>
<p class="section-copy">Cleanup runs every {escape(settings.cleanup_interval_seconds)} seconds. It removes unverified domain claims after {escape(settings.cleanup_challenge_hours)} hours and never-used generated hostnames after {escape(settings.cleanup_unused_account_hours)} hours.</p>
<p class="section-copy">The scheduler state is stored in SQLite. Multiple app workers coordinate with a database lease, so only one worker runs a due cleanup task.</p>
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row mb-4">
<div>
<p class="eyebrow">Service health</p>
<h2>Service status</h2>
</div>
</div>
<form method="post" action="{safe_admin_path}/cleanup" class="col-lg-5 card card-body gap-3" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run cleanup now</button>
</form>
{cleanup_panel}
<div class="mt-4">
{metrics_html}
</div>
</div>
</section>
@@ -116,15 +107,15 @@ def _render_admin_page(
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Hostnames</p>
<h2>Active credentials</h2>
<p class="section-copy mb-0">Showing up to {escape(len(accounts))} of {accounts_count} hostnames.</p>
<h2>Hostnames</h2>
<p class="section-copy mb-0">Showing {escape(len(accounts))} of {accounts_count}.</p>
</div>
</div>
{_admin_table_toolbar("hostnames", "Search hostnames")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
<tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th>Links</th><th></th></tr>
<tr><th scope="col">Domain</th><th scope="col">User</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
@@ -139,14 +130,14 @@ def _render_admin_page(
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Domain claims</p>
<h2>Verification lifecycle</h2>
<p class="section-copy mb-0">Showing up to {escape(len(domain_challenges))} of {domain_count} claims.</p>
<h2>Domain verification</h2>
<p class="section-copy mb-0">Showing {escape(len(domain_challenges))} of {domain_count}.</p>
</div>
</div>
{_admin_table_toolbar("domain-claims", "Search domain claims")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Domain</th><th>Owner dashboard</th><th>Status</th><th>Created</th><th>Verified</th></tr></thead>
<thead><tr><th scope="col">Domain</th><th scope="col">Owner dashboard</th><th scope="col">Status</th><th scope="col">Created</th><th scope="col">Verified</th></tr></thead>
<tbody>{domain_rows}</tbody>
</table>
</div>
@@ -155,52 +146,19 @@ def _render_admin_page(
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Scheduler state</h2>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll mb-4">
<table class="table mb-0">
<thead><tr><th>Task</th><th>Status</th><th>Next run</th><th>Lease expires</th><th>Last finished</th><th>Last error</th></tr></thead>
<tbody>{scheduled_task_rows}</tbody>
</table>
</div>
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Recent runs</h2>
<p class="section-copy mb-0">Showing up to {escape(len(cleanup_runs))} of {cleanup_count} runs.</p>
</div>
</div>
{_admin_table_toolbar("cleanup-runs", "Search cleanup runs")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain claims removed</th><th>Unused hostnames removed</th></tr></thead>
<tbody>{cleanup_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{cleanup_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Updates</p>
<h2>Recent events</h2>
<p class="section-copy mb-0">Showing up to {escape(len(update_events))} of {event_count} event rows. Repeated unchanged polls are aggregated.</p>
<h2>Update events</h2>
<p class="section-copy mb-0">Showing {escape(len(update_events))} of {event_count}. Repeated checks with no IP change are grouped.</p>
</div>
</div>
{_admin_table_toolbar("update-events", "Search update events")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<thead><tr><th scope="col">Time</th><th scope="col">Domain</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
@@ -210,6 +168,8 @@ def _render_admin_page(
</section>
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
)
@@ -227,7 +187,103 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-3" aria-label="Server-side table pages">
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous server page</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next server page</a>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a>
</nav>
""")
def _cleanup_panel(
settings: DdnsSettings,
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
safe_admin_path: str,
csrf: str,
) -> SafeHtml:
task = next((item for item in scheduled_tasks if item.get("name") == "cleanup"), scheduled_tasks[0] if scheduled_tasks else {})
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
next_run = task.get("next_run_at")
status = task.get("last_status") or "pending"
last_error = task.get("last_error") or ""
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
domains_removed = last_cleanup.get("domain_challenges_deleted", 0)
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
error_html = (
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
if last_error
else trusted("")
)
cards = [
("Interval", f"*/{interval}s"),
("Last run", str(last_run or "Never")),
("Next run", str(next_run or "Pending")),
("Status", str(status)),
]
card_html = "\n".join(
f"""
<div class="col-12 col-md-6 col-xl-3">
<div class="metric-card h-100">
<span>{escape(label)}</span>
<strong>{escape(value)}</strong>
</div>
</div>
"""
for label, value in cards
)
return trusted(f"""
<div class="card flat-card">
<div class="card-body p-4">
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row">
<div>
<h3 class="h5 mb-2">Cleanup</h3>
<p class="section-copy mb-1">Last cleanup removed {escape(domains_removed)} domain claims and {escape(accounts_removed)} unused hostnames.</p>
{error_html}
</div>
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
</form>
</div>
<div class="row g-3 mt-3">
{card_html}
</div>
</div>
</div>
""")
def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
samples = metrics.get("update_latency_samples_total", 0)
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
items = [
("Updates", _metric_int(metrics.get("update_requests_total", 0))),
("Updated", _metric_int(metrics.get("update_success_total", 0))),
("DNS failures", _metric_int(metrics.get("dns_failures_total", 0))),
("DNS check failures", _metric_int(metrics.get("dns_reconcile_failures_total", 0))),
("Blocked requests", _metric_int(metrics.get("rate_limit_hits_total", 0))),
("Average update time", f"{average_latency:.1f} ms"),
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
("Cleanup ok/errors", f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}"),
]
cards = "\n".join(
f"""
<div class="col-6 col-lg-3">
<div class="metric-card h-100">
<span>{escape(label)}</span>
<strong>{escape(value)}</strong>
</div>
</div>
"""
for label, value in items
)
return trusted(f"""
<div class="mt-4">
<p class="eyebrow">Metrics</p>
<div class="row g-3 mt-1">{cards}</div>
</div>
""")
def _metric_int(value: float) -> str:
return str(int(value))
+4 -4
View File
@@ -17,16 +17,16 @@ def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_pat
<form method="post" action="{safe_admin_path}/accounts/rotate" class="d-flex gap-2 flex-wrap">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Rotate update URL" aria-label="Rotate update URL for {hostname}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Rotate management link" aria-label="Rotate management link for {hostname}">Manage link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Rotate router password" aria-label="Rotate router password for {hostname}">Password</button>
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Create a new router password" aria-label="New password for {hostname}">New password</button>
</form>
</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button class="btn btn-sm btn-light px-2 text-danger" title="Delete hostname" aria-label="Delete {hostname}">x</button>
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button>
</form>
</td>
</tr>
+31 -30
View File
@@ -57,10 +57,10 @@ def _render_management_page(
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Magic management</p>
<p class="eyebrow">Dashboard</p>
<h1>{hostname}</h1>
<p class="lead">Anyone with this link can manage or delete this hostname. Keep it private.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Back home</a>
<p class="lead">This private link can manage this hostname. Keep it safe.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
</section>
{message_html}
@@ -71,12 +71,12 @@ def _render_management_page(
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<h2>Router update settings</h2>
<p class="section-copy">Use the Update-URL directly if your router only has one URL field. Separate Benutzername and Kennwort values are optional compatibility fields. If you use your own DNS name, point it at this hostname with a CNAME.</p>
<p class="section-copy">Use the Update-URL if your router has one URL field. Username and password are only for routers that require them. For your own subdomain, create a CNAME to this hostname.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap mt-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy CNAME record", cname_record)}
{_copy_button("Copy router fields", fritzbox_fields)}
{_copy_button("Copy CNAME", cname_record)}
</div>
</div>
<div class="col-lg-6 d-grid gap-2">
@@ -91,19 +91,19 @@ def _render_management_page(
<div class="card card-body gap-3 mt-4">
<div>
<p class="eyebrow">Actions</p>
<h3>Rotate dashboard secrets</h3>
<p class="section-copy">Rotate the URL used by routers, or issue a new router password for compatibility clients. Update your router after rotating.</p>
<h3>Replace router secrets</h3>
<p class="section-copy">Create a new update URL or router password. Update your router after changing either one.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate Update-URL</button>
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
</form>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="password">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate router credentials</button>
<button class="btn btn-outline-primary rounded-pill" type="submit">New password</button>
</form>
</div>
</div>
@@ -134,7 +134,7 @@ def _render_management_page(
<div>
<p class="eyebrow">History</p>
<h2>Router updates</h2>
<p class="section-copy">Recent IP changes and failed DNS publishes for this hostname.</p>
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll">
@@ -148,7 +148,7 @@ def _render_management_page(
<section class="section danger-section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Danger zone</p>
<p class="eyebrow">Delete</p>
<h2>Delete hostname</h2>
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
</div>
@@ -160,6 +160,7 @@ def _render_management_page(
</section>
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
)
def _management_message(message: str | None) -> SafeHtml:
@@ -176,22 +177,22 @@ def _management_message(message: str | None) -> SafeHtml:
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
password_status = "Enabled" if password_enabled else "Not enabled"
password_action = "Change dashboard password" if password_enabled else "Set dashboard password"
password_action = "Change password" if password_enabled else "Set password"
remove_form = ""
if password_enabled:
remove_form = f"""
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password/remove">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label class="form-label" for="dashboard-remove-current-password">Current dashboard password</label>
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove dashboard password</button>
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
</form>
"""
current_password_field = ""
if password_enabled:
current_password_field = """
<div>
<label class="form-label" for="dashboard-current-password">Current dashboard password</label>
<label class="form-label" for="dashboard-current-password">Current password</label>
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
@@ -203,7 +204,7 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
<div class="col-lg-5">
<p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2>
<p class="section-copy">Status: {escape(password_status)}. This protects this management dashboard only. Router update URLs continue to work normally.</p>
<p class="section-copy">Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.</p>
</div>
<div class="col-lg-7 d-grid gap-3">
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password">
@@ -249,7 +250,7 @@ def _render_management_unlock_page(
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Dashboard locked</p>
<h1>{hostname}</h1>
<p class="lead">Enter the dashboard password to manage this hostname.</p>
<p class="lead">Enter the dashboard password for this hostname.</p>
</div>
</section>
<section class="section">
@@ -263,9 +264,9 @@ def _render_management_unlock_page(
</div>
<label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
<span class="form-check-label">Stay unlocked for 30 days on this browser</span>
<span class="form-check-label">Remember this browser for 30 days</span>
</label>
<button class="btn btn-primary rounded-pill" type="submit">Unlock dashboard</button>
<button class="btn btn-primary rounded-pill" type="submit">Unlock</button>
</form>
</div>
</section>
@@ -285,15 +286,15 @@ def _management_domain_claim_panel(
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Custom domain</p>
<h2>Claim a domain</h2>
<p class="section-copy">Create a TXT challenge, verify it from this dashboard, then create router credentials for a hostname under that verified domain.</p>
<h2>Use your domain</h2>
<p class="section-copy">Create a TXT record, verify it here, then create a hostname under that domain.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/request" class="d-grid gap-3" aria-label="Create dashboard domain claim">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create domain claim</button>
<button type="submit" class="btn btn-primary rounded-pill">Create claim</button>
</form>
</div>
"""
@@ -312,7 +313,7 @@ def _management_domain_claim_panel(
verification_name = service.verification_name(domain)
token = str(challenge["token"])
claim_url = service.domain_claim_url(challenge)
status = "Verified" if challenge.get("verified_at") else "Waiting for TXT verification"
status = "Verified" if challenge.get("verified_at") else "Waiting for TXT"
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
create_hostname = _management_custom_hostname_form(management_slug, claim_secret, csrf_token) if challenge.get("verified_at") else trusted("")
return trusted(
@@ -325,11 +326,11 @@ def _management_domain_claim_panel(
<p class="eyebrow">Domain claim</p>
<h2>{escape(domain)}</h2>
<p class="section-copy">Status: {escape(status)}</p>
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Another dashboard cannot verify or use it.</p>
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Other dashboards cannot use it.</p>
</div>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The claim secret and private claim link are secrets.</div>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. Claim secret and private claim link are secret.</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy TXT record", txt_bundle)}
{_copy_button("Copy TXT", txt_bundle)}
</div>
<div class="d-grid gap-2">
{_copy_row("TXT name", verification_name, "Public")}
@@ -341,7 +342,7 @@ def _management_domain_claim_panel(
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="domain" value="{attr(domain)}">
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<button type="submit" class="btn btn-primary rounded-pill">Verify claim</button>
<button type="submit" class="btn btn-primary rounded-pill">Verify</button>
</form>
{create_hostname}
</div>
@@ -358,10 +359,10 @@ def _management_custom_hostname_form(management_slug: str, claim_secret: str, cs
<form method="post" action="/m/{attr(management_slug)}/domains/hostnames" class="d-grid gap-3" aria-label="Create verified custom hostname">
{csrf_field}
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<label>Custom hostname
<label>Hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create custom hostname</button>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
"""
)
+66 -24
View File
@@ -14,11 +14,13 @@ def _render_public_home(
message: str | None,
challenge: dict[str, str | None] | None = None,
) -> str:
base_url = settings.public_base_url.rstrip("/")
suffix = escape(settings.hostname_suffix or "your DynDNS domain")
demo_update_url = escape(f"{settings.public_base_url.rstrip('/')}/u/<secret>?myip=<ipaddr>")
demo_update_url = escape(f"{base_url}/u/<secret>?myip=<ipaddr>")
created_html = _credentials_panel(service, created_account)
message_html = _message_band(message) if message and not challenge else ""
challenge_html = _custom_domain_flow(service, challenge, message)
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, custom domains, Linux cron jobs, and home networks."
return _page(
"KarlDNS",
trusted(f"""
@@ -26,15 +28,15 @@ def _render_public_home(
{_top_nav()}
<section class="hero-band">
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.png" alt="" width="80" height="80">
<h1>KarlDNS for home routers.</h1>
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
<h1>Dynamic DNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">Generate one secret update URL under {suffix}. Paste it into your router, script it with curl, or point your own subdomain at it with a CNAME.</p>
<p class="lead">KarlDNS gives you a private DynDNS update URL under {suffix}. Add it to your router, or use it from a script.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<a class="btn btn-primary rounded-pill" href="#create">Generate hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">See curl flow</a>
<a class="btn btn-primary rounded-pill" href="#create">Create hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
</div>
</div>
</section>
@@ -43,31 +45,31 @@ def _render_public_home(
<section class="section" id="create">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Choose your hostname</p>
<h2>No account. No login.</h2>
<p class="section-copy mx-auto">Use a generated KarlDNS hostname immediately, or verify your own domain with a DNS TXT record.</p>
<p class="eyebrow">Hostname</p>
<h2>No account needed.</h2>
<p class="section-copy mx-auto">Use a KarlDNS hostname now, or verify your own domain when you need it.</p>
</div>
<div class="row g-4 align-items-stretch">
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use our domain</p>
<h3>Generate a KarlDNS hostname</h3>
<p class="section-copy mb-0">KarlDNS generates the hostname, secret update URL, and optional router compatibility credentials automatically.</p>
<p class="eyebrow">KarlDNS domain</p>
<h3>Create a hostname</h3>
<p class="section-copy mb-0">You get a hostname, a private update URL, and router fields if your router needs them.</p>
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Generate hostname</button>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
</div>
</div>
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use your own domain</p>
<h3>Verify DNS ownership</h3>
<p class="section-copy mb-0">Add one TXT record so KarlDNS can confirm you control the domain before it publishes A/AAAA records there.</p>
<h3>Verify your domain</h3>
<p class="section-copy mb-0">Add one TXT record first. KarlDNS only updates domains it can verify.</p>
<form method="post" action="/request-domain" class="d-grid gap-3" aria-label="Request a custom domain TXT challenge">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create TXT challenge</button>
<button type="submit" class="btn btn-primary rounded-pill">Create TXT record</button>
</form>
</div>
</div>
@@ -80,8 +82,8 @@ def _render_public_home(
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from anything that can send GET.</h2>
<p class="lead mx-auto mt-3">Use FRITZ!Box placeholders, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, or your own automation.</p>
<h2>Update from a router or script.</h2>
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
<div class="terminal-card text-start mt-4">
<code># explicit IPv4</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
@@ -94,9 +96,33 @@ def _render_public_home(
{challenge_html}
</main>
"""),
description=description,
preload_logo=True,
canonical_url=f"{base_url}/",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
structured_data={
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "KarlDNS",
"applicationCategory": "NetworkingApplication",
"operatingSystem": "Linux",
"url": f"{base_url}/",
"description": description,
"image": f"{base_url}/social-preview.png",
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
"featureList": [
"Dynamic DNS for FRITZ!Box routers",
"Accountless private update URLs",
"Custom domain TXT verification",
"Linux cron updater",
],
},
)
def _render_privacy_page() -> str:
def _render_privacy_page(settings: DdnsSettings) -> str:
base_url = settings.public_base_url.rstrip("/")
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, DNS verification records, IP history, and cleanup behavior."
return _page(
"Legal and Privacy",
trusted(f"""
@@ -105,23 +131,23 @@ def _render_privacy_page() -> str:
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Legal and privacy</p>
<h1>KarlDNS keeps accountless DynDNS simple.</h1>
<p class="lead">This page explains the basic data handled by this self-hosted service. Configure your own imprint, controller details, and jurisdiction-specific terms before offering it publicly.</p>
<h1>Privacy basics.</h1>
<p class="lead">KarlDNS stores only the data needed to keep your hostname updated.</p>
</div>
</section>
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores generated hostnames, public DNS target names, update slugs, hashed management tokens, hashed router passwords, DNS TXT verification challenges, IP update history, timestamps, and operational events needed to run Dynamic DNS.</p>
<p class="section-copy">KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, TXT verification records, recent IP history, timestamps, and service events.</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Private management links, update URLs, router passwords, and one-box update URLs are bearer secrets. Anyone with them can update or manage the related hostname. Passwords and management lookup values are not stored in recoverable plaintext.</p>
<p class="section-copy">Dashboard links, update URLs, router passwords, full update URLs, and claim links are secrets. Anyone with them may update or manage the hostname. Passwords and dashboard lookup values are not stored as readable text.</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">The service keeps recent update events for troubleshooting and runs cleanup jobs for abandoned setup flows and unused hostnames. Server and reverse-proxy logs may also contain IP addresses and request metadata depending on your deployment.</p>
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes abandoned setup records and unused hostnames. Server logs may also include IP addresses and request details.</p>
</div>
<div>
<h2>Contact</h2>
@@ -131,4 +157,20 @@ def _render_privacy_page() -> str:
</section>
</main>
"""),
description=description,
canonical_url=f"{base_url}/privacy",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS legal and privacy information",
structured_data={
"@context": "https://schema.org",
"@type": "WebPage",
"name": "KarlDNS Legal and Privacy",
"url": f"{base_url}/privacy",
"description": description,
"isPartOf": {
"@type": "WebSite",
"name": "KarlDNS",
"url": f"{base_url}/",
},
},
)
+23 -23
View File
@@ -25,8 +25,8 @@ def _linux_installer_section() -> SafeHtml:
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p>
<h2>Install and uninstall should be boring.</h2>
<p class="section-copy mx-auto">For a Linux server, Proxmox host, NAS, or home-lab box that is always on, the installer creates a hostname if needed, installs the updater, schedules cron, runs the first update, and prints the private dashboard URL.</p>
<h2>Linux updater.</h2>
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates a hostname, installs the updater, schedules it, and prints your private dashboard link.</p>
</div>
<div class="card card-body gap-3">
<div class="terminal-card">
@@ -39,11 +39,11 @@ def _linux_installer_section() -> SafeHtml:
{_copy_button("Copy all commands", all_commands)}
</div>
<div class="alert alert-info my-0 py-2 small" role="note">
Uninstall keeps the private config by default because it may contain your only local copy of the update and dashboard URLs. Use <code>--delete-config</code> for a clean wipe.
Uninstall keeps your private URLs by default. Use <code>--delete-config</code> only when you want to remove everything.
</div>
<div>
<h3>Without sudo</h3>
<p class="section-copy">User mode writes to <code>~/.local/bin</code>, <code>~/.config/karldns</code>, and your user crontab.</p>
<h3>User install</h3>
<p class="section-copy">Use this when you do not want a system-wide install.</p>
</div>
<div class="terminal-card">
<code>{escape(user_install)}</code>
@@ -69,7 +69,7 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
claim_secret = attr(challenge.get("claim_secret") or "")
verification_name = service.verification_name(str(challenge["domain"]))
claim_url = service.domain_claim_url(challenge)
status = escape(message or "Add this TXT record at your DNS provider, then check when it has propagated.")
status = escape(message or "Add this TXT record at your DNS provider, then check again. DNS can take a few minutes.")
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
challenge_html = trusted(
f"""
@@ -78,8 +78,8 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
<span class="badge text-bg-primary rounded-circle align-self-start">1</span>
<h3>Add the TXT record</h3>
<p class="small text-secondary mb-0 mt-2">{status}</p>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The private claim link is secret.</div>
{_copy_button("Copy TXT record", txt_bundle)}
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. The private claim link is secret.</div>
{_copy_button("Copy TXT", txt_bundle)}
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Private claim link", claim_url, "Secret")}
@@ -99,9 +99,9 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
<section class="section" id="custom-domain">
<div class="container narrow">
<div class="text-center">
<p class="eyebrow">Direct custom-domain publishing</p>
<h2>Verify DNS ownership</h2>
<p class="section-copy mx-auto">Use this only when KarlDNS should publish A/AAAA records directly inside your domain. If you just want your own subdomain, generate a hostname above and create a CNAME to it.</p>
<p class="eyebrow">Custom domain</p>
<h2>Verify your domain</h2>
<p class="section-copy mx-auto">Use this when KarlDNS should update records inside your domain. For a simpler setup, create a KarlDNS hostname and point a CNAME to it.</p>
</div>
<div class="row g-3 mt-4 justify-content-center">
{challenge_html}
@@ -129,24 +129,24 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Hostname generated</p>
<p class="eyebrow">Hostname ready</p>
<h2>{escape(hostname)}</h2>
<p class="section-copy">Use the Update-URL directly. The separate Benutzername and Kennwort values are optional compatibility fields for routers that require them.</p>
<p class="section-copy">Use the Update-URL directly. Username and password are only for routers that require separate fields.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap my-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy one-box update URL", credentialed_update_url)}
{_copy_button("Copy CNAME record", cname_record)}
{_copy_button("Copy router fields", fritzbox_fields)}
{_copy_button("Copy full URL", credentialed_update_url)}
{_copy_button("Copy CNAME", cname_record)}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("One-box update URL:", credentialed_update_url, "Secret")}
{_copy_row("Full update URL:", credentialed_update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", password, "Secret")}
{_copy_row("Private status page:", management_url, "Secret")}
{_copy_row("Private dashboard:", management_url, "Secret")}
</div>
{_manual_update_panel(manual_commands)}
</div>
@@ -161,15 +161,15 @@ def _custom_hostname_form(claim_secret: str) -> SafeHtml:
<div class="col-lg-5">
<div class="card card-body h-100 gap-3">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create router hostname</h3>
<h3>Create hostname</h3>
<form method="post" action="/accounts" class="d-grid gap-3" aria-label="Create custom-domain router credentials">
<input type="hidden" name="mode" value="custom">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<label>Verified hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<p class="section-copy mb-0">Router compatibility credentials are generated automatically after creation.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create credentials</button>
<p class="section-copy mb-0">Router fields are created after this step.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
</div>
</div>
@@ -183,8 +183,8 @@ def _locked_custom_hostname_card() -> SafeHtml:
<div class="col-lg-5">
<div class="card card-body h-100 gap-3 opacity-75">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create router hostname</h3>
<p class="small text-secondary mb-0 mt-2">This unlocks after the TXT record is visible in public DNS.</p>
<h3>Create hostname</h3>
<p class="small text-secondary mb-0 mt-2">Available after KarlDNS can see the TXT record.</p>
</div>
</div>
"""