This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/karldns/web/templates_management.py
T

353 lines
17 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
from __future__ import annotations
2026-05-21 17:23:08 +02:00
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import (
2026-05-21 17:58:20 +02:00
_code_window,
2026-05-22 16:27:25 +02:00
_copy_button,
2026-05-20 23:55:27 +02:00
_copy_row,
2026-05-21 17:23:08 +02:00
_event_label,
2026-05-20 23:55:27 +02:00
_fritzbox_fields,
_manual_update_commands,
_secret_notice,
)
2026-05-21 17:23:08 +02:00
from .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav
2026-05-20 23:55:27 +02:00
def _render_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
2026-05-21 17:23:08 +02:00
request_ip: str | None = None,
2026-05-20 23:55:27 +02:00
) -> str:
account_for_url = {
"hostname": str(account["hostname"]),
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
hostname = escape(account["hostname"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
2026-05-21 17:23:08 +02:00
fritzbox_fields = _fritzbox_fields(update_url, hostname)
2026-05-20 23:55:27 +02:00
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
2026-05-21 17:23:08 +02:00
manual_commands = _manual_update_commands(update_url, request_ip)
2026-05-20 23:55:27 +02:00
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)) or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
message_html = _management_message(message)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
return _page(
"KarlDNS management",
trusted(f"""
2026-05-22 16:27:25 +02:00
<main id="main" class="dashboard-page">
2026-05-20 23:55:27 +02:00
{_top_nav()}
2026-05-22 16:27:25 +02:00
<section class="dashboard-shell">
2026-05-20 23:55:27 +02:00
<div class="container">
2026-05-22 16:27:25 +02:00
<div class="dashboard-header">
2026-05-20 23:55:27 +02:00
<div>
2026-05-22 16:27:25 +02:00
<p class="eyebrow">Dashboard</p>
<h1>{hostname}</h1>
<p class="lead">Private management link. Keep it safe.</p>
2026-05-20 23:55:27 +02:00
</div>
2026-05-22 16:27:25 +02:00
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
2026-05-20 23:55:27 +02:00
</div>
2026-05-22 16:27:25 +02:00
{message_html}
<div class="dashboard-grid">
<section class="dashboard-card dashboard-card-main" aria-labelledby="router-settings-title">
<div class="dashboard-card-header">
<div>
<p class="eyebrow">Router settings</p>
<h2 id="router-settings-title">Update endpoint</h2>
</div>
<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">New update URL</button>
</form>
</div>
<p class="section-copy">Paste the Update-URL into your router. Use the hostname as Domainnamen, or point your own subdomain at it with a CNAME.</p>
{_secret_notice()}
<div class="dashboard-copy-stack">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
</div>
<div class="dashboard-actions">
{_copy_button("Router fields", fritzbox_fields)}
{_copy_button("Copy CNAME", cname_record, style="outline-secondary")}
</div>
</section>
<section class="dashboard-card dashboard-status-card" aria-labelledby="status-title">
<p class="eyebrow">Current address</p>
<h2 id="status-title">Status</h2>
<dl class="dashboard-status-list">
<div><dt>IPv4</dt><dd>{ipv4}</dd></div>
<div><dt>IPv6</dt><dd>{ipv6}</dd></div>
<div><dt>Updated</dt><dd>{updated}</dd></div>
</dl>
</section>
</div>
<section class="dashboard-card dashboard-card-compact" aria-labelledby="manual-updates-title">
<div class="dashboard-card-header">
<div>
<p class="eyebrow">Manual updates</p>
<h2 id="manual-updates-title">curl examples</h2>
</div>
</div>
<p class="section-copy">FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly or omit it and let KarlDNS use the request IP.</p>
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code")}
</section>
2026-05-20 23:55:27 +02:00
</div>
</section>
{dashboard_password_panel}
2026-05-22 16:27:25 +02:00
<section class="dashboard-section">
2026-05-20 23:55:27 +02:00
<div class="container">
2026-05-22 16:27:25 +02:00
<div class="dashboard-section-header">
2026-05-20 23:55:27 +02:00
<div>
<p class="eyebrow">History</p>
<h2>Router updates</h2>
2026-05-21 10:05:40 +02:00
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
2026-05-20 23:55:27 +02:00
</div>
</div>
2026-05-22 16:27:25 +02:00
<div class="table-responsive dashboard-table-shell">
<table class="table table-hover dashboard-history-table mb-0">
2026-05-20 23:55:27 +02:00
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{history_rows}</tbody>
</table>
</div>
</div>
</section>
2026-05-22 16:27:25 +02:00
<section class="dashboard-section dashboard-danger-section">
<div class="container">
<div class="dashboard-danger-card">
<div>
<p class="eyebrow">Delete</p>
<h2>Delete hostname</h2>
<p class="section-copy">Remove this hostname and its DNS records. The update URL will stop working.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/delete">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
2026-05-20 23:55:27 +02:00
</div>
</div>
</section>
</main>
"""),
2026-05-22 16:30:37 +02:00
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
2026-05-20 23:55:27 +02:00
)
2026-05-21 17:23:08 +02:00
def _render_pending_management_page(
2026-05-21 17:58:20 +02:00
service: DdnsService,
2026-05-21 17:23:08 +02:00
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
2026-05-21 17:58:20 +02:00
request_ip: str | None = None,
2026-05-21 17:23:08 +02:00
) -> str:
2026-05-21 17:58:20 +02:00
raw_hostname = str(account["hostname"])
account_for_url = {
"hostname": raw_hostname,
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
dashboard_url = service.dashboard_url({"management_slug": management_slug})
hostname = escape(raw_hostname)
2026-05-21 17:23:08 +02:00
message_html = _management_message(message)
cleanup_hours = settings.cleanup_pending_account_hours
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
2026-05-21 17:58:20 +02:00
manual_commands = _manual_update_commands(update_url, request_ip)
2026-05-21 17:23:08 +02:00
return _page(
2026-05-21 17:58:20 +02:00
"KarlDNS setup",
2026-05-21 17:23:08 +02:00
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Pending setup</p>
<h1>{hostname}</h1>
2026-05-21 17:58:20 +02:00
<p class="lead">Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.</p>
2026-05-21 17:23:08 +02:00
</div>
</section>
{message_html}
2026-05-21 17:58:20 +02:00
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">Save</p>
<h2>Your values.</h2>
2026-05-21 18:30:15 +02:00
<p class="section-copy">Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.</p>
<p class="section-copy"><strong>Public:</strong> Domainnamen. <strong>Secret:</strong> Update-URL and private dashboard.</p>
2026-05-21 17:58:20 +02:00
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
{_copy_row("Domainnamen:", raw_hostname, "Public")}
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
</div>
<form method="post" action="/m/{attr(management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
</div>
</section>
2026-05-21 17:23:08 +02:00
<section class="section section-compact">
2026-05-21 17:58:20 +02:00
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">FRITZ!Box</p>
<h2>Router setup.</h2>
<p class="section-copy">Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field. Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>. No KarlDNS username or password is needed; if your router requires them, enter any placeholder.</p>
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
</div>
<div>
<h2>Manual updates.</h2>
2026-05-21 18:57:45 +02:00
<p class="section-copy">Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP. Visible examples use your public IP when KarlDNS or your browser can detect it.</p>
2026-05-21 19:09:37 +02:00
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
2026-05-21 17:58:20 +02:00
</div>
<div>
<h2>Use your own DNS name.</h2>
2026-05-21 18:30:15 +02:00
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. This is the same value as Domainnamen.</p>
2026-05-21 17:58:20 +02:00
</div>
2026-05-21 17:23:08 +02:00
</div>
</section>
</main>
"""),
)
2026-05-20 23:55:27 +02:00
def _management_message(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
password_status = "Enabled" if password_enabled else "Not enabled"
2026-05-21 10:05:40 +02:00
password_action = "Change password" if password_enabled else "Set password"
2026-05-20 23:55:27 +02:00
remove_form = ""
if password_enabled:
remove_form = f"""
2026-05-22 16:27:25 +02:00
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password/remove">
2026-05-20 23:55:27 +02:00
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
2026-05-21 10:05:40 +02:00
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
2026-05-20 23:55:27 +02:00
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
2026-05-21 10:05:40 +02:00
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
2026-05-20 23:55:27 +02:00
</form>
"""
current_password_field = ""
if password_enabled:
current_password_field = """
<div>
2026-05-21 10:05:40 +02:00
<label class="form-label" for="dashboard-current-password">Current password</label>
2026-05-20 23:55:27 +02:00
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
return trusted(
f"""
2026-05-22 16:27:25 +02:00
<section class="dashboard-section">
2026-05-20 23:55:27 +02:00
<div class="container">
2026-05-22 16:27:25 +02:00
<div class="dashboard-settings-grid">
<div>
2026-05-20 23:55:27 +02:00
<p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2>
2026-05-21 10:05:40 +02:00
<p class="section-copy">Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.</p>
2026-05-20 23:55:27 +02:00
</div>
2026-05-22 16:27:25 +02:00
<div class="d-grid gap-3">
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password">
2026-05-20 23:55:27 +02:00
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
{current_password_field}
<div>
<label class="form-label" for="dashboard-password">{escape(password_action)}</label>
<input class="form-control" id="dashboard-password" name="dashboard_password" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div>
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
<input class="form-control" id="dashboard-password-confirm" name="dashboard_password_confirm" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button>
</div>
</form>
{remove_form}
</div>
</div>
</div>
</section>
"""
)
def _render_management_unlock_page(
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
) -> str:
hostname = escape(account["hostname"])
message_html = ""
if message:
message_html = f'<div class="alert alert-warning" role="alert">{escape(message)}</div>'
return _page(
"Unlock KarlDNS management",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Dashboard locked</p>
<h1>{hostname}</h1>
2026-05-21 10:05:40 +02:00
<p class="lead">Enter the dashboard password for this hostname.</p>
2026-05-20 23:55:27 +02:00
</div>
</section>
<section class="section">
<div class="container narrow">
{message_html}
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/unlock">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<div>
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password" type="password" autocomplete="current-password" required autofocus>
</div>
<label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
2026-05-21 10:05:40 +02:00
<span class="form-check-label">Remember this browser for 30 days</span>
2026-05-20 23:55:27 +02:00
</label>
2026-05-21 10:05:40 +02:00
<button class="btn btn-primary rounded-pill" type="submit">Unlock</button>
2026-05-20 23:55:27 +02:00
</form>
</div>
</section>
</main>
"""),
)
def _user_event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
2026-05-22 16:27:25 +02:00
raw_status = str(event.get("status") or "")
status = escape(_event_label(raw_status))
badge_class = attr(_event_badge_class(raw_status))
2026-05-20 23:55:27 +02:00
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
2026-05-22 16:27:25 +02:00
return f'<tr><td>{created}</td><td><span class="status-badge {badge_class}">{status}</span></td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>'
def _event_badge_class(status: str) -> str:
if status in {"updated", "dashboard_hostname_activated", "unchanged"}:
return "status-badge-ok"
if "failed" in status or "failure" in status:
return "status-badge-error"
return "status-badge-neutral"