Simplify DDNS flow and polish UI

This commit is contained in:
StasonJatham committed 2026-05-21 14:18:39 +02:00
1 parent 5beee0e5c6
commit 5a90bd9d87
42 files changed
+587 -1418

No files matched your search

+4 -5
View File
@@ -107,11 +107,11 @@ def is_admin_path(path: str, settings: DdnsSettings) -> bool:
def is_secret_response(request: Request) -> bool:
path = request.url.path
settings = request.app.state.settings
if is_admin_path(path, settings) or path.startswith(("/m/", "/d/", "/u/")):
if is_admin_path(path, settings) or path.startswith(("/m/", "/u/")):
return True
if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
if path.startswith("/api/v1/hostnames/"):
return request.method in {"POST", "DELETE"}
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
return (request.method == "POST" and path in {"/magic", "/accounts"}) or path == "/nic/update"
def is_noindex_response(request: Request) -> bool:
@@ -120,7 +120,7 @@ def is_noindex_response(request: Request) -> bool:
return True
if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}:
return True
if path.startswith(("/api/", "/m/", "/d/", "/u/", "/nic/")):
if path.startswith(("/api/", "/m/", "/u/", "/nic/")):
return True
return is_admin_path(path, request.app.state.settings)
@@ -154,7 +154,6 @@ 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>",
}
+200 -11
View File
@@ -544,17 +544,36 @@ select:focus-visible {
background: var(--rp-field);
}
.manual-code {
overflow-x: auto;
padding: 16px;
border: 1px solid var(--rp-line);
border-radius: 12px;
color: var(--rp-terminal-ink);
background: var(--rp-terminal);
font: 400 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
.copy-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.copy-button .copy-icon,
.copy-button .check-icon,
.copy-icon-button .copy-icon,
.copy-icon-button .check-icon,
.copy-menu-item .copy-icon,
.copy-menu-item .check-icon,
.copy-menu .more-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.check-icon,
.is-copied .copy-icon {
display: none;
}
.is-copied .check-icon {
display: block;
}
.terminal-card {
position: relative;
display: grid;
gap: 8px;
padding: 18px 20px;
@@ -565,6 +584,14 @@ select:focus-visible {
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.code-window {
padding-right: 56px;
}
.manual-code {
overflow-x: auto;
}
.terminal-card code {
display: block;
overflow-wrap: anywhere;
@@ -572,13 +599,151 @@ select:focus-visible {
font: inherit;
}
.copy-icon-button {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: 999px;
color: var(--rp-muted);
background: transparent;
}
.copy-icon-button:hover,
.copy-icon-button:focus-visible {
color: var(--rp-ink);
background: var(--rp-surface-alt);
}
.copy-actions {
display: flex;
align-items: center;
justify-content: flex-start;
}
.copy-menu {
position: relative;
display: inline-flex;
}
.copy-menu > summary {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--rp-line);
border-radius: 999px;
color: var(--rp-muted);
background: var(--rp-surface);
cursor: pointer;
list-style: none;
}
.copy-menu > summary::-webkit-details-marker {
display: none;
}
.copy-menu > summary:hover,
.copy-menu > summary:focus-visible {
color: var(--rp-ink);
background: var(--rp-surface-alt);
}
.copy-menu-popover {
position: absolute;
z-index: 20;
top: calc(100% + 8px);
left: 0;
display: grid;
min-width: 220px;
gap: 4px;
padding: 8px;
border: 1px solid var(--rp-line);
border-radius: 12px;
background: var(--rp-surface);
}
.copy-menu-item {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
min-height: 36px;
padding: 8px 10px;
border: 0;
border-radius: 10px;
color: var(--rp-ink);
background: transparent;
font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
text-align: left;
}
.copy-menu-item:hover,
.copy-menu-item:focus-visible {
background: var(--rp-surface-alt);
}
.site-footer {
padding: 32px 0;
padding: 28px 0;
background: var(--rp-canvas);
}
.site-footer .nav-link:hover {
color: var(--rp-ink) !important;
.footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.footer-made {
flex: 0 0 auto;
color: var(--rp-muted);
font-size: 13px;
line-height: 1.4;
}
.footer-links {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px 18px;
}
.footer-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--rp-muted);
font-size: 13px;
line-height: 1.4;
text-decoration: none;
}
.footer-link svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
opacity: 0.8;
}
.footer-link:hover,
.footer-link:focus-visible {
color: var(--rp-ink);
}
.footer-link:focus-visible {
border-radius: 999px;
outline: 2px solid var(--rp-focus-ring);
outline-offset: 4px;
}
@media (max-width: 860px) {
@@ -679,6 +844,30 @@ select:focus-visible {
border-radius: 0 0 14px 14px;
}
.card .copy-icon-button,
.copy-icon-button {
width: 34px;
}
.card .copy-menu > summary,
.copy-menu > summary {
width: 40px;
}
.copy-menu-popover {
right: auto;
left: 0;
}
.footer-inner {
align-items: flex-start;
flex-direction: column;
}
.footer-links {
justify-content: flex-start;
}
h1 {
font-size: 28px;
line-height: 1.15;
+108 -19
View File
@@ -5,6 +5,26 @@ import json
from .html_utils import SafeHtml, attr, escape, join_html, trusted
def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
if name == "check":
path = '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75 10.5 18.75 19.5 5.25"/>'
elif name == "more":
path = (
'<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75h.01M12 12h.01M12 17.25h.01"/>'
)
else:
path = (
'<path stroke-linecap="round" stroke-linejoin="round" '
'd="M16.5 8.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v8.25A2.25 2.25 0 0 0 6 16.5h2.25'
'm8.25-8.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-7.5A2.25 2.25 0 0 1 8.25 18'
'v-7.5A2.25 2.25 0 0 1 10.5 8.25h6Z"/>'
)
return trusted(
f'<svg class="{attr(css_class)}" aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" '
f'fill="none" stroke="currentColor" stroke-width="1.5">{path}</svg>'
)
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
if label:
@@ -63,16 +83,66 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtm
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
{value_field}
{reveal_button}
<button type="button" class="btn btn-outline-secondary" data-copy="{safe_value}" aria-label="Copy {safe_label}">Copy</button>
<button type="button" class="btn btn-outline-secondary copy-button" data-copy="{safe_value}" aria-label="Copy {safe_label}">
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button>
</div>
"""
)
def _copy_button(label: str, value: str) -> SafeHtml:
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
return trusted(f'<button type="button" class="btn btn-outline-primary rounded-pill" data-copy="{safe_value}">{safe_label}</button>')
safe_style = attr(style)
return trusted(
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_icon_button(label: str, value: str) -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
return trusted(
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
safe_label = escape(label)
buttons = []
for item_label, item_value in items:
buttons.append(
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
)
return trusted(
f"""
<details class="copy-menu">
<summary aria-label="{safe_label}" title="{safe_label}">
{_svg_icon("more", "more-icon")}
</summary>
<div class="copy-menu-popover" role="menu">
{''.join(buttons)}
</div>
</details>
"""
)
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "") -> SafeHtml:
safe_class = f" {attr(class_name)}" if class_name else ""
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
return trusted(
f"""
<div class="terminal-card code-window{safe_class}">
{_copy_icon_button(copy_label, commands)}
{code_lines}
</div>
"""
)
def _manual_update_commands(update_url: str) -> str:
@@ -87,7 +157,6 @@ def _manual_update_commands(update_url: str) -> str:
def _manual_update_panel(commands: str) -> SafeHtml:
safe_commands = escape(commands)
return trusted(
f"""
<div class="card card-body gap-3 mt-4">
@@ -95,8 +164,7 @@ def _manual_update_panel(commands: str) -> SafeHtml:
<h3>Manual updates</h3>
<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", commands)}
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
"""
)
@@ -125,8 +193,8 @@ def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<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.
<strong>Public:</strong> Hostname and CNAME target can be shared.
<strong>Secret:</strong> Update URLs, router password, and dashboard link can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
@@ -159,15 +227,33 @@ def _site_footer() -> SafeHtml:
return trusted(
"""
<footer class="site-footer border-top">
<div class="container d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row">
<div class="small text-secondary">Made by Karl</div>
<nav class="nav justify-content-center gap-3" aria-label="Footer links">
<a class="nav-link p-0 small text-secondary" href="https://github.com/StasonJatham/karldns.de" rel="noopener">GitHub</a>
<a class="nav-link p-0 small text-secondary" href="https://karl.fail" rel="noopener">karl.fail</a>
<a class="nav-link p-0 small text-secondary" href="https://karlcom.de" rel="noopener">karlcom.de</a>
<a class="nav-link p-0 small text-secondary" href="/docs">API docs</a>
<a class="nav-link p-0 small text-secondary" href="/redoc">ReDoc</a>
<a class="nav-link p-0 small text-secondary" href="/privacy">Legal & privacy</a>
<div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
<nav class="footer-links" aria-label="Footer links">
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A12 12 0 0 0 8.2 23.9c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.4-4-1.4-.5-1.3-1.2-1.7-1.2-1.7-1-.7.1-.7.1-.7 1.1.1 1.7 1.2 1.7 1.2 1 .1.6 2.7 3.4 1.9.1-.7.4-1.2.7-1.5-2.6-.3-5.4-1.3-5.4-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.4 5.9.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6A12 12 0 0 0 12 .5Z"/></svg>
<span>GitHub</span>
</a>
<a class="footer-link" href="https://karl.fail" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6.5h16M4 12h10M4 17.5h7"/><path stroke-linecap="round" d="M18 15.5h.01M18 18.5h.01"/></svg>
<span>karl.fail</span>
</a>
<a class="footer-link" href="https://karlcom.de" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="8.5"/><path stroke-linecap="round" d="M3.5 12h17M12 3.5c2.2 2.3 3.3 5.1 3.3 8.5S14.2 18.2 12 20.5M12 3.5C9.8 5.8 8.7 8.6 8.7 12S9.8 18.2 12 20.5"/></svg>
<span>karlcom.de</span>
</a>
<a class="footer-link" href="/docs">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M6 4.5h8.5L18 8v11.5H6z"/><path stroke-linecap="round" stroke-linejoin="round" d="M14.5 4.5V8H18M8.5 12h7M8.5 15h7"/></svg>
<span>API docs</span>
</a>
<a class="footer-link" href="/redoc">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="m9 8-4 4 4 4M15 8l4 4-4 4"/></svg>
<span>ReDoc</span>
</a>
<a class="footer-link" href="/privacy">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3.5 5.5 6v5.5c0 4.1 2.7 7.6 6.5 9 3.8-1.4 6.5-4.9 6.5-9V6z"/><path stroke-linecap="round" stroke-linejoin="round" d="m9 12 2 2 4-4"/></svg>
<span>Legal & privacy</span>
</a>
</nav>
</div>
</footer>
@@ -242,10 +328,10 @@ def _page(
<link rel="manifest" href="/site.webmanifest">
{preload_logo_html}
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=2" defer></script>
<script src="/clipboard.js?v=3" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=2" rel="stylesheet">
<link href="/app.css?v=5" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
</head>
@@ -257,7 +343,10 @@ def _page(
__all__ = [
"SafeHtml",
"_copy_button",
"_copy_icon_button",
"_copy_menu",
"_copy_row",
"_code_window",
"_fritzbox_fields",
"_manual_update_commands",
"_manual_update_panel",
+4 -39
View File
@@ -11,7 +11,6 @@ from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_domain_claim_row,
_event_row,
)
@@ -34,7 +33,6 @@ def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: list[dict[str, str | int | None]],
domain_challenges: list[dict[str, str | None]],
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
@@ -52,24 +50,13 @@ def _render_admin_page(
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
"""
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>
"""
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)))
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,
"domain_claims_offset",
table_offsets.get("domain_challenges", 0),
table_limit,
table_counts.get("domain_challenges", 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(
@@ -81,7 +68,7 @@ def _render_admin_page(
<div class="container">
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
<p class="lead">Review hostnames, domain checks, updates, cleanup, and secret changes.</p>
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
</div>
</section>
@@ -115,7 +102,7 @@ def _render_admin_page(
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
<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>
<tr><th scope="col">Hostname</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>
@@ -125,27 +112,6 @@ 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">Domain 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 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>
{_admin_table_pagination()}
{domain_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">
@@ -158,7 +124,7 @@ def _render_admin_page(
{_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 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>
<thead><tr><th scope="col">Time</th><th scope="col">Hostname</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>
@@ -207,7 +173,6 @@ def _cleanup_panel(
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>')
@@ -237,7 +202,7 @@ def _cleanup_panel(
<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>
<p class="section-copy mb-1">Last cleanup removed {escape(accounts_removed)} unused hostnames.</p>
{error_html}
</div>
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
@@ -66,32 +66,6 @@ def _admin_table_pagination() -> str:
"""
def _domain_claim_row(challenge: dict[str, str | None]) -> str:
domain = escape(challenge["domain"])
owner = escape(challenge.get("owner_hostname") or "-")
created = escape(challenge.get("created_at") or "-")
verified = escape(challenge.get("verified_at") or "-")
status = "Verified" if challenge.get("verified_at") else "Pending"
return f"<tr><td>{domain}</td><td>{owner}</td><td>{status}</td><td>{created}</td><td>{verified}</td></tr>"
def _cleanup_row(run: dict[str, str | int]) -> str:
created = escape(run.get("created_at") or "-")
domains = escape(run.get("domain_challenges_deleted") or 0)
accounts = escape(run.get("unused_accounts_deleted") or 0)
return f"<tr><td>{created}</td><td>{domains}</td><td>{accounts}</td></tr>"
def _scheduled_task_row(task: dict[str, str | int | None]) -> str:
name = escape(task.get("name") or "-")
status = escape(task.get("last_status") or "-")
next_run = escape(task.get("next_run_at") or "-")
lease_expires = escape(task.get("lease_expires_at") or "-")
last_finished = escape(task.get("last_finished_at") or "-")
last_error = escape(task.get("last_error") or "-")
return f"<tr><td>{name}</td><td>{status}</td><td>{next_run}</td><td>{lease_expires}</td><td>{last_finished}</td><td>{last_error}</td></tr>"
def _event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
hostname = escape(event.get("hostname") or "-")
+4 -101
View File
@@ -4,7 +4,7 @@ from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
_copy_button,
_copy_menu,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
@@ -24,7 +24,6 @@ def _render_management_page(
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
challenge: dict[str, str | None] | None = None,
created_account: dict[str, str] | None = None,
) -> str:
account_for_url = {
@@ -48,7 +47,6 @@ def _render_management_page(
"""
message_html = _management_message(message)
rotated_credentials = _credentials_panel(service, created_account)
domain_claim = _management_domain_claim_panel(service, management_slug, csrf_token, challenge)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
return _page(
"KarlDNS management",
@@ -71,12 +69,11 @@ 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 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>
<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. Optional: point another DNS name at this hostname with a CNAME.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap mt-3">
{_copy_button("Copy router fields", fritzbox_fields)}
{_copy_button("Copy CNAME", cname_record)}
<div class="copy-actions mt-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])}
</div>
</div>
<div class="col-lg-6 d-grid gap-2">
@@ -109,7 +106,6 @@ def _render_management_page(
</div>
</div>
</section>
{domain_claim}
{dashboard_password_panel}
<section class="section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
@@ -275,99 +271,6 @@ def _render_management_unlock_page(
)
def _management_domain_claim_panel(
service: DdnsService,
management_slug: str,
csrf_token: str,
challenge: dict[str, str | None] | None,
) -> SafeHtml:
create_claim = trusted(
f"""
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Custom 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 claim</button>
</form>
</div>
"""
)
if not challenge:
return trusted(
f"""
<section class="section">
<div class="container narrow">{create_claim}</div>
</section>
"""
)
domain = str(challenge["domain"])
claim_secret = str(challenge.get("claim_secret") or "")
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"
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(
f"""
<section class="section" id="custom-domain">
<div class="container narrow d-grid gap-4">
{create_claim}
<div class="card card-body gap-3">
<div>
<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. Other dashboards cannot use it.</p>
</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", txt_bundle)}
</div>
<div class="d-grid gap-2">
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Claim secret", claim_secret, "Secret")}
{_copy_row("Private claim link", claim_url, "Secret")}
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/verify" class="d-flex gap-2 flex-wrap" aria-label="Verify dashboard domain claim">
<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</button>
</form>
{create_hostname}
</div>
</div>
</section>
"""
)
def _management_custom_hostname_form(management_slug: str, claim_secret: str, csrf_token: str | None = None) -> SafeHtml:
csrf_field = f'<input type="hidden" name="csrf" value="{attr(csrf_token)}">' if csrf_token else ""
return trusted(
f"""
<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>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 hostname</button>
</form>
"""
)
def _user_event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
status = escape(event.get("status") or "-")
+31 -35
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import escape, trusted
from .templates import _message_band, _page, _top_nav
from .templates_public_sections import _credentials_panel, _custom_domain_flow, _linux_installer_section
from .templates import _code_window, _message_band, _page, _top_nav
from .templates_public_sections import _credentials_panel, _linux_installer_section
def _render_public_home(
@@ -12,15 +12,24 @@ def _render_public_home(
settings: DdnsSettings,
created_account: dict[str, str] | None,
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")
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
suffix = escape(hostname_suffix)
demo_update_url = escape(f"{base_url}/u/<secret>?myip=<ipaddr>")
cname_example = f"home.example.com. CNAME your-name.{hostname_suffix}."
create_demo = "$ curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'\ngood 203.0.113.10"
manual_demo = "\n".join(
[
"# explicit IPv4",
"curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'",
"# source IP fallback",
"curl -sS 'https://karldns.de/u/<secret>'",
]
)
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."
message_html = _message_band(message) if message else ""
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
return _page(
"KarlDNS",
trusted(f"""
@@ -33,7 +42,7 @@ def _render_public_home(
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<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>
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL 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">Create hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
@@ -47,12 +56,12 @@ def _render_public_home(
<div class="text-center mb-4">
<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>
<p class="section-copy mx-auto">Create a KarlDNS hostname. Use it directly, or point your own DNS name at it with a CNAME.</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">KarlDNS domain</p>
<p class="eyebrow">KarlDNS hostname</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">
@@ -62,38 +71,25 @@ def _render_public_home(
</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 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 record</button>
</form>
<p class="eyebrow">Optional DNS alias</p>
<h3>Add a CNAME</h3>
<p class="section-copy mb-0">After creation, add a CNAME at your DNS provider:</p>
{_code_window(cname_example, copy_label="CNAME example")}
<p class="small text-secondary mb-0">KarlDNS updates only the generated hostname. The external DNS name follows it through CNAME.</p>
</div>
</div>
</div>
<div class="terminal-card mt-4" aria-hidden="true">
<code>$ curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
<code>good 203.0.113.10</code>
</div>
{_code_window(create_demo, copy_label="update example", class_name="mt-4")}
</div>
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<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>
<code># source IP fallback</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;'</code>
</div>
{_code_window(manual_demo, copy_label="manual update examples", class_name="text-start mt-4")}
</div>
</section>
{_linux_installer_section()}
{challenge_html}
</main>
"""),
description=description,
@@ -114,7 +110,7 @@ def _render_public_home(
"featureList": [
"Dynamic DNS for FRITZ!Box routers",
"Accountless private update URLs",
"Custom domain TXT verification",
"CNAME target for external DNS names",
"Linux cron updater",
],
},
@@ -122,7 +118,7 @@ def _render_public_home(
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."
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
return _page(
"Legal and Privacy",
trusted(f"""
@@ -139,15 +135,15 @@ def _render_privacy_page(settings: DdnsSettings) -> str:
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<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>
<p class="section-copy">KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, recent IP history, timestamps, and service events.</p>
</div>
<div>
<h2>Secrets</h2>
<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>
<p class="section-copy">Dashboard links, update URLs, router passwords, and full update URLs 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">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>
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.</p>
</div>
<div>
<h2>Contact</h2>
+15 -111
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .html_utils import SafeHtml, escape, trusted
from .templates import (
_copy_button,
_code_window,
_copy_menu,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
@@ -18,7 +19,6 @@ def _linux_installer_section() -> SafeHtml:
system_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sudo sh"
user_install = "curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user"
clean_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sh -s -- --mode user --delete-config"
all_commands = "\n".join([system_install, system_uninstall, user_install, clean_uninstall])
return trusted(
f"""
<section class="section" id="linux-installer">
@@ -29,15 +29,16 @@ def _linux_installer_section() -> SafeHtml:
<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">
<code>{escape(system_install)}</code>
<code>{escape(system_uninstall)}</code>
<div>
<h3>Install</h3>
<p class="section-copy">System install for most Linux servers.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy install", system_install)}
{_copy_button("Copy uninstall", system_uninstall)}
{_copy_button("Copy all commands", all_commands)}
{_code_window(system_install, copy_label="install command")}
<div>
<h3>Uninstall</h3>
<p class="section-copy">Stops the updater and leaves your private URLs on disk.</p>
</div>
{_code_window(system_uninstall, copy_label="uninstall command")}
<div class="alert alert-info my-0 py-2 small" role="note">
Uninstall keeps your private URLs by default. Use <code>--delete-config</code> only when you want to remove everything.
</div>
@@ -45,67 +46,8 @@ def _linux_installer_section() -> SafeHtml:
<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>
<code>{escape(clean_uninstall)}</code>
</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy user install", user_install)}
{_copy_button("Copy clean user uninstall", clean_uninstall)}
</div>
</div>
</div>
</section>
"""
)
def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> SafeHtml:
if not challenge:
return trusted("")
domain = attr(challenge["domain"])
token = str(challenge["token"])
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 again. DNS can take a few minutes.")
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
challenge_html = trusted(
f"""
<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">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. 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")}
<form method="post" action="/verify-domain" class="mt-3" aria-label="Check DNS TXT verification">
<input type="hidden" name="domain" value="{domain}">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<button type="submit" class="btn btn-primary rounded-pill">Check DNS record</button>
</form>
</div>
</div>
"""
)
create_form = _custom_hostname_form(claim_secret) if challenge.get("verified_at") else _locked_custom_hostname_card()
return trusted(
f"""
<section class="section" id="custom-domain">
<div class="container narrow">
<div class="text-center">
<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}
{create_form}
{_code_window(user_install, copy_label="user install command")}
{_copy_menu("More copy options", [("Clean user uninstall", clean_uninstall)])}
</div>
</div>
</section>
@@ -134,10 +76,8 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
<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 router fields", fritzbox_fields)}
{_copy_button("Copy full URL", credentialed_update_url)}
{_copy_button("Copy CNAME", cname_record)}
<div class="copy-actions my-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Full update URL", credentialed_update_url), ("CNAME", cname_record)])}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
@@ -153,39 +93,3 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
</section>
"""
)
def _custom_hostname_form(claim_secret: str) -> SafeHtml:
return trusted(
f"""
<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 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 fields are created after this step.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
</div>
</div>
"""
)
def _locked_custom_hostname_card() -> SafeHtml:
return trusted(
"""
<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 hostname</h3>
<p class="small text-secondary mb-0 mt-2">Available after KarlDNS can see the TXT record.</p>
</div>
</div>
"""
)