Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
@@ -0,0 +1,377 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..ddns_service import DdnsService
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .templates import (
|
||||
_copy_button,
|
||||
_copy_row,
|
||||
_fritzbox_fields,
|
||||
_manual_update_commands,
|
||||
_manual_update_panel,
|
||||
_one_time_secret_notice,
|
||||
_page,
|
||||
_secret_notice,
|
||||
_top_nav,
|
||||
)
|
||||
from .templates_public_sections import _credentials_panel
|
||||
|
||||
|
||||
def _render_management_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
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 = {
|
||||
"hostname": str(account["hostname"]),
|
||||
"username": str(account["username"]),
|
||||
"password": "",
|
||||
"update_slug": str(account["update_slug"]),
|
||||
"management_slug": management_slug,
|
||||
}
|
||||
update_url = service.fritz_update_url(account_for_url)
|
||||
hostname = escape(account["hostname"])
|
||||
username = escape(account["username"])
|
||||
ipv4 = escape(account.get("ipv4") or "-")
|
||||
ipv6 = escape(account.get("ipv6") or "-")
|
||||
updated = escape(account.get("updated_at") or "Never")
|
||||
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated")
|
||||
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
|
||||
manual_commands = _manual_update_commands(update_url)
|
||||
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)
|
||||
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",
|
||||
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">Magic management</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>
|
||||
</div>
|
||||
</section>
|
||||
{message_html}
|
||||
{rotated_credentials}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<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>
|
||||
{_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)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 d-grid gap-2">
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
{_copy_row("Benutzername (optional):", username, "Public")}
|
||||
{_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
|
||||
</div>
|
||||
</div>
|
||||
{_manual_update_panel(manual_commands)}
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</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">
|
||||
<div>
|
||||
<p class="eyebrow">Current address</p>
|
||||
<h2>Status</h2>
|
||||
</div>
|
||||
<div class="table-responsive status-table-shell">
|
||||
<table class="table status-table mb-0">
|
||||
<tbody>
|
||||
<tr><th>IPv4</th><td>{ipv4}</td></tr>
|
||||
<tr><th>IPv6</th><td>{ipv6}</td></tr>
|
||||
<tr><th>Updated</th><td>{updated}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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">History</p>
|
||||
<h2>Router updates</h2>
|
||||
<p class="section-copy">Recent IP changes and failed DNS publishes for this hostname.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive border rounded admin-table-scroll">
|
||||
<table class="table mb-0">
|
||||
<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>
|
||||
<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>
|
||||
<h2>Delete hostname</h2>
|
||||
<p class="section-copy">This removes the hostname and DNS records. The router 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>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
)
|
||||
|
||||
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"
|
||||
password_action = "Change dashboard password" if password_enabled else "Set dashboard 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>
|
||||
<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>
|
||||
</form>
|
||||
"""
|
||||
current_password_field = ""
|
||||
if password_enabled:
|
||||
current_password_field = """
|
||||
<div>
|
||||
<label class="form-label" for="dashboard-current-password">Current dashboard password</label>
|
||||
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
"""
|
||||
return trusted(
|
||||
f"""
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
<p class="lead">Enter the dashboard password to manage this hostname.</p>
|
||||
</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">
|
||||
<span class="form-check-label">Stay unlocked for 30 days on this browser</span>
|
||||
</label>
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Unlock dashboard</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
)
|
||||
|
||||
|
||||
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>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>
|
||||
</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>
|
||||
</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 verification"
|
||||
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. Another dashboard cannot verify or 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="d-flex gap-2 flex-wrap">
|
||||
{_copy_button("Copy TXT record", 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 claim</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>Custom 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>
|
||||
</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 "-")
|
||||
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 "-")
|
||||
return f"<tr><td>{created}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
|
||||
Reference in new issue
Block a user