Refactor UI assets and harden quality checks

This commit is contained in:
StasonJatham committed 2026-06-17 23:18:00 +02:00
1 parent 23ab466c52
commit 4ecc8eb874
62 files changed
+3314 -3206

No files matched your search

+110 -39
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from ipaddress import ip_address
from .html_utils import SafeHtml, attr, escape, trusted
@@ -28,32 +29,77 @@ def _event_label(status: object) -> str:
return _EVENT_LABELS.get(value, value.replace("_", " "))
@dataclass(frozen=True, slots=True)
class _CopyRowParts:
label: str
value: str
safe_label: str
safe_value: str
attr_label: str
attr_value: str
is_secret: bool
should_mask: bool
sensitivity_badge: str
@property
def row_class(self) -> str:
return "copy-field copy-field-secret" if self.is_secret else "copy-field copy-field-public"
@property
def value_field(self) -> str:
if self.should_mask:
return (
f'<input class="copy-pill-value copy-value" type="password" value="{self.attr_value}" '
"readonly data-secret-input>"
)
return f'<code class="copy-pill-value copy-value">{self.safe_value}</code>'
@property
def reveal_button(self) -> str:
if not self.should_mask:
return ""
return (
f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" '
f'data-secret-toggle aria-label="Show {self.attr_label}">View</button>'
)
@classmethod
def from_values(
cls, label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True
) -> _CopyRowParts:
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
return cls(
label=label,
value=value,
safe_label=escape(label),
safe_value=escape(value),
attr_label=attr(label),
attr_value=attr(value),
is_secret=is_secret,
should_mask=is_secret and mask_secret,
sensitivity_badge=_copy_row_sensitivity_badge(sensitivity, is_secret),
)
def _copy_row_sensitivity_badge(sensitivity: str | None, is_secret: bool) -> str:
if not sensitivity:
return ""
badge_kind = "secret" if is_secret else "public"
return f'<span class="ui-badge ui-badge-{badge_kind}">{escape(sensitivity)}</span>'
def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
safe_label = escape(label)
safe_value = escape(value)
attr_label = attr(label)
attr_value = attr(value)
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
should_mask = is_secret and mask_secret
value_field = (
f'<input class="copy-pill-value copy-value" type="password" value="{attr_value}" readonly data-secret-input>'
if should_mask
else f'<code class="copy-pill-value copy-value">{safe_value}</code>'
)
reveal_button = (
f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" data-secret-toggle aria-label="Show {attr_label}">View</button>'
if should_mask
else ""
)
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
parts = _CopyRowParts.from_values(label, value, sensitivity, mask_secret=mask_secret)
return trusted(f"""
<div class="{row_class}">
<div class="{parts.row_class}">
<div class="copy-pill">
<span class="copy-pill-label">{safe_label}</span>
{value_field}
{reveal_button}
<span class="copy-pill-label">{parts.safe_label}</span>
{parts.sensitivity_badge}
{parts.value_field}
{parts.reveal_button}
</div>
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button" data-copy="{attr_value}" aria-label="Copy {attr_label}">
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button"
data-copy="{parts.attr_value}" aria-label="Copy {parts.attr_label}">
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button>
</div>
@@ -66,16 +112,22 @@ def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> S
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>'
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}'
f'<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)
button_attrs = (
f'type="button" class="copy-icon-button" data-copy="{safe_value}" '
f'aria-label="Copy {safe_label}" title="Copy"'
)
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>'
f"<button {button_attrs}>"
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>'
f'{_svg_icon("check", "check-icon")}</button>'
)
@@ -83,9 +135,14 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
safe_label = escape(label)
buttons = []
for item_label, item_value in items:
button_attrs = (
f'type="button" class="copy-menu-item" data-copy="{attr(item_value)}" '
f'aria-label="Copy {attr(item_label)}"'
)
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>'
f"<button {button_attrs}>"
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>'
f'{_svg_icon("check", "check-icon")}</button>'
)
return trusted(f"""
<details class="copy-menu">
@@ -106,7 +163,7 @@ def _code_window(
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}">
<div class="terminal-card code-window{safe_class}" data-slot="code-window">
{_copy_icon_button(copy_label, copy_value)}
{code_lines}
</div>
@@ -115,10 +172,14 @@ def _code_window(
def _code_copy_value(commands: str) -> str:
lines = commands.splitlines()
prompt_lines = [line[2:] for line in lines if line.startswith("$ ")]
prompt_lines = [_code_copy_line(line) 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)
return "\n".join(_code_copy_line(line) for line in lines)
def _code_copy_line(line: str) -> str:
return line[2:] if line.startswith("$ ") else line
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
@@ -164,12 +225,17 @@ def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]:
def _manual_update_panel(commands: str) -> SafeHtml:
return trusted(f"""
<div class="card card-body gap-3 mt-4">
<div>
<div class="card card-body gap-3 mt-4" data-slot="card">
<div data-slot="card-header">
<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>
<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>
<div data-slot="card-content">
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
""")
@@ -194,10 +260,14 @@ def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
"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 class="alert alert-info ui-alert my-3 py-2 small" role="note" data-slot="alert">
<span class="ui-badge ui-badge-public">Public:</span>
<span>Hostname and CNAME target can be shared.</span>
<span class="ui-badge ui-badge-secret">Secret:</span>
<span>
{escape(secret_items)} can update or manage this hostname. Secrets are hidden on screen.
Copy still copies the real value.
</span>
</div>
""")
@@ -206,7 +276,8 @@ 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="alert alert-warning ui-alert mb-0 rounded-0 border-start-0 border-end-0"
role="status" data-slot="alert">
<div class="container">{escape(message)}</div>
</div>
""")
+2 -1
View File
@@ -22,7 +22,8 @@ def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
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>'
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
f'stroke-linejoin="round">{path}</svg>'
)
+107 -65
View File
@@ -1,10 +1,27 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from .html_utils import SafeHtml, attr, escape, trusted
from .icons import _svg_icon
DEFAULT_DESCRIPTION = (
"KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, " "Linux cron jobs, and home networks."
)
@dataclass(frozen=True)
class PageOptions:
extra_styles: SafeHtml = field(default_factory=lambda: trusted(""))
extra_scripts: SafeHtml = field(default_factory=lambda: trusted(""))
description: str = DEFAULT_DESCRIPTION
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
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
@@ -22,8 +39,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
<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">
<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}
@@ -68,60 +88,11 @@ def _site_footer() -> SafeHtml:
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,
options: PageOptions | 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>"
)
options = options or PageOptions()
return f"""
<!doctype html>
<html lang="en">
@@ -129,25 +100,96 @@ def _page(
<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}
<meta name="description" content="{attr(options.description)}">
{_canonical_tag(options.canonical_url)}
{_social_meta_tags(
title=title,
description=options.description,
canonical_url=options.canonical_url,
image_url=options.image_url,
image_alt=options.image_alt,
)}
<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>
<script src="/visitor-ip.js?v=1" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=13" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
{_logo_preload_tag(options.preload_logo)}
{_base_script_tags()}
{options.extra_scripts}
{_base_style_tags()}
{options.extra_styles}
{_structured_data_tag(options.structured_data)}
</head>
<body>{body}{_site_footer()}</body>
</html>
"""
def _canonical_tag(canonical_url: str | None) -> SafeHtml:
return trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
def _logo_preload_tag(preload_logo: bool) -> SafeHtml:
if not preload_logo:
return trusted("")
return trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
def _base_script_tags() -> SafeHtml:
return trusted("""
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=3" defer></script>
<script src="/visitor-ip.js?v=1" defer></script>
""")
def _base_style_tags() -> SafeHtml:
return trusted("""
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=14" rel="stylesheet">
""")
def _social_meta_tags(
*,
title: str,
description: str,
canonical_url: str | None,
image_url: str | None,
image_alt: str | None,
) -> SafeHtml:
if not canonical_url:
return trusted("")
social_card = "summary_large_image" if image_url else "summary"
return 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(canonical_url)}">
{_social_image_tags(image_url, image_alt or title)}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
def _social_image_tags(image_url: str | None, image_alt: str) -> SafeHtml:
if not image_url:
return trusted("")
return trusted(f"""
<meta property="og:image" content="{attr(image_url)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(image_alt)}">
<meta name="twitter:image" content="{attr(image_url)}">
<meta name="twitter:image:alt" content="{attr(image_alt)}">
""")
def _structured_data_tag(structured_data: dict[str, object] | None) -> SafeHtml:
if not structured_data:
return trusted("")
payload = json.dumps(structured_data, ensure_ascii=False, separators=(",", ":"))
return trusted('<script type="application/ld+json">' + escape(payload) + "</script>")
+1 -280
View File
@@ -1,282 +1,3 @@
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()
VISITOR_IP_JS = """
(() => {
const ipv4Placeholder = "203.0.113.10";
const ipv6Placeholder = "2001:db8::10";
const needsIpv4 = () => document.body.textContent.includes(ipv4Placeholder) || document.querySelector(`[data-copy*="${ipv4Placeholder}"]`);
const needsIpv6 = () => document.body.textContent.includes(ipv6Placeholder) || document.querySelector(`[data-copy*="${ipv6Placeholder}"]`);
const fetchIp = async (url) => {
try {
const response = await fetch(url, { cache: "no-store", mode: "cors" });
if (!response.ok) return "";
const payload = await response.json();
return typeof payload.ip === "string" ? payload.ip : "";
} catch (_) {
return "";
}
};
const replaceInTextNodes = (search, replacement) => {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) {
if (walker.currentNode.nodeValue.includes(search)) nodes.push(walker.currentNode);
}
nodes.forEach((node) => {
node.nodeValue = node.nodeValue.split(search).join(replacement);
});
};
const replaceInCopyValues = (search, replacement) => {
document.querySelectorAll("[data-copy]").forEach((element) => {
const value = element.dataset.copy || "";
if (value.includes(search)) element.dataset.copy = value.split(search).join(replacement);
});
};
const replaceEverywhere = (search, replacement) => {
if (!replacement || replacement === search) return;
replaceInTextNodes(search, replacement);
replaceInCopyValues(search, replacement);
};
const updateCopyCommand = (button, ipv6Command) => {
const value = button.dataset.copy || "";
if (!value || value.includes("?myipv6=")) return;
const lines = value.split("\\n");
const output = [];
let inserted = false;
lines.forEach((line) => {
output.push(line);
if (!inserted && line.includes("/api/v1/router/update/") && line.includes("?myip=")) {
output.push(ipv6Command);
inserted = true;
}
});
if (inserted) button.dataset.copy = output.join("\\n");
};
const insertIpv6Examples = (ip) => {
if (!ip) return;
document.querySelectorAll(".terminal-card.manual-code").forEach((card) => {
if (card.textContent.includes("?myipv6=")) return;
const lines = [...card.querySelectorAll("code")];
const ipv4Line = lines.find((line) => line.textContent.includes("/api/v1/router/update/") && line.textContent.includes("?myip="));
if (!ipv4Line) return;
const base = ipv4Line.textContent.split("?myip=", 1)[0].replace(/'$/, "");
const ipv6Command = `${base}?myipv6=${ip}'`;
const code = document.createElement("code");
code.textContent = ipv6Command;
ipv4Line.after(code);
const copyButton = card.querySelector("[data-copy]");
if (copyButton) updateCopyCommand(copyButton, ipv6Command);
});
};
document.addEventListener("DOMContentLoaded", async () => {
const tasks = [];
if (needsIpv4()) tasks.push(fetchIp("https://api4.ipify.org?format=json").then((ip) => replaceEverywhere(ipv4Placeholder, ip)));
if (needsIpv6() || document.querySelector(".terminal-card.manual-code")) {
tasks.push(fetchIp("https://api6.ipify.org?format=json").then((ip) => {
replaceEverywhere(ipv6Placeholder, ip);
insertIpv6Examples(ip);
}));
}
await Promise.allSettled(tasks);
});
})();
""".strip()
"""JavaScript is served from package assets by karldns.routers.assets."""
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from ..components import _code_window
from ..html_utils import SafeHtml, escape, trusted
def _public_home_hero(*, suffix: str, demo_update_url: str) -> SafeHtml:
return trusted(f"""
<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" data-ui="hero-command">
<code>{escape(demo_update_url)}</code>
</div>
<p class="lead">
KarlDNS gives you a generated DynDNS hostname under {escape(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>
""")
def _setup_flow_section(*, create_demo: str, create_demo_command: str) -> SafeHtml:
return trusted(f"""
<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>
""")
def _manual_updates_section(*, manual_demo: str) -> SafeHtml:
return trusted(f"""
<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="manual-code text-start mt-4")}
</div>
</section>
""")
+15 -9
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import logging
import time
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from starlette.responses import Response
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
@@ -13,6 +15,12 @@ from ..settings import DdnsSettings
from ..store import DdnsStore
ACCESS_LOGGER = logging.getLogger("karldns.access")
SECRET_RESPONSE_PREFIXES = (
"/m/",
"/api/v1/hostnames",
"/api/v1/router/",
"/api/v1/admin/",
)
class RequestBodyTooLargeError(Exception):
@@ -49,7 +57,7 @@ class RequestBodyLimitMiddleware:
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
async def security_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
started_at = time.perf_counter()
try:
content_length = int(request.headers.get("content-length", "0") or "0")
@@ -102,14 +110,12 @@ def is_admin_path(path: str, settings: DdnsSettings) -> bool:
def is_secret_response(request: Request) -> bool:
path = request.url.path
settings = request.app.state.settings
if is_admin_path(path, settings) or path.startswith("/m/"):
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 == "/"
return (
is_admin_path(path, request.app.state.settings)
or path.startswith(SECRET_RESPONSE_PREFIXES)
or request.method == "POST"
and path == "/"
)
def is_noindex_response(request: Request) -> bool:
+1 -1718
View File
File diff suppressed because it is too large. Load diff
+270 -114
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from urllib.parse import urlencode
from ..security import admin_csrf_token
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from ..store import DdnsStore
from .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav
from .layout import PageOptions, _page, _top_nav
from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
@@ -17,6 +18,52 @@ from .templates_admin_tables import (
)
@dataclass(frozen=True)
class _CleanupSummary:
last_run: object
next_run: object
status: object
last_error: object
interval_seconds: object
accounts_removed: object
@dataclass(frozen=True)
class AdminPageContext:
accounts: Sequence[Mapping[str, object]]
cleanup_runs: Sequence[Mapping[str, object]]
scheduled_tasks: Sequence[Mapping[str, object]]
update_events: Sequence[Mapping[str, object]]
service_metrics: dict[str, float]
table_counts: dict[str, int]
table_offsets: dict[str, int]
table_limit: int
notice_html: SafeHtml | None
def _admin_page_context(
store: DdnsStore,
table_offsets: dict[str, int],
table_limit: int,
notice_html: SafeHtml | None,
) -> AdminPageContext:
return AdminPageContext(
accounts=store.list_accounts(limit=table_limit, offset=table_offsets.get("accounts", 0)),
cleanup_runs=store.list_cleanup_runs(limit=table_limit, offset=table_offsets.get("cleanup_runs", 0)),
scheduled_tasks=store.list_scheduled_tasks(),
update_events=store.list_update_events(table_limit, offset=table_offsets.get("update_events", 0)),
service_metrics=store.get_service_metrics(),
table_counts={
"accounts": store.count_accounts(),
"cleanup_runs": store.count_cleanup_runs(),
"update_events": store.count_update_events(),
},
table_offsets=table_offsets,
table_limit=table_limit,
notice_html=notice_html,
)
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
return trusted(f"""
<section class="section success-section">
@@ -33,116 +80,193 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: Sequence[Mapping[str, object]],
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
update_events: Sequence[Mapping[str, object]],
service_metrics: dict[str, float],
table_counts: dict[str, int],
table_offsets: dict[str, int],
table_limit: int,
notice_html: SafeHtml | None,
context: AdminPageContext,
) -> 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)
rows = _account_rows(context.accounts, csrf=csrf, admin_path=admin_path)
event_rows = _event_rows(context.update_events)
accounts_count = escape(context.table_counts.get("accounts", len(context.accounts)))
event_count = escape(context.table_counts.get("update_events", len(context.update_events)))
metrics_html = _metrics_panel(context.service_metrics)
account_pager = _server_page_links(
admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0)
admin_path,
"accounts_offset",
context.table_offsets.get("accounts", 0),
context.table_limit,
context.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),
context.table_offsets.get("update_events", 0),
context.table_limit,
context.table_counts.get("update_events", 0),
)
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(table_counts, service_metrics)
cleanup_panel = _cleanup_panel(settings, context.cleanup_runs, context.scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(context.table_counts, context.service_metrics)
title_section = _admin_title_section(totals_html)
status_section = _admin_status_section(cleanup_panel, metrics_html)
hostnames_section = _admin_hostnames_section(context, rows, accounts_count, account_pager)
events_section = _admin_events_section(context, event_rows, event_count, event_pager)
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator")}
<section class="admin-console">
<div class="container">
<div class="admin-page-title">
<div>
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
</div>
{totals_html}
</div>
</div>
</section>
{notice_html or ""}
<section class="admin-block">
<div class="container">
{cleanup_panel}
{metrics_html}
</div>
</section>
<section class="admin-block">
<div class="container">
<div class="admin-section-heading">
<div>
<h2>Hostnames</h2>
<p class="section-copy mb-0">{escape(len(accounts))} of {accounts_count} shown.</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="admin-block">
<div class="container">
<div class="admin-section-heading">
<div>
<h2>Update events</h2>
<p class="section-copy mb-0">{escape(len(update_events))} of {event_count} shown. Unchanged checks 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>
{title_section}
{context.notice_html or ""}
{status_section}
{hostnames_section}
{events_section}
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
PageOptions(
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
extra_scripts=trusted('<script src="/admin-tables.js?v=3" defer></script>'),
),
)
def _admin_title_section(totals_html: SafeHtml) -> SafeHtml:
return trusted(f"""
<section class="admin-console">
<div class="container">
<div class="admin-page-title">
<div>
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
</div>
{totals_html}
</div>
</div>
</section>
""")
def _admin_status_section(cleanup_panel: SafeHtml, metrics_html: SafeHtml) -> SafeHtml:
return trusted(f"""
<section class="admin-block">
<div class="container">
{cleanup_panel}
{metrics_html}
</div>
</section>
""")
def _admin_hostnames_section(
context: AdminPageContext,
rows: SafeHtml,
accounts_count: str,
account_pager: SafeHtml,
) -> SafeHtml:
return trusted(f"""
<section class="admin-block">
<div class="container">
<div class="admin-section-heading">
<div>
<h2>Hostnames</h2>
<p class="section-copy mb-0">{escape(len(context.accounts))} of {accounts_count} shown.</p>
</div>
</div>
{_admin_table_toolbar("hostnames", "Filter visible hostnames")}
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
<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>
""")
def _admin_events_section(
context: AdminPageContext,
event_rows: SafeHtml,
event_count: str,
event_pager: SafeHtml,
) -> SafeHtml:
return trusted(f"""
<section class="admin-block">
<div class="container">
<div class="admin-section-heading">
<div>
<h2>Update events</h2>
<p class="section-copy mb-0">
{escape(len(context.update_events))} of {event_count} shown. Unchanged checks are grouped.
</p>
</div>
</div>
{_admin_table_toolbar("update-events", "Filter visible update events")}
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
<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>
""")
def _account_rows(accounts: Sequence[Mapping[str, object]], *, csrf: str, admin_path: str) -> SafeHtml:
return trusted("\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">
<div class="empty-state">
<div class="empty-state-title">No hostnames yet.</div>
<div class="empty-state-description">
Generated hostnames appear here after users create or register them.
</div>
</div>
</td>
</tr>
""")
def _event_rows(events: Sequence[Mapping[str, object]]) -> SafeHtml:
return trusted("\n".join(_event_row(event) for event in events) or """
<tr class="empty-row">
<td colspan="7" class="empty">
<div class="empty-state">
<div class="empty-state-title">No update events yet.</div>
<div class="empty-state-description">
Router update activity and DNS publish results will appear here.
</div>
</div>
</td>
</tr>
""")
def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> SafeHtml:
items = [
("Hostnames", table_counts.get("accounts", 0)),
@@ -161,7 +285,7 @@ def _admin_notice(kind: str, message: str) -> SafeHtml:
alert_class = "alert-success" if kind == "success" else "alert-info"
return trusted(f"""
<div class="container">
<div class="alert {alert_class} admin-notice" role="status">{escape(message)}</div>
<div class="alert {alert_class} ui-alert admin-notice" role="status" data-slot="alert">{escape(message)}</div>
</div>
""")
@@ -180,8 +304,10 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" 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>
<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>
""")
@@ -193,37 +319,31 @@ def _cleanup_panel(
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 {},
summary = _cleanup_summary(
cleanup_runs=cleanup_runs,
scheduled_tasks=scheduled_tasks,
fallback_interval_seconds=settings.cleanup_interval_seconds,
)
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
trusted(f'<p class="small text-danger mb-0">Last error: {escape(summary.last_error)}</p>')
if summary.last_error
else trusted("")
)
last_run_title = attr(str(last_run or "Never"))
next_run_title = attr(str(next_run or "Pending"))
last_run_display = escape(_format_timestamp(last_run, "Never"))
next_run_display = escape(_format_timestamp(next_run, "Pending"))
last_run_title = attr(str(summary.last_run or "Never"))
next_run_title = attr(str(summary.next_run or "Pending"))
last_run_display = escape(_format_timestamp(summary.last_run, "Never"))
next_run_display = escape(_format_timestamp(summary.next_run, "Pending"))
return trusted(f"""
<div class="admin-cleanup-bar">
<div class="admin-cleanup-title">
<h2>Cleanup</h2>
<span>Every {escape(_format_interval(interval))}</span>
<span>Every {escape(_format_interval(summary.interval_seconds))}</span>
</div>
<dl class="admin-cleanup-times">
<div><dt>Last run</dt><dd title="{last_run_title}">{last_run_display}</dd></div>
<div><dt>Next run</dt><dd title="{next_run_title}">{next_run_display}</dd></div>
<div><dt>Status</dt><dd>{escape(str(status))}</dd></div>
<div><dt>Removed</dt><dd>{escape(accounts_removed)}</dd></div>
<div><dt>Status</dt><dd>{escape(str(summary.status))}</dd></div>
<div><dt>Removed</dt><dd>{escape(summary.accounts_removed)}</dd></div>
</dl>
{error_html}
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
@@ -234,6 +354,39 @@ def _cleanup_panel(
""")
def _cleanup_task(scheduled_tasks: Sequence[Mapping[str, object]]) -> Mapping[str, object]:
return next(
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
scheduled_tasks[0] if scheduled_tasks else {},
)
def _cleanup_summary(
*,
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
fallback_interval_seconds: int,
) -> _CleanupSummary:
task = _cleanup_task(scheduled_tasks)
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
return _CleanupSummary(
last_run=_cleanup_last_run(task, last_cleanup),
next_run=task.get("next_run_at"),
status=task.get("last_status") or "pending",
last_error=task.get("last_error") or "",
interval_seconds=task.get("interval_seconds") or fallback_interval_seconds,
accounts_removed=_cleanup_accounts_removed(last_cleanup),
)
def _cleanup_last_run(task: Mapping[str, object], last_cleanup: Mapping[str, object]) -> object:
return task.get("last_finished_at") or last_cleanup.get("created_at")
def _cleanup_accounts_removed(last_cleanup: Mapping[str, object]) -> object:
return last_cleanup.get("unused_accounts_deleted", 0)
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
@@ -247,7 +400,10 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
("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))}",
(
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/"
f"{_metric_int(metrics.get('cleanup_errors_total', 0))}"
),
),
]
cards = "\n".join(f"""
+51 -18
View File
@@ -1,12 +1,49 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from .components import _event_label
from .html_utils import attr, escape
@dataclass(frozen=True, slots=True)
class _AdminEventRow:
created_raw: str
hostname_raw: str
detail_raw: str
created: str
hostname: str
status: str
ipv4: str
ipv6: str
count: str
detail: str
def _admin_event_row_view(event: Mapping[str, object]) -> _AdminEventRow:
created_raw = _event_text(event, "created_at")
hostname_raw = _event_text(event, "hostname")
detail_raw = _event_text(event, "detail")
return _AdminEventRow(
created_raw=created_raw,
hostname_raw=hostname_raw,
detail_raw=detail_raw,
created=escape(_short_time(created_raw)),
hostname=escape(hostname_raw),
status=escape(_event_label(event.get("status"))),
ipv4=escape(_event_text(event, "ipv4")),
ipv6=escape(_event_text(event, "ipv6")),
count=escape(event.get("count") or 1),
detail=escape(detail_raw),
)
def _event_text(event: Mapping[str, object], key: str) -> str:
return str(event.get(key) or "-")
def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
raw_hostname = str(account["hostname"])
hostname = escape(raw_hostname)
@@ -30,15 +67,18 @@ def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="admin-action-group">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname_attr}">
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill" title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill" title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill"
title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill"
title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
</form>
</td>
<td class="cell-delete">
<form method="post" action="{safe_admin_path}/hostnames/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname_attr}">
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname" aria-label="Delete {hostname_attr}">Delete</button>
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname"
aria-label="Delete {hostname_attr}">Delete</button>
</form>
</td>
</tr>
@@ -53,7 +93,8 @@ def _admin_table_toolbar(table_id: str, search_label: str) -> str:
<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">
<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>
@@ -79,21 +120,13 @@ def _admin_table_pagination() -> str:
def _event_row(event: Mapping[str, object]) -> str:
created_raw = str(event.get("created_at") or "-")
hostname_raw = str(event.get("hostname") or "-")
detail_raw = str(event.get("detail") or "-")
created = escape(_short_time(created_raw))
hostname = escape(hostname_raw)
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(detail_raw)
row = _admin_event_row_view(event)
return (
f'<tr><td class="cell-time" title="{attr(created_raw)}">{created}</td>'
f'<td class="cell-hostname" title="{attr(hostname_raw)}">{hostname}</td>'
f'<td class="cell-status">{status}</td><td class="cell-ip">{ipv4}</td><td class="cell-ip">{ipv6}</td>'
f'<td class="cell-count">{count}</td><td class="cell-detail" title="{attr(detail_raw)}">{detail}</td></tr>'
f'<tr><td class="cell-time" title="{attr(row.created_raw)}">{row.created}</td>'
f'<td class="cell-hostname" title="{attr(row.hostname_raw)}">{row.hostname}</td>'
f'<td class="cell-status">{row.status}</td><td class="cell-ip">{row.ipv4}</td>'
f'<td class="cell-ip">{row.ipv6}</td><td class="cell-count">{row.count}</td>'
f'<td class="cell-detail" title="{attr(row.detail_raw)}">{row.detail}</td></tr>'
)
+402 -209
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import (
_code_window,
_copy_button,
@@ -14,229 +15,383 @@ from .components import (
_secret_notice,
)
from .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav
from .layout import PageOptions, _page, _top_nav
if TYPE_CHECKING:
from ..settings import DdnsSettings
def _render_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: Mapping[str, object],
csrf_token: str,
message: 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>
"""
@dataclass(frozen=True)
class ManagementPageContext:
management_slug: str
account: Mapping[str, object]
csrf_token: str
message: str | None = None
request_ip: str | None = None
@property
def account_for_url(self) -> dict[str, str]:
return {
"hostname": str(self.account["hostname"]),
"update_slug": str(self.account["update_slug"]),
"management_slug": self.management_slug,
}
@dataclass(frozen=True, slots=True)
class _ManagementPageView:
update_url: str
hostname: str
ipv4: str
ipv6: str
updated: str
fritzbox_fields: str
cname_record: str
manual_commands: str
def _management_page_view(update_url: str, context: ManagementPageContext) -> _ManagementPageView:
hostname = escape(context.account["hostname"])
return _ManagementPageView(
update_url=update_url,
hostname=hostname,
ipv4=escape(context.account.get("ipv4") or "-"),
ipv6=escape(context.account.get("ipv6") or "-"),
updated=escape(context.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, context.request_ip),
)
message_html = _management_message(message)
@dataclass(frozen=True)
class PendingManagementPageContext:
settings: DdnsSettings
management_slug: str
account: Mapping[str, object]
csrf_token: str
message: str | None = None
request_ip: str | None = None
@property
def account_for_url(self) -> dict[str, str]:
return {
"hostname": str(self.account["hostname"]),
"update_slug": str(self.account["update_slug"]),
"management_slug": self.management_slug,
}
def _render_management_page(service: DdnsService, context: ManagementPageContext) -> str:
update_url = service.fritz_update_url(context.account_for_url)
view = _management_page_view(update_url, context)
message_html = _management_message(context.message)
dashboard_password_panel = _dashboard_password_panel(
management_slug, csrf_token, bool(account.get("dashboard_password_hash"))
context.management_slug,
context.csrf_token,
bool(context.account.get("dashboard_password_hash")),
)
dashboard_shell = _management_dashboard_shell(context, view, message_html)
history_section = _management_history_section(_management_history_rows(service, context))
delete_section = _management_delete_section(context)
return _page(
"KarlDNS management",
trusted(f"""
<main id="main" class="dashboard-page">
{_top_nav()}
<section class="dashboard-shell">
<div class="container">
<div class="dashboard-header">
<div>
<p class="eyebrow">Dashboard</p>
<h1>{hostname}</h1>
<p class="lead">Private management link. Keep it safe.</p>
</div>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
{message_html}
<div class="dashboard-grid">
<section class="dashboard-card dashboard-card-main" aria-labelledby="router-settings-title">
<div class="dashboard-card-header">
<div>
<p class="eyebrow">Router settings</p>
<h2 id="router-settings-title">Update endpoint</h2>
</div>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
</form>
</div>
<p class="section-copy">Paste the Update-URL into your router. Use the hostname as Domainnamen, or point your own subdomain at it with a CNAME.</p>
{_secret_notice()}
<div class="dashboard-copy-stack">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
</div>
<div class="dashboard-actions">
{_copy_button("Router fields", fritzbox_fields)}
{_copy_button("Copy CNAME", cname_record, style="outline-secondary")}
</div>
</section>
<section class="dashboard-card dashboard-status-card" aria-labelledby="status-title">
<p class="eyebrow">Current address</p>
<h2 id="status-title">Status</h2>
<dl class="dashboard-status-list">
<div><dt>IPv4</dt><dd>{ipv4}</dd></div>
<div><dt>IPv6</dt><dd>{ipv6}</dd></div>
<div><dt>Updated</dt><dd>{updated}</dd></div>
</dl>
</section>
</div>
<section class="dashboard-card dashboard-card-compact" aria-labelledby="manual-updates-title">
<div class="dashboard-card-header">
<div>
<p class="eyebrow">Manual updates</p>
<h2 id="manual-updates-title">curl examples</h2>
</div>
</div>
<p class="section-copy">FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly or omit it and let KarlDNS use the request IP.</p>
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code")}
</section>
</div>
</section>
{dashboard_shell}
{dashboard_password_panel}
<section class="dashboard-section">
<div class="container">
<div class="dashboard-section-header">
<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 dashboard-table-shell">
<table class="table table-hover dashboard-history-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="dashboard-section dashboard-danger-section">
<div class="container">
<div class="dashboard-danger-card">
<div>
<p class="eyebrow">Delete</p>
<h2>Delete hostname</h2>
<p class="section-copy">Remove this hostname and its DNS records. The update URL will stop working.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/delete">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
</div>
</div>
</section>
{history_section}
{delete_section}
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
PageOptions(extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">')),
)
def _render_pending_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
) -> str:
raw_hostname = str(account["hostname"])
account_for_url = {
"hostname": raw_hostname,
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
dashboard_url = service.dashboard_url({"management_slug": management_slug})
def _management_history_rows(service: DdnsService, context: ManagementPageContext) -> SafeHtml:
events = service.store.list_update_events_for_hostname(str(context.account["hostname"]), 10)
rows = "\n".join(_user_event_row(event) for event in events)
return trusted(rows or """
<tr class="empty-row">
<td colspan="6" class="empty">
<div class="empty-state">
<div class="empty-state-title">No router updates yet.</div>
<div class="empty-state-description">
This table fills after the router or a script sends its first address update.
</div>
</div>
</td>
</tr>
""")
def _management_dashboard_shell(
context: ManagementPageContext,
view: _ManagementPageView,
message_html: SafeHtml,
) -> SafeHtml:
return trusted(f"""
<section class="dashboard-shell">
<div class="container">
{_management_dashboard_header(view)}
{message_html}
<div class="dashboard-grid">
{_router_settings_card(context, view)}
{_status_card(view)}
</div>
{_manual_updates_card(view)}
</div>
</section>
""")
def _management_dashboard_header(view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
<div class="dashboard-header">
<div>
<p class="eyebrow">Dashboard</p>
<h1>{view.hostname}</h1>
<p class="lead">Private management link. Keep it safe.</p>
</div>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
""")
def _router_settings_card(context: ManagementPageContext, view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
<section class="dashboard-card dashboard-card-main" data-slot="card" aria-labelledby="router-settings-title">
<div class="dashboard-card-header" data-slot="card-header">
<div>
<p class="eyebrow">Router settings</p>
<h2 id="router-settings-title">Update endpoint</h2>
</div>
<form method="post" action="/m/{attr(context.management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(context.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 data-slot="card-content">
<p class="section-copy">
Paste the Update-URL into your router. Use the hostname as Domainnamen,
or point your own subdomain at it with a CNAME.
</p>
{_secret_notice()}
<div class="dashboard-copy-stack">
{_copy_row("Update-URL:", view.update_url, "Secret")}
{_copy_row("Domainnamen:", view.hostname, "Public")}
{_copy_row("CNAME target:", view.hostname, "Public")}
</div>
</div>
<div class="dashboard-actions" data-slot="card-footer">
{_copy_button("Router fields", view.fritzbox_fields)}
{_copy_button("Copy CNAME", view.cname_record, style="outline-secondary")}
</div>
</section>
""")
def _status_card(view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
<section class="dashboard-card dashboard-status-card" data-slot="card" aria-labelledby="status-title">
<div data-slot="card-header">
<p class="eyebrow">Current address</p>
<h2 id="status-title">Status</h2>
</div>
<div data-slot="card-content">
<dl class="dashboard-status-list">
<div><dt>IPv4</dt><dd>{view.ipv4}</dd></div>
<div><dt>IPv6</dt><dd>{view.ipv6}</dd></div>
<div><dt>Updated</dt><dd>{view.updated}</dd></div>
</dl>
</div>
</section>
""")
def _manual_updates_card(view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
<section class="dashboard-card dashboard-card-compact" data-slot="card" aria-labelledby="manual-updates-title">
<div class="dashboard-card-header" data-slot="card-header">
<div>
<p class="eyebrow">Manual updates</p>
<h2 id="manual-updates-title">curl examples</h2>
</div>
</div>
<div data-slot="card-content">
<p class="section-copy">
FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly
or omit it and let KarlDNS use the request IP.
</p>
{_code_window(view.manual_commands, copy_label="manual update commands", class_name="manual-code")}
</div>
</section>
""")
def _management_history_section(history_rows: SafeHtml) -> SafeHtml:
return trusted(f"""
<section class="dashboard-section">
<div class="container">
<div class="dashboard-section-header">
<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 dashboard-table-shell">
<table class="table table-hover dashboard-history-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>
""")
def _management_delete_section(context: ManagementPageContext) -> SafeHtml:
return trusted(f"""
<section class="dashboard-section dashboard-danger-section">
<div class="container">
<div class="dashboard-danger-card" data-slot="card">
<div>
<p class="eyebrow">Delete</p>
<h2>Delete hostname</h2>
<p class="section-copy">
Remove this hostname and its DNS records. The update URL will stop working.
</p>
</div>
<form method="post" action="/m/{attr(context.management_slug)}/delete">
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
</div>
</div>
</section>
""")
def _render_pending_management_page(service: DdnsService, context: PendingManagementPageContext) -> str:
raw_hostname = str(context.account["hostname"])
update_url = service.fritz_update_url(context.account_for_url)
dashboard_url = service.dashboard_url({"management_slug": context.management_slug})
hostname = escape(raw_hostname)
message_html = _management_message(message)
cleanup_hours = settings.cleanup_pending_account_hours
message_html = _management_message(context.message)
cleanup_hours = context.settings.cleanup_pending_account_hours
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
manual_commands = _manual_update_commands(update_url, request_ip)
pending_lead = (
"Save these values, then register the hostname. Pending setups are cleaned up after " f"{cleanup_text}."
)
manual_commands = _manual_update_commands(update_url, context.request_ip)
hero = _pending_hero(hostname, pending_lead)
values_section = _pending_values_section(context, update_url, raw_hostname, dashboard_url)
router_section = _pending_router_section(hostname, update_url, manual_commands)
return _page(
"KarlDNS setup",
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">Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.</p>
</div>
</section>
{hero}
{message_html}
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">Save</p>
<h2>Your values.</h2>
<p class="section-copy">Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.</p>
<p class="section-copy"><strong>Public:</strong> Domainnamen. <strong>Secret:</strong> Update-URL and private dashboard.</p>
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
{_copy_row("Domainnamen:", raw_hostname, "Public")}
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
</div>
<form method="post" action="/m/{attr(management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
</div>
</section>
<section class="section section-compact">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">FRITZ!Box</p>
<h2>Router setup.</h2>
<p class="section-copy">Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field. Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>. No KarlDNS username or password is needed; if your router requires them, enter any placeholder.</p>
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
</div>
<div>
<h2>Manual updates.</h2>
<p class="section-copy">Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP. Visible examples use your public IP when KarlDNS or your browser can detect it.</p>
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
</div>
<div>
<h2>Use your own DNS name.</h2>
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. This is the same value as Domainnamen.</p>
</div>
</div>
</section>
{values_section}
{router_section}
</main>
"""),
)
def _pending_hero(hostname: str, pending_lead: str) -> SafeHtml:
return trusted(f"""
<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">{escape(pending_lead)}</p>
</div>
</section>
""")
def _pending_values_section(
context: PendingManagementPageContext,
update_url: str,
raw_hostname: str,
dashboard_url: str,
) -> SafeHtml:
return trusted(f"""
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">Save</p>
<h2>Your values.</h2>
<p class="section-copy">
Save the update URL and dashboard link before you register. After registration,
the dashboard hides secrets by default.
</p>
<p class="section-copy">
<strong>Public:</strong> Domainnamen.
<strong>Secret:</strong> Update-URL and private dashboard.
</p>
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
{_copy_row("Domainnamen:", raw_hostname, "Public")}
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
</div>
<form method="post" action="/m/{attr(context.management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
</div>
</section>
""")
def _pending_router_section(hostname: str, update_url: str, manual_commands: str) -> SafeHtml:
return trusted(f"""
<section class="section section-compact">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">FRITZ!Box</p>
<h2>Router setup.</h2>
<p class="section-copy">
Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field.
Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>.
No KarlDNS username or password is needed; if your router requires them,
enter any placeholder.
</p>
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
</div>
<div>
<h2>Manual updates.</h2>
<p class="section-copy">
Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP.
Visible examples use your public IP when KarlDNS or your browser can detect it.
</p>
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
</div>
<div>
<h2>Use your own DNS name.</h2>
<p class="section-copy">
Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>.
This is the same value as Domainnamen.
</p>
</div>
</div>
</section>
""")
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="alert alert-info ui-alert mb-0 rounded-0 border-start-0 border-end-0"
role="status" data-slot="alert">
<div class="container">{escape(message)}</div>
</div>
""")
@@ -248,10 +403,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
remove_form = ""
if password_enabled:
remove_form = f"""
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password/remove">
<form class="dashboard-card dashboard-form-card" data-slot="card" 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>
<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>
"""
@@ -260,7 +417,8 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
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>
<input class="form-control" id="dashboard-current-password"
name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
return trusted(f"""
@@ -270,19 +428,26 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
<div>
<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>
<p class="section-copy">
Status: {escape(password_status)}. This password protects only this dashboard.
Router updates still work.
</p>
</div>
<div class="d-grid gap-3">
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password">
<form class="dashboard-card dashboard-form-card" data-slot="card" 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>
<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>
<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>
@@ -305,7 +470,9 @@ def _render_management_unlock_page(
hostname = escape(account["hostname"])
message_html = ""
if message:
message_html = f'<div class="alert alert-warning" role="alert">{escape(message)}</div>'
message_html = (
f'<div class="alert alert-warning ui-alert" role="alert" data-slot="alert">{escape(message)}</div>'
)
return _page(
"Unlock KarlDNS management",
trusted(f"""
@@ -321,11 +488,13 @@ def _render_management_unlock_page(
<section class="section">
<div class="container narrow">
{message_html}
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/unlock">
<form class="card card-body gap-3" data-slot="card" 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>
<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">
@@ -341,15 +510,39 @@ def _render_management_unlock_page(
def _user_event_row(event: Mapping[str, object]) -> str:
created = escape(event.get("created_at") or "-")
row = _user_event_row_view(event)
return (
f'<tr><td>{row.created}</td><td><span class="status-badge {attr(row.badge_class)}">{row.status}</span></td>'
f"<td>{row.ipv4}</td><td>{row.ipv6}</td><td>{row.count}</td><td>{row.detail}</td></tr>"
)
@dataclass(frozen=True, slots=True)
class _UserEventRow:
created: str
status: str
badge_class: str
ipv4: str
ipv6: str
count: str
detail: str
def _user_event_row_view(event: Mapping[str, object]) -> _UserEventRow:
raw_status = str(event.get("status") or "")
status = escape(_event_label(raw_status))
badge_class = attr(_event_badge_class(raw_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><span class="status-badge {badge_class}">{status}</span></td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>'
return _UserEventRow(
created=escape(_event_text(event, "created_at")),
status=escape(_event_label(raw_status)),
badge_class=_event_badge_class(raw_status),
ipv4=escape(_event_text(event, "ipv4")),
ipv6=escape(_event_text(event, "ipv6")),
count=escape(event.get("count") or 1),
detail=escape(_event_text(event, "detail")),
)
def _event_text(event: Mapping[str, object], key: str) -> str:
return str(event.get(key) or "-")
def _event_badge_class(status: str) -> str:
+223 -145
View File
@@ -1,12 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
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 .html_utils import trusted
from .layout import PageOptions, _page, _top_nav
from .sections.public_home import _manual_updates_section, _public_home_hero, _setup_flow_section
from .templates_public_sections import _linux_installer_section
@dataclass(frozen=True, slots=True)
class _ApiDocsExamples:
create: str
register: str
update: str
status: str
rotate: str
schema: str
def _render_public_home(
settings: DdnsSettings,
message: str | None,
@@ -14,8 +27,7 @@ def _render_public_home(
) -> 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>")
demo_update_url = 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}"
@@ -28,81 +40,55 @@ def _render_public_home(
]
)
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."
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>
{_public_home_hero(suffix=hostname_suffix, demo_update_url=demo_update_url)}
{message_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="manual-code text-start mt-4")}
</div>
</section>
{_setup_flow_section(create_demo=create_demo, create_demo_command=create_demo_command)}
{_manual_updates_section(manual_demo=manual_demo)}
{_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",
],
},
PageOptions(
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."
description = (
"Privacy and legal information for KarlDNS, including stored hostnames, update links, "
"IP history, and cleanup behavior."
)
return _page(
"Legal and Privacy",
trusted(f"""
@@ -119,113 +105,205 @@ def _render_privacy_page(settings: DdnsSettings) -> str:
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores hostnames, protected dashboard tokens, private update links, recent IP history, timestamps, and service events.</p>
<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>
<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>
<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>
<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}/",
PageOptions(
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/<karl_dashboard_secret>?myip={example_ipv4}'"
examples = _api_docs_examples(base_url, example_ipv4)
description = (
"Small KarlDNS API guide for creating, activating, updating, reading, and deleting "
"generated Dynamic DNS hostnames."
)
update_example = f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'"
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'"
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url"
schema_example = f"curl -sS {base_url}/openapi.json"
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">Create a hostname, register it, update it, and read its status.</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 private setup values. The response includes <code>registration_url</code>.</li>
<li><strong>GET or POST /api/v1/router/register/&lt;karl_secret&gt;.</strong> Register the hostname after secrets are saved. Publishes an initial DNS record from a public request IP or explicit <code>myip</code>/<code>myipv6</code> value. If no public IP is visible, publishes a temporary <code>192.0.2.1</code> A record until the first router update.</li>
<li><strong>GET or POST /api/v1/router/update/&lt;karl_secret&gt;.</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/&lt;karl_secret&gt;.</strong> Read hostname status and recent history.</li>
<li><strong>POST /api/v1/hostnames/&lt;karl_secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li>
<li><strong>DELETE /api/v1/hostnames/&lt;karl_secret&gt;.</strong> Delete hostname and DNS records.</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 available for the public hostname and router endpoints.</p>
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
</div>
</div>
</section>
{_api_docs_hero()}
{_api_docs_content(examples)}
</main>
"""),
description=description,
canonical_url=f"{base_url}/docs",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS API documentation",
PageOptions(
description=description,
canonical_url=f"{base_url}/docs",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS API documentation",
),
)
def _api_docs_examples(base_url: str, example_ipv4: str) -> _ApiDocsExamples:
return _ApiDocsExamples(
create=f"curl -sS -X POST {base_url}/api/v1/hostnames",
register=f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'",
update=f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'",
status=f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'",
rotate=f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url",
schema=f"curl -sS {base_url}/openapi.json",
)
def _api_docs_hero() -> str:
return """
<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">Create a hostname, register it, update it, and read its status.</p>
</div>
</section>
"""
def _api_docs_content(examples: _ApiDocsExamples) -> str:
return f"""
<section class="section section-compact">
<div class="container narrow d-grid gap-4">
{_api_endpoints_overview()}
{_api_example_block("Create", examples.create, "create command")}
{_api_example_block("Register", examples.register, "register command")}
{_api_example_block("Update", examples.update, "update command")}
{_api_status_block(examples.status)}
{_api_rotate_block(examples.rotate)}
{_api_schema_block(examples.schema)}
</div>
</section>
"""
def _api_endpoints_overview() -> str:
return """
<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 private setup values. The response includes
<code>registration_url</code>.
</li>
<li>
<strong>GET or POST /api/v1/router/register/&lt;karl_secret&gt;.</strong>
Register the hostname after secrets are saved. Publishes an initial DNS record
from a public request IP or explicit <code>myip</code>/<code>myipv6</code> value.
If no public IP is visible, publishes a temporary <code>192.0.2.1</code> A record
until the first router update.
</li>
<li>
<strong>GET or POST /api/v1/router/update/&lt;karl_secret&gt;.</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/&lt;karl_secret&gt;.</strong>
Read hostname status and recent history.
</li>
<li>
<strong>POST /api/v1/hostnames/&lt;karl_secret&gt;/rotate-update-url.</strong>
Replace the private router update URL.
</li>
<li>
<strong>DELETE /api/v1/hostnames/&lt;karl_secret&gt;.</strong>
Delete hostname and DNS records.
</li>
</ol>
</div>
"""
def _api_example_block(title: str, command: str, copy_label: str) -> str:
return f"""
<div>
<h2>{title}</h2>
{_code_window(command, copy_label=copy_label, class_name="mt-3")}
</div>
"""
def _api_status_block(command: str) -> str:
return f"""
<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(command, copy_label="status command", class_name="mt-3")}
</div>
"""
def _api_rotate_block(command: str) -> str:
return f"""
<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(command, copy_label="rotate command", class_name="mt-3")}
</div>
"""
def _api_schema_block(command: str) -> str:
return f"""
<div>
<h2>Schema</h2>
<p class="section-copy">
Machine-readable OpenAPI is available for the public hostname and router endpoints.
</p>
{_code_window(command, copy_label="schema command", class_name="mt-3")}
</div>
"""
+5 -1
View File
@@ -14,7 +14,11 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
<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>
<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">