Archived
Prepare KarlDNS production deployment
This commit is contained in:
1 parent
5a90bd9d87
commit
ce5b4e0ff2
101 files changed
+3364
-2018
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address
|
||||
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .icons import _svg_icon
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"admin_hostname_deleted": "hostname deleted by admin",
|
||||
"admin_management_url_rotated": "dashboard link rotated by admin",
|
||||
"admin_update_url_rotated": "update URL rotated by admin",
|
||||
"dashboard_hostname_activated": "hostname registered",
|
||||
"dashboard_hostname_deleted": "hostname deleted",
|
||||
"dashboard_password_changed": "dashboard password changed",
|
||||
"dashboard_password_enabled": "dashboard password enabled",
|
||||
"dashboard_password_removed": "dashboard password removed",
|
||||
"dashboard_update_url_rotated": "update URL rotated",
|
||||
"dns_delete_failed": "DNS delete failed",
|
||||
"dns_failed": "DNS update failed",
|
||||
"dns_reconcile_failed": "DNS reconciliation failed",
|
||||
"unchanged": "unchanged",
|
||||
"updated": "updated",
|
||||
}
|
||||
|
||||
|
||||
def _event_label(status: object) -> str:
|
||||
value = str(status or "-")
|
||||
return _EVENT_LABELS.get(value, value.replace("_", " "))
|
||||
|
||||
|
||||
def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = escape(value)
|
||||
badge = ""
|
||||
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
|
||||
if sensitivity:
|
||||
safe_sensitivity = escape(sensitivity)
|
||||
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
|
||||
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
|
||||
value_field = (
|
||||
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
|
||||
if is_secret
|
||||
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
|
||||
)
|
||||
reveal_button = (
|
||||
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>'
|
||||
if is_secret
|
||||
else ""
|
||||
)
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="input-group copy-field">
|
||||
<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 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, *, style: str = "outline-primary") -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = attr(value)
|
||||
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 = "", copy_value: str | None = None) -> 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())
|
||||
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="terminal-card code-window{safe_class}">
|
||||
{_copy_icon_button(copy_label, copy_value)}
|
||||
{code_lines}
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _code_copy_value(commands: str) -> str:
|
||||
lines = commands.splitlines()
|
||||
prompt_lines = [line[2:] for line in lines if line.startswith("$ ")]
|
||||
if prompt_lines:
|
||||
return "\n".join(prompt_lines)
|
||||
return "\n".join(line[2:] if line.startswith("$ ") else line for line in lines)
|
||||
|
||||
|
||||
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
|
||||
ipv4 = "203.0.113.10"
|
||||
ipv6 = "2001:db8::10"
|
||||
if not request_ip:
|
||||
return ipv4, ipv6
|
||||
try:
|
||||
parsed = ip_address(request_ip.strip())
|
||||
except ValueError:
|
||||
return ipv4, ipv6
|
||||
if not parsed.is_global:
|
||||
return ipv4, ipv6
|
||||
if parsed.version == 4:
|
||||
return str(parsed), ipv6
|
||||
return ipv4, str(parsed)
|
||||
|
||||
|
||||
def _manual_update_commands(update_url: str, request_ip: str | None = None) -> str:
|
||||
update_base = update_url.split("?", 1)[0]
|
||||
ipv4, ipv6 = _example_update_ips(request_ip)
|
||||
return "\n".join(
|
||||
[
|
||||
f"curl -sS '{update_base}?myip={ipv4}'",
|
||||
f"curl -sS '{update_base}?myipv6={ipv6}'",
|
||||
f"curl -sS '{update_base}' # use request IP",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _manual_update_panel(commands: str) -> SafeHtml:
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div>
|
||||
<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>
|
||||
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _fritzbox_fields(
|
||||
update_url: str,
|
||||
hostname: str,
|
||||
management_url: str | None = None,
|
||||
) -> str:
|
||||
lines = [
|
||||
f"Update-URL: {update_url}",
|
||||
f"Domainnamen: {hostname}",
|
||||
f"CNAME target: {hostname}",
|
||||
]
|
||||
if management_url:
|
||||
lines.append(f"Private dashboard: {management_url}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
|
||||
secret_items = "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="alert alert-info my-3 py-2 small" role="note">
|
||||
<strong>Public:</strong> Hostname and CNAME target can be shared.
|
||||
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
|
||||
Secrets are hidden on screen. Copy still copies the real value.
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _one_time_secret_notice() -> SafeHtml:
|
||||
return trusted(
|
||||
"""
|
||||
<div class="alert alert-warning my-3 py-2 small" role="alert">
|
||||
Copy the private URLs now. Newly created or rotated secrets are shown only once.
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _message_band(message: str | None) -> SafeHtml:
|
||||
if not message:
|
||||
return trusted("")
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
|
||||
<div class="container">{escape(message)}</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from ..settings import DdnsSettings
|
||||
|
||||
MAX_FORM_FIELDS = 100
|
||||
|
||||
|
||||
def parse_form(body: bytes) -> dict[str, list[str]]:
|
||||
try:
|
||||
decoded = body.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="form body must be valid UTF-8") from exc
|
||||
try:
|
||||
return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields") from exc
|
||||
|
||||
|
||||
async def request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]:
|
||||
body = await request.body()
|
||||
if len(body) > settings.max_request_body_bytes:
|
||||
raise HTTPException(status_code=413, detail="request body too large")
|
||||
return parse_form(body)
|
||||
|
||||
|
||||
def first_form_value(form: dict[str, list[str]], key: str) -> str:
|
||||
return form.get(key, [""])[0].strip()
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
|
||||
class SafeHtml(str):
|
||||
"""HTML fragment that was built by trusted server-side template helpers."""
|
||||
|
||||
|
||||
def escape(value: object) -> str:
|
||||
return html.escape(str(value), quote=True)
|
||||
|
||||
|
||||
def attr(value: object) -> str:
|
||||
return escape(value)
|
||||
|
||||
|
||||
def trusted(fragment: str) -> SafeHtml:
|
||||
return SafeHtml(fragment)
|
||||
|
||||
|
||||
def join_html(fragments: list[str]) -> SafeHtml:
|
||||
return SafeHtml("".join(fragments))
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from .html_utils import SafeHtml, attr, trusted
|
||||
|
||||
_ICON_DIR = Path(__file__).parents[1] / "assets" / "lucide"
|
||||
_ICON_FILES = {
|
||||
"blog": "newspaper",
|
||||
"business": "briefcase",
|
||||
"docs": "book-open",
|
||||
"github": "code",
|
||||
"more": "ellipsis-vertical",
|
||||
"privacy": "shield-check",
|
||||
}
|
||||
|
||||
|
||||
def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
|
||||
path = _lucide_body(name)
|
||||
safe_class = attr(f"lucide-icon {css_class}")
|
||||
return trusted(
|
||||
f'<svg class="{safe_class}" aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24" '
|
||||
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{path}</svg>'
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def _lucide_body(name: str) -> str:
|
||||
file_stem = _ICON_FILES.get(name, name)
|
||||
raw = (_ICON_DIR / f"{file_stem}.svg").read_text(encoding="utf-8")
|
||||
match = re.search(r"<svg\b[^>]*>(.*?)</svg>", raw, flags=re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid Lucide SVG: {file_stem}")
|
||||
return re.sub(r"\s+", " ", match.group(1).strip())
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .icons import _svg_icon
|
||||
|
||||
|
||||
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
|
||||
account_html = ""
|
||||
if label:
|
||||
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">API</a>')
|
||||
theme_toggle = f"""
|
||||
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
|
||||
{_svg_icon("moon", "theme-icon theme-icon-moon")}
|
||||
{_svg_icon("sun", "theme-icon theme-icon-sun")}
|
||||
</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-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}
|
||||
{theme_toggle}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _site_footer() -> SafeHtml:
|
||||
return trusted(
|
||||
f"""
|
||||
<footer class="site-footer border-top">
|
||||
<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_icon("github", "footer-icon")}
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a class="footer-link" href="https://karl.fail" rel="noopener">
|
||||
{_svg_icon("blog", "footer-icon")}
|
||||
<span>karl.fail</span>
|
||||
</a>
|
||||
<a class="footer-link" href="https://karlcom.de" rel="noopener">
|
||||
{_svg_icon("business", "footer-icon")}
|
||||
<span>karlcom.de</span>
|
||||
</a>
|
||||
<a class="footer-link" href="/docs">
|
||||
{_svg_icon("docs", "footer-icon")}
|
||||
<span>API</span>
|
||||
</a>
|
||||
<a class="footer-link" href="/privacy">
|
||||
{_svg_icon("privacy", "footer-icon")}
|
||||
<span>Legal & privacy</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
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">
|
||||
<head>
|
||||
<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">
|
||||
{preload_logo_html}
|
||||
<script src="/theme.js?v=2"></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=12" rel="stylesheet">
|
||||
{extra_styles_html}
|
||||
{structured_data_html}
|
||||
</head>
|
||||
<body>{body}{_site_footer()}</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
THEME_JS = """
|
||||
(() => {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const storageKey = "karldns-theme";
|
||||
const systemTheme = () => (media.matches ? "dark" : "light");
|
||||
const storedTheme = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const setStoredTheme = (theme) => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, theme);
|
||||
} catch (_) {}
|
||||
};
|
||||
const syncToggle = (theme) => {
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
button.dataset.themeNext = nextTheme;
|
||||
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`);
|
||||
});
|
||||
};
|
||||
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
|
||||
const theme = requestedTheme === "dark" ? "dark" : "light";
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
syncToggle(theme);
|
||||
return theme;
|
||||
};
|
||||
applyTheme();
|
||||
media.addEventListener("change", () => {
|
||||
if (!storedTheme()) applyTheme();
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", () => syncToggle(document.documentElement.getAttribute("data-bs-theme") || "light"));
|
||||
document.addEventListener("click", (event) => {
|
||||
const toggle = event.target.closest("[data-theme-toggle]");
|
||||
if (!toggle) return;
|
||||
const currentTheme = document.documentElement.getAttribute("data-bs-theme") || systemTheme();
|
||||
const nextTheme = currentTheme === "dark" ? "light" : "dark";
|
||||
setStoredTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
});
|
||||
})();
|
||||
""".strip()
|
||||
|
||||
|
||||
CLIPBOARD_JS = """
|
||||
document.addEventListener("click", async (event) => {
|
||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||
if (!menu.contains(event.target)) menu.removeAttribute("open");
|
||||
});
|
||||
|
||||
const toggle = event.target.closest("[data-secret-toggle]");
|
||||
if (toggle) {
|
||||
const group = toggle.closest(".copy-field");
|
||||
const field = group ? group.querySelector("[data-secret-input], [data-secret-value]") : null;
|
||||
if (!field) return;
|
||||
if (field.matches("[data-secret-input]")) {
|
||||
const masked = field.type === "password";
|
||||
field.type = masked ? "text" : "password";
|
||||
toggle.textContent = masked ? "Hide" : "View";
|
||||
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
|
||||
} else {
|
||||
const masked = field.dataset.secretMasked !== "false";
|
||||
field.dataset.secretMasked = masked ? "false" : "true";
|
||||
field.textContent = masked ? (field.dataset.secretValue || "") : "********";
|
||||
toggle.textContent = masked ? "Hide" : "View";
|
||||
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const button = event.target.closest("[data-copy]");
|
||||
if (!button) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(button.dataset.copy || "");
|
||||
const label = button.querySelector("[data-copy-label]");
|
||||
const oldLabel = label ? label.textContent : "";
|
||||
button.classList.add("is-copied");
|
||||
if (label) label.textContent = "Copied";
|
||||
const menu = button.closest("details.copy-menu");
|
||||
if (menu) {
|
||||
setTimeout(() => {
|
||||
menu.removeAttribute("open");
|
||||
}, 250);
|
||||
}
|
||||
setTimeout(() => {
|
||||
button.classList.remove("is-copied");
|
||||
if (label) label.textContent = oldLabel;
|
||||
}, 1200);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||
menu.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
""".strip()
|
||||
|
||||
|
||||
ADMIN_TABLES_JS = """
|
||||
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
||||
const table = tableShell.querySelector("table");
|
||||
const tbody = tableShell.querySelector("tbody");
|
||||
const search = tableShell.querySelector("[data-table-search]");
|
||||
const pageSize = tableShell.querySelector("[data-table-page-size]");
|
||||
const count = tableShell.querySelector("[data-table-count]");
|
||||
const pagination = tableShell.querySelector("[data-table-pagination]");
|
||||
if (!table || !tbody || !search || !pageSize || !count || !pagination) return;
|
||||
|
||||
const headerCount = table.querySelectorAll("thead th").length || 1;
|
||||
const originalRows = Array.from(tbody.querySelectorAll("tr"));
|
||||
const dataRows = originalRows.filter((row) => !row.classList.contains("empty-row"));
|
||||
let currentPage = 1;
|
||||
|
||||
const emptyRow = (message) => {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "empty-row";
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "empty";
|
||||
cell.colSpan = headerCount;
|
||||
cell.textContent = message;
|
||||
row.append(cell);
|
||||
return row;
|
||||
};
|
||||
|
||||
const visibleRows = () => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
if (!query) return dataRows;
|
||||
return dataRows.filter((row) => row.textContent.toLowerCase().includes(query));
|
||||
};
|
||||
|
||||
const renderButton = (label, page, disabled = false, active = false) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = `page-item${disabled ? " disabled" : ""}${active ? " active" : ""}`;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "page-link";
|
||||
button.textContent = label;
|
||||
button.disabled = disabled;
|
||||
button.addEventListener("click", () => {
|
||||
currentPage = page;
|
||||
render();
|
||||
});
|
||||
item.append(button);
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderPagination = (totalPages) => {
|
||||
pagination.replaceChildren();
|
||||
if (totalPages <= 1) return;
|
||||
pagination.append(renderButton("Previous", Math.max(1, currentPage - 1), currentPage === 1));
|
||||
for (let page = 1; page <= totalPages; page += 1) {
|
||||
if (totalPages > 7 && page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) {
|
||||
if (page === 2 || page === totalPages - 1) pagination.append(renderButton("...", currentPage, true));
|
||||
continue;
|
||||
}
|
||||
pagination.append(renderButton(String(page), page, false, page === currentPage));
|
||||
}
|
||||
pagination.append(renderButton("Next", Math.min(totalPages, currentPage + 1), currentPage === totalPages));
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const rows = visibleRows();
|
||||
const size = Number.parseInt(pageSize.value, 10) || 25;
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / size));
|
||||
currentPage = Math.min(currentPage, totalPages);
|
||||
const start = (currentPage - 1) * size;
|
||||
const pageRows = rows.slice(start, start + size);
|
||||
tbody.replaceChildren(...(pageRows.length ? pageRows : [emptyRow(dataRows.length ? "No matching rows." : "No rows yet.")]));
|
||||
const shownStart = rows.length ? start + 1 : 0;
|
||||
const shownEnd = rows.length ? Math.min(start + size, rows.length) : 0;
|
||||
count.textContent = `${shownStart}-${shownEnd} of ${rows.length}`;
|
||||
renderPagination(totalPages);
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
currentPage = 1;
|
||||
render();
|
||||
});
|
||||
pageSize.addEventListener("change", () => {
|
||||
currentPage = 1;
|
||||
render();
|
||||
});
|
||||
render();
|
||||
});
|
||||
""".strip()
|
||||
@@ -0,0 +1,151 @@
|
||||
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
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
|
||||
from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
|
||||
ACCESS_LOGGER = logging.getLogger("karldns.access")
|
||||
|
||||
|
||||
class RequestBodyTooLargeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
def __init__(self, app: ASGIApp, max_body_bytes: int) -> None:
|
||||
self.app = app
|
||||
self.max_body_bytes = max_body_bytes
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
received = 0
|
||||
|
||||
async def limited_receive() -> Message:
|
||||
nonlocal received
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
received += len(message.get("body", b""))
|
||||
if received > self.max_body_bytes:
|
||||
raise RequestBodyTooLargeError
|
||||
return message
|
||||
|
||||
try:
|
||||
await self.app(scope, limited_receive, send)
|
||||
except RequestBodyTooLargeError:
|
||||
response = PlainTextResponse("request body too large", status_code=413)
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
|
||||
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:
|
||||
content_length = 0
|
||||
if content_length > settings.max_request_body_bytes:
|
||||
return PlainTextResponse("request body too large", status_code=413)
|
||||
|
||||
if is_rate_limited_path(request.url.path) or is_admin_path(request.url.path, settings):
|
||||
limit = (
|
||||
settings.admin_rate_limit_per_minute
|
||||
if is_admin_path(request.url.path, settings)
|
||||
else settings.rate_limit_per_minute
|
||||
)
|
||||
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"
|
||||
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||
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
|
||||
|
||||
|
||||
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
|
||||
admin_path = settings.admin_path.rstrip("/")
|
||||
return path == admin_path or path.startswith(f"{admin_path}/") or path.startswith("/api/v1/admin/")
|
||||
|
||||
|
||||
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/"):
|
||||
return True
|
||||
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
|
||||
return True
|
||||
if path.startswith("/api/v1/admin/"):
|
||||
return True
|
||||
return request.method == "POST" and path == "/"
|
||||
|
||||
|
||||
def is_noindex_response(request: Request) -> bool:
|
||||
path = request.url.path
|
||||
if request.method != "GET":
|
||||
return True
|
||||
if path in {"/openapi.json", "/healthz"}:
|
||||
return True
|
||||
if path.startswith(("/api/", "/m/")):
|
||||
return True
|
||||
return is_admin_path(path, request.app.state.settings)
|
||||
|
||||
|
||||
def content_security_policy(path: str) -> str:
|
||||
script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
|
||||
return (
|
||||
"default-src 'self'; "
|
||||
f"{script_src}"
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
|
||||
|
||||
def redacted_access_path(path: str) -> str:
|
||||
secret_prefixes = {
|
||||
"/m": "/m/<secret>",
|
||||
"/api/v1/hostnames": "/api/v1/hostnames/<secret>",
|
||||
"/api/v1/router/register": "/api/v1/router/register/<secret>",
|
||||
"/api/v1/router/update": "/api/v1/router/update/<secret>",
|
||||
}
|
||||
for prefix, replacement in secret_prefixes.items():
|
||||
if path == prefix or path.startswith(f"{prefix}/"):
|
||||
return replacement
|
||||
return path
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ..security import admin_csrf_token
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .templates_admin_tables import (
|
||||
_account_row,
|
||||
_admin_table_pagination,
|
||||
_admin_table_toolbar,
|
||||
_event_row,
|
||||
)
|
||||
|
||||
|
||||
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="section success-section">
|
||||
<div class="container">
|
||||
<p class="eyebrow">Admin action</p>
|
||||
<h2>{escape(title)}</h2>
|
||||
<p class="section-copy">Copy these values now. Secret values are only shown once.</p>
|
||||
<div class="d-grid gap-2">
|
||||
{"".join(rows)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
def _render_admin_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
accounts: list[dict[str, str | int | None]],
|
||||
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,
|
||||
notice_html: SafeHtml | None,
|
||||
) -> str:
|
||||
csrf = attr(admin_csrf_token(settings))
|
||||
admin_path = settings.admin_path
|
||||
safe_admin_path = attr(admin_path)
|
||||
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>
|
||||
"""
|
||||
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)))
|
||||
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))
|
||||
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")}
|
||||
<section class="section admin-heading">
|
||||
<div class="container">
|
||||
<p class="eyebrow">KarlDNS</p>
|
||||
<h1>Operator console</h1>
|
||||
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice_html or ""}
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<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>
|
||||
{cleanup_panel}
|
||||
<div class="mt-4">
|
||||
{metrics_html}
|
||||
</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">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 scope="col">Hostname</th><th scope="col">Status</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>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{account_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>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 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>
|
||||
{_admin_table_pagination()}
|
||||
{event_pager}
|
||||
</div>
|
||||
</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>'),
|
||||
)
|
||||
|
||||
|
||||
def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: int, total: int) -> SafeHtml:
|
||||
if total <= limit:
|
||||
return trusted("")
|
||||
previous_offset = max(0, offset - limit)
|
||||
next_offset = offset + limit
|
||||
previous_disabled = " disabled" if offset <= 0 else ""
|
||||
next_disabled = " disabled" if next_offset >= total else ""
|
||||
previous_href = attr(f"{admin_path}?{urlencode({offset_name: previous_offset})}")
|
||||
next_href = attr(f"{admin_path}?{urlencode({offset_name: next_offset})}")
|
||||
start = min(offset + 1, total)
|
||||
end = min(offset + limit, total)
|
||||
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</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 {}
|
||||
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(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))
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .components import _event_label
|
||||
from .html_utils import attr, escape
|
||||
|
||||
|
||||
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
|
||||
hostname = escape(account["hostname"])
|
||||
status = "Active" if account.get("accepted_at") else "Pending"
|
||||
ipv4 = escape(account.get("ipv4") or "-")
|
||||
ipv6 = escape(account.get("ipv6") or "-")
|
||||
updated = escape(account.get("updated_at") or "Never")
|
||||
safe_admin_path = attr(admin_path)
|
||||
return f"""
|
||||
<tr>
|
||||
<td>{hostname}</td><td>{escape(status)}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
|
||||
<td>
|
||||
<form method="post" action="{safe_admin_path}/hostnames/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="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>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{safe_admin_path}/hostnames/delete">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname}">
|
||||
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
|
||||
def _admin_table_toolbar(table_id: str, search_label: str) -> str:
|
||||
safe_id = attr(table_id)
|
||||
safe_label = attr(search_label)
|
||||
return f"""
|
||||
<div class="admin-table-shell mt-3" data-admin-table>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row mb-3">
|
||||
<label class="w-100 mb-0" for="{safe_id}-search">
|
||||
<span class="visually-hidden">{safe_label}</span>
|
||||
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}" data-table-search autocomplete="off">
|
||||
</label>
|
||||
<div class="d-flex align-items-center gap-2 flex-shrink-0">
|
||||
<span class="small text-secondary text-nowrap" data-table-count>0-0 of 0</span>
|
||||
<label class="visually-hidden" for="{safe_id}-page-size">Rows per page</label>
|
||||
<select id="{safe_id}-page-size" class="form-select form-select-sm table-page-size" data-table-page-size>
|
||||
<option value="10">10</option>
|
||||
<option value="25" selected>25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _admin_table_pagination() -> str:
|
||||
return """
|
||||
<nav class="mt-3" aria-label="Table pagination">
|
||||
<ul class="pagination pagination-sm justify-content-end mb-0" data-table-pagination></ul>
|
||||
</nav>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _event_row(event: dict[str, str | int | None]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
hostname = escape(event.get("hostname") or "-")
|
||||
status = escape(_event_label(event.get("status")))
|
||||
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>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .components import (
|
||||
_copy_menu,
|
||||
_copy_row,
|
||||
_event_label,
|
||||
_fritzbox_fields,
|
||||
_manual_update_commands,
|
||||
_manual_update_panel,
|
||||
_secret_notice,
|
||||
)
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .templates_public_sections import _created_hostname_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,
|
||||
created_account: dict[str, str] | None = None,
|
||||
request_ip: str | None = None,
|
||||
) -> 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")
|
||||
fritzbox_fields = _fritzbox_fields(update_url, hostname)
|
||||
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
|
||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
||||
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_links = _created_hostname_panel(service, created_account, request_ip)
|
||||
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
|
||||
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">Dashboard</p>
|
||||
<h1>{hostname}</h1>
|
||||
<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}
|
||||
{rotated_links}
|
||||
<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 this Update-URL in your router or script. Optional: point another DNS name at this hostname with a CNAME.</p>
|
||||
{_secret_notice()}
|
||||
<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">
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
</div>
|
||||
</div>
|
||||
{_manual_update_panel(manual_commands)}
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div>
|
||||
<p class="eyebrow">Actions</p>
|
||||
<h3>Replace router update URL</h3>
|
||||
<p class="section-copy">Create a new update URL. Update your router after changing it.</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">New update URL</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{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 updates 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">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>
|
||||
<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>
|
||||
"""),
|
||||
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
|
||||
)
|
||||
|
||||
def _render_pending_management_page(
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: dict[str, str | int | None],
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
) -> str:
|
||||
hostname = escape(account["hostname"])
|
||||
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"
|
||||
return _page(
|
||||
"Register hostname",
|
||||
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>
|
||||
<p class="lead">Register this hostname after you have copied the private values. Unregistered setups are cleaned up after {escape(cleanup_text)}.</p>
|
||||
<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>
|
||||
{message_html}
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow text-center">
|
||||
<p class="section-copy mx-auto">Lost the first page? Create a new hostname. Private URLs are shown only once.</p>
|
||||
</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 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 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 password</button>
|
||||
</form>
|
||||
"""
|
||||
current_password_field = ""
|
||||
if password_enabled:
|
||||
current_password_field = """
|
||||
<div>
|
||||
<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>
|
||||
"""
|
||||
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 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">
|
||||
<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 for 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">Remember this browser for 30 days</span>
|
||||
</label>
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Unlock</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
)
|
||||
|
||||
|
||||
def _user_event_row(event: dict[str, str | int | None]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
status = escape(_event_label(event.get("status")))
|
||||
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>"
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .components import _code_window, _example_update_ips, _message_band
|
||||
from .html_utils import escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .templates_public_sections import _created_hostname_panel, _linux_installer_section
|
||||
|
||||
|
||||
def _render_public_home(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
created_account: dict[str, str] | None,
|
||||
message: str | None,
|
||||
request_ip: str | None = None,
|
||||
) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
|
||||
suffix = escape(hostname_suffix)
|
||||
demo_update_url = escape(f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>")
|
||||
example_ipv4, _ = _example_update_ips(request_ip)
|
||||
create_demo_command = f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'"
|
||||
create_demo = f"{create_demo_command}\ngood {example_ipv4}"
|
||||
manual_demo = "\n".join(
|
||||
[
|
||||
"# explicit IPv4",
|
||||
f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'",
|
||||
"# source IP fallback",
|
||||
f"curl -sS '{base_url}/api/v1/router/update/<secret>'",
|
||||
]
|
||||
)
|
||||
created_html = _created_hostname_panel(service, created_account, request_ip)
|
||||
message_html = _message_band(message) if message else ""
|
||||
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"""
|
||||
<main id="main">
|
||||
{_top_nav()}
|
||||
<section class="hero-band">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<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">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">
|
||||
<form method="post" action="/" class="hero-action-form" aria-label="Create a generated DynDNS hostname">
|
||||
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
|
||||
</form>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{message_html}
|
||||
{created_html}
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow text-center">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>No account needed.</h2>
|
||||
<p class="section-copy mx-auto">Create a hostname, save the private values, register it, done.</p>
|
||||
<ol class="flow-steps" aria-label="KarlDNS setup flow">
|
||||
<li><strong>Create</strong><span>Random hostname and private links.</span></li>
|
||||
<li><strong>Save</strong><span>Copy the secrets while they are shown.</span></li>
|
||||
<li><strong>Register</strong><span>Activate the hostname when you are ready.</span></li>
|
||||
<li><strong>Update</strong><span>Router, cron, or CNAME can use it.</span></li>
|
||||
</ol>
|
||||
{_code_window(create_demo, copy_label="update example", class_name="mt-4", copy_value=create_demo_command)}
|
||||
</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>
|
||||
{_code_window(manual_demo, copy_label="manual update examples", class_name="text-start mt-4")}
|
||||
</div>
|
||||
</section>
|
||||
{_linux_installer_section(base_url)}
|
||||
</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",
|
||||
"CNAME target for external DNS names",
|
||||
"Linux cron updater",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
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, IP history, and cleanup behavior."
|
||||
return _page(
|
||||
"Legal and Privacy",
|
||||
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">Legal and privacy</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 hostnames, protected dashboard tokens, private update links, recent IP history, timestamps, and service events.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Secrets</h2>
|
||||
<p class="section-copy">Dashboard links, registration URLs, and update URLs are secrets. Anyone with them may update or manage the hostname. 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 unused hostnames. Server logs may also include IP addresses and request details.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Contact</h2>
|
||||
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</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}/",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
example_ipv4, _ = _example_update_ips(request_ip)
|
||||
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
|
||||
register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/<dashboard-secret>?myip={example_ipv4}'"
|
||||
update_example = f"curl -sS '{base_url}/api/v1/router/update/<update-secret>?myip={example_ipv4}'"
|
||||
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<dashboard-secret>?history_limit=10'"
|
||||
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<dashboard-secret>/rotate-update-url"
|
||||
schema_example = f"curl -sS {base_url}/openapi.json"
|
||||
admin_dashboard_example = f"curl -sS -u admin:<admin-password> {base_url}/api/v1/admin/dashboard"
|
||||
admin_history_example = f"curl -sS -u admin:<admin-password> '{base_url}/api/v1/admin/events?limit=25'"
|
||||
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
|
||||
return _page(
|
||||
"KarlDNS API",
|
||||
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">API</p>
|
||||
<h1>Small API surface.</h1>
|
||||
<p class="lead">Three surfaces: admin API, user dashboard data, and router registration/update calls.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow d-grid gap-4">
|
||||
<div class="api-endpoints">
|
||||
<h2>Endpoints</h2>
|
||||
<ol class="simple-steps text-start">
|
||||
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and one-time secrets. The response includes <code>registration_url</code>.</li>
|
||||
<li><strong>GET or POST /api/v1/router/register/<secret>.</strong> Register the hostname after secrets are saved. Uses the request IP, or <code>myip</code>/<code>myipv6</code>, for the initial DNS record.</li>
|
||||
<li><strong>GET or POST /api/v1/router/update/<secret>.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
|
||||
<li><strong>GET /api/v1/hostnames/<secret>.</strong> Read hostname status and recent history.</li>
|
||||
<li><strong>POST /api/v1/hostnames/<secret>/rotate-update-url.</strong> Replace the private router update URL.</li>
|
||||
<li><strong>DELETE /api/v1/hostnames/<secret>.</strong> Delete hostname and DNS records.</li>
|
||||
<li><strong>GET /api/v1/admin/dashboard.</strong> Read operator summary, metrics, recent hostnames, cleanup state, and recent update history with admin Basic auth.</li>
|
||||
<li><strong>GET /api/v1/admin/events.</strong> Read paginated update history with admin Basic auth. Also available: <code>/api/v1/admin/hostnames</code> and <code>/api/v1/admin/records</code>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Create</h2>
|
||||
{_code_window(create_example, copy_label="create command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Register</h2>
|
||||
{_code_window(register_example, copy_label="register command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Update</h2>
|
||||
{_code_window(update_example, copy_label="update command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Status</h2>
|
||||
<p class="section-copy">Use the private dashboard secret to read current DNS state and recent updates for only that hostname.</p>
|
||||
{_code_window(status_example, copy_label="status command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Rotate update URL</h2>
|
||||
<p class="section-copy">Use the private dashboard secret to replace the router update URL. The old update URL stops working immediately.</p>
|
||||
{_code_window(rotate_example, copy_label="rotate command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Schema</h2>
|
||||
<p class="section-copy">Machine-readable OpenAPI is still available. Operator API routes are included and require admin Basic auth.</p>
|
||||
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Operator API</h2>
|
||||
<p class="section-copy">Admin endpoints use the same HTTP Basic auth as the operator console.</p>
|
||||
{_code_window(admin_dashboard_example, copy_label="admin dashboard command", class_name="mt-3")}
|
||||
{_code_window(admin_history_example, copy_label="admin history command", class_name="mt-3")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
description=description,
|
||||
canonical_url=f"{base_url}/docs",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS API documentation",
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..security import dashboard_csrf_token
|
||||
from ..services.ddns import DdnsService
|
||||
from .components import (
|
||||
_code_window,
|
||||
_copy_menu,
|
||||
_copy_row,
|
||||
_fritzbox_fields,
|
||||
_manual_update_commands,
|
||||
_manual_update_panel,
|
||||
_one_time_secret_notice,
|
||||
_secret_notice,
|
||||
)
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
|
||||
|
||||
def _linux_installer_section(base_url: str) -> SafeHtml:
|
||||
base_url = base_url.rstrip("/")
|
||||
system_install = f"curl -fsSL {base_url}/install.sh | sudo sh"
|
||||
system_uninstall = f"curl -fsSL {base_url}/uninstall.sh | sudo sh"
|
||||
return trusted(
|
||||
f"""
|
||||
<section class="section" id="linux-installer">
|
||||
<div class="container narrow">
|
||||
<div class="text-center mb-4">
|
||||
<p class="eyebrow">Linux cron updater</p>
|
||||
<h2>Linux updater.</h2>
|
||||
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates and registers a hostname, installs the updater, schedules cron, and prints your private dashboard link. Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.</p>
|
||||
</div>
|
||||
<div class="installer-commands">
|
||||
<div class="installer-command">
|
||||
<h3>Install</h3>
|
||||
<p class="section-copy">Sets up the updater and cron job.</p>
|
||||
{_code_window(system_install, copy_label="install command", class_name="mt-3")}
|
||||
</div>
|
||||
<div class="installer-command">
|
||||
<h3>Uninstall</h3>
|
||||
<p class="section-copy">Stops the updater and leaves private URLs on disk.</p>
|
||||
{_code_window(system_uninstall, copy_label="uninstall command", class_name="mt-3")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _created_hostname_panel(service: DdnsService, created_account: dict[str, str] | None, request_ip: str | None = None) -> SafeHtml:
|
||||
if not created_account:
|
||||
return trusted("")
|
||||
hostname = created_account["hostname"]
|
||||
update_url = service.fritz_update_url(created_account)
|
||||
management_url = service.dashboard_url(created_account)
|
||||
registration_url = service.registration_url(created_account)
|
||||
csrf_token = dashboard_csrf_token(created_account["management_slug"], created_account)
|
||||
management_slug = attr(created_account["management_slug"])
|
||||
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
|
||||
fritzbox_fields = _fritzbox_fields(update_url, hostname, management_url)
|
||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
||||
return trusted(
|
||||
f"""
|
||||
<section class="section success-section">
|
||||
<div class="container">
|
||||
<p class="eyebrow">Pending setup</p>
|
||||
<h2>{escape(hostname)}</h2>
|
||||
<p class="section-copy">Copy these values now. Then register the hostname so router updates can start. Unregistered setups are removed after 24 hours.</p>
|
||||
{_one_time_secret_notice()}
|
||||
{_secret_notice(include_registration=True)}
|
||||
<div class="copy-actions my-3">
|
||||
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Register URL", registration_url), ("CNAME", cname_record)])}
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Register URL:", registration_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
{_copy_row("Private dashboard:", management_url, "Secret")}
|
||||
</div>
|
||||
<form class="mt-4" method="post" action="/m/{management_slug}/activate">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
||||
</form>
|
||||
{_manual_update_panel(manual_commands)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
)
|
||||
Reference in new issue
Block a user