Build KarlDNS self-hosted DynDNS service

This commit is contained in:
StasonJatham committed 2026-05-20 23:55:27 +02:00
1 parent 45bc38d950
commit ca92dc211a
65 files changed
+9200 -3570

No files matched your search

View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import urllib.parse
from fastapi import HTTPException, Request
from ..ddns_models import DdnsSettings
MAX_FORM_FIELDS = 100
def parse_form(body: bytes) -> dict[str, list[str]]:
try:
decoded = body.decode("utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="form body must be valid UTF-8") from exc
try:
return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields") from exc
async def request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]:
body = await request.body()
if len(body) > settings.max_request_body_bytes:
raise HTTPException(status_code=413, detail="request body too large")
return parse_form(body)
def first_form_value(form: dict[str, list[str]], key: str) -> str:
return form.get(key, [""])[0].strip()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import html
class SafeHtml(str):
"""HTML fragment that was built by trusted server-side template helpers."""
def escape(value: object) -> str:
return html.escape(str(value), quote=True)
def attr(value: object) -> str:
return escape(value)
def trusted(fragment: str) -> SafeHtml:
return SafeHtml(fragment)
def join_html(fragments: list[str]) -> SafeHtml:
return SafeHtml("".join(fragments))
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..ddns_models import DdnsSettings
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..ddns_store import DdnsStore
class RequestBodyTooLargeError(Exception):
pass
class RequestBodyLimitMiddleware:
def __init__(self, app: ASGIApp, max_body_bytes: int) -> None:
self.app = app
self.max_body_bytes = max_body_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
received = 0
async def limited_receive() -> Message:
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > self.max_body_bytes:
raise RequestBodyTooLargeError
return message
try:
await self.app(scope, limited_receive, send)
except RequestBodyTooLargeError:
response = PlainTextResponse("request body too large", status_code=413)
await response(scope, receive, send)
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
try:
content_length = int(request.headers.get("content-length", "0") or "0")
except ValueError:
content_length = 0
if content_length > settings.max_request_body_bytes:
return PlainTextResponse("request body too large", status_code=413)
if is_rate_limited_path(request.url.path) or is_admin_path(request.url.path, settings):
limit = (
settings.admin_rate_limit_per_minute
if is_admin_path(request.url.path, settings)
else settings.rate_limit_per_minute
)
key = f"{client_ip(request, settings)}:{rate_limit_bucket(request.url.path)}"
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed:
return PlainTextResponse("rate limit exceeded", status_code=429)
response = await call_next(request)
if is_secret_response(request):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
response.headers["Content-Security-Policy"] = content_security_policy(request.url.path)
if settings.public_base_url.startswith("https://"):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
admin_path = settings.admin_path.rstrip("/")
admin_records = f"{admin_path}/records"
admin_events = f"{admin_path}/events"
legacy_admin_api = settings.admin_path == "/admin" and path in {"/records", "/events"}
return path == admin_path or path.startswith(f"{admin_path}/") or path in {admin_records, admin_events} or legacy_admin_api
def is_secret_response(request: Request) -> bool:
path = request.url.path
settings = request.app.state.settings
if is_admin_path(path, settings) or path.startswith(("/m/", "/d/", "/u/")):
return True
if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
return request.method in {"POST", "DELETE"}
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
def content_security_policy(path: str) -> str:
if path in {"/docs", "/redoc", "/openapi.json"}:
return (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https://fastapi.tiangolo.com; "
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
return (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' https://cdn.jsdelivr.net; "
"img-src 'self' data:; "
"font-src 'self' https://cdn.jsdelivr.net; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
+780
View File
@@ -0,0 +1,780 @@
from __future__ import annotations
APP_CSS = """
:root {
color-scheme: light;
--bs-primary: #000000;
--bs-primary-rgb: 0, 0, 0;
--bs-link-color: #000000;
--bs-link-hover-color: #3f3f46;
--bs-border-radius: 12px;
--bs-border-radius-sm: 10px;
--bs-border-radius-lg: 12px;
--rp-bg: #ffffff;
--rp-canvas: #ffffff;
--rp-surface: #ffffff;
--rp-surface-alt: #fafafa;
--rp-field: #fafafa;
--rp-ink: #000000;
--rp-muted: #737373;
--rp-charcoal: #52525b;
--rp-mute: #a1a1aa;
--rp-line: #e5e5e5;
--rp-soft-line: #eeeeee;
--rp-danger: #b42318;
--rp-focus-ring: rgba(0, 0, 0, 0.18);
--rp-terminal: #ffffff;
--rp-terminal-ink: #000000;
--rp-success: #1d6f42;
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-body-bg: #0f0f0f;
--bs-body-color: #f5f5f5;
--bs-secondary-color: #a1a1aa;
--bs-border-color: rgba(255, 255, 255, 0.16);
--bs-tertiary-bg: #171717;
--rp-bg: #0f0f0f;
--rp-canvas: #0f0f0f;
--rp-surface: #171717;
--rp-surface-alt: #1f1f1f;
--rp-field: #1f1f1f;
--rp-ink: #f5f5f5;
--rp-muted: #a3a3a3;
--rp-charcoal: #d4d4d4;
--rp-mute: #737373;
--rp-line: rgba(255, 255, 255, 0.16);
--rp-soft-line: rgba(255, 255, 255, 0.1);
--rp-danger: #ff8a80;
--rp-focus-ring: rgba(255, 255, 255, 0.24);
--rp-terminal: #171717;
--rp-terminal-ink: #f5f5f5;
--rp-success: #62c383;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
overflow-x: clip;
}
body {
min-height: 100vh;
overflow-x: clip;
background: var(--rp-bg);
color: var(--rp-ink);
font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.justify-items-center {
justify-items: center;
}
.container {
max-width: 1040px;
}
.container.narrow {
max-width: 760px;
}
.skip-link {
position: fixed;
left: 16px;
top: 10px;
z-index: 40;
transform: translateY(-140%);
padding: 8px 20px;
border-radius: 999px;
background: var(--rp-ink);
color: var(--rp-bg);
text-decoration: none;
font-size: 14px;
transition: transform 0.16s ease;
}
.skip-link:focus {
transform: translateY(0);
}
.site-nav {
min-height: 63px;
padding: 14px 0;
border-bottom: 0;
background: var(--rp-canvas);
}
.site-nav .container {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
color: var(--rp-ink);
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
white-space: nowrap;
}
.brand:hover {
color: var(--rp-ink);
}
.brand img {
width: 26px;
height: 26px;
display: block;
}
.nav-label {
max-width: min(38vw, 320px);
}
.site-nav-actions {
min-width: 0;
flex-wrap: wrap;
}
.navbar .nav-link,
.navbar-text {
color: var(--rp-muted);
font-size: 13px;
font-weight: 500;
}
.navbar .nav-link:hover {
color: var(--rp-ink);
}
.theme-toggle {
min-width: 58px;
}
.hero-band {
min-height: auto;
display: grid;
align-items: center;
border-bottom: 0;
background: var(--rp-canvas);
text-align: center;
padding: 96px 0 88px;
}
.hero-band .container {
max-width: 720px;
min-width: 0;
}
.hero-band .container > * {
max-width: 100%;
min-width: 0;
}
.hero-logo {
width: 80px;
height: 80px;
margin-inline: auto;
}
.hero-band.compact {
min-height: auto;
padding: 56px 0 48px;
}
.section {
padding: 88px 0;
border-bottom: 0;
background: var(--rp-canvas);
}
.section + .section {
border-top: 0;
}
.success-section {
background: var(--rp-surface-alt);
}
.danger-section {
background: var(--rp-surface);
border-top: 1px solid var(--rp-soft-line);
}
.page-heading,
.admin-heading .container {
max-width: 760px;
}
.admin-heading {
padding: 56px 0 48px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1,
h2 {
font-family: "SF Pro Rounded", ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
h1 {
max-width: 720px;
font-size: 36px;
line-height: 1.11;
font-weight: 500;
letter-spacing: 0;
overflow-wrap: anywhere;
}
h2 {
font-size: 28px;
line-height: 1.22;
font-weight: 500;
letter-spacing: 0;
}
h3 {
font-size: 19px;
line-height: 1.35;
font-weight: 500;
}
.eyebrow {
color: var(--rp-charcoal);
font-size: 13px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
.lead {
max-width: 660px;
color: var(--rp-muted);
font-size: 16px;
line-height: 1.5;
letter-spacing: 0;
}
.section-copy,
.intro {
margin-top: 12px;
max-width: 560px;
color: var(--rp-muted);
font-size: 15px;
line-height: 1.5;
}
.install-pill {
display: inline-flex;
align-items: center;
max-width: 100%;
min-width: 0;
min-height: 48px;
padding: 12px 20px;
border: 0;
border-radius: 9999px;
background: var(--rp-field);
color: var(--rp-ink);
}
.install-pill code {
min-width: 0;
overflow: hidden;
color: var(--rp-ink);
text-overflow: ellipsis;
white-space: nowrap;
font: 400 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.card {
background: var(--rp-surface);
border: 1px solid var(--rp-line);
border-radius: 16px;
box-shadow: none;
overflow: clip;
}
.card-body {
padding: 24px;
}
label {
display: grid;
gap: 8px;
color: var(--rp-charcoal);
font-size: 14px;
font-weight: 500;
}
.form-control,
input {
width: 100%;
min-height: 44px;
border: 1px solid var(--rp-line);
border-radius: 9999px;
padding: 10px 16px;
color: var(--bs-body-color);
font: inherit;
background: var(--rp-field);
outline: none;
}
.form-select {
min-height: 38px;
border-color: var(--rp-line);
border-radius: 9999px;
background-color: var(--rp-field);
color: var(--rp-ink);
}
.form-control:focus,
input:focus,
.form-select:focus {
border-color: var(--rp-ink);
box-shadow: 0 0 0 3px var(--rp-focus-ring);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 9999px;
font-size: 14px;
font-weight: 600;
line-height: 1;
transition: background-color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.btn:not(.btn-sm) {
min-height: 42px;
padding: 10px 22px;
}
.btn-sm {
min-width: 34px;
min-height: 34px;
padding-inline: 14px;
border-radius: 9999px;
}
.btn:active {
transform: scale(0.97);
}
.btn:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--rp-focus-ring);
outline-offset: 3px;
}
.btn-primary {
--bs-btn-color: #ffffff;
--bs-btn-bg: #000000;
--bs-btn-border-color: #000000;
--bs-btn-hover-color: #ffffff;
--bs-btn-hover-bg: #1c1c1c;
--bs-btn-hover-border-color: #1c1c1c;
--bs-btn-active-color: #ffffff;
--bs-btn-active-bg: #1c1c1c;
--bs-btn-active-border-color: #1c1c1c;
--bs-btn-disabled-bg: var(--rp-surface-alt);
--bs-btn-disabled-border-color: var(--rp-line);
--bs-btn-disabled-color: var(--rp-mute);
}
.btn-outline-primary {
--bs-btn-color: var(--rp-ink);
--bs-btn-border-color: var(--rp-line);
--bs-btn-hover-color: var(--rp-ink);
--bs-btn-hover-bg: var(--rp-surface-alt);
--bs-btn-hover-border-color: var(--rp-line);
--bs-btn-active-color: var(--rp-ink);
--bs-btn-active-bg: var(--rp-surface-alt);
--bs-btn-active-border-color: var(--rp-line);
}
.btn-outline-secondary,
.btn-light {
--bs-btn-bg: var(--rp-field);
--bs-btn-border-color: var(--rp-line);
--bs-btn-color: var(--rp-ink);
--bs-btn-hover-bg: var(--rp-surface-alt);
--bs-btn-hover-border-color: var(--rp-line);
--bs-btn-hover-color: var(--rp-ink);
}
.btn-outline-danger {
--bs-btn-color: var(--rp-danger);
--bs-btn-border-color: var(--rp-line);
--bs-btn-hover-color: #ffffff;
--bs-btn-hover-bg: var(--rp-danger);
--bs-btn-hover-border-color: var(--rp-danger);
}
.text-primary {
color: var(--rp-ink) !important;
}
.text-bg-primary {
color: #ffffff !important;
background-color: #000000 !important;
}
.text-bg-secondary,
.text-bg-warning {
color: var(--rp-charcoal) !important;
background-color: var(--rp-field) !important;
border: 1px solid var(--rp-line);
}
.text-bg-primary.rounded-circle {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
border-radius: 9999px !important;
}
.alert-info,
.alert-warning {
--bs-alert-color: var(--rp-charcoal);
--bs-alert-bg: var(--rp-surface-alt);
--bs-alert-border-color: var(--rp-line);
border-radius: 12px;
}
.copy-field {
min-width: 0;
max-width: 100%;
}
.copy-field .input-group-text {
min-width: 150px;
border-color: var(--rp-line);
color: var(--rp-muted);
font-size: 13px;
background: var(--rp-surface-alt);
}
.copy-label {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.copy-field .form-control {
min-width: 0;
border-color: var(--rp-line);
background: var(--rp-field);
}
.copy-field .copy-value {
min-width: 0;
overflow-wrap: anywhere;
}
.copy-field code.form-control {
width: 1%;
flex: 1 1 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.copy-field code {
min-height: 42px;
margin: 0;
color: var(--rp-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
background: var(--rp-field);
}
.manual-code {
overflow-x: auto;
padding: 16px;
border: 1px solid var(--rp-line);
border-radius: 12px;
color: var(--rp-terminal-ink);
background: var(--rp-terminal);
font: 400 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.terminal-card {
display: grid;
gap: 8px;
padding: 18px 20px;
border: 1px solid var(--rp-line);
border-radius: 12px;
color: var(--rp-terminal-ink);
background: var(--rp-terminal);
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.terminal-card code {
display: block;
overflow-wrap: anywhere;
color: inherit;
font: inherit;
}
.admin-table-scroll {
max-height: 560px;
overflow: auto;
background: var(--rp-surface);
border-color: var(--rp-line) !important;
border-radius: 16px !important;
-webkit-overflow-scrolling: touch;
}
.admin-table-scroll thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--rp-surface-alt);
}
.table-page-size {
width: auto;
min-width: 76px;
}
.admin-table-shell .form-control {
max-width: 360px;
}
.status-table-shell {
max-width: 100%;
border: 1px solid var(--rp-line);
border-radius: 16px;
background: var(--rp-surface);
}
.pagination {
--bs-pagination-color: var(--rp-muted);
--bs-pagination-bg: var(--rp-surface);
--bs-pagination-border-color: var(--rp-line);
--bs-pagination-hover-color: var(--rp-ink);
--bs-pagination-hover-bg: var(--rp-field);
--bs-pagination-hover-border-color: var(--rp-line);
--bs-pagination-active-color: #ffffff;
--bs-pagination-active-bg: #000000;
--bs-pagination-active-border-color: #000000;
--bs-pagination-disabled-color: var(--rp-mute);
--bs-pagination-disabled-bg: var(--rp-surface);
--bs-pagination-disabled-border-color: var(--rp-line);
}
.site-footer {
padding: 32px 0;
background: var(--rp-canvas);
}
.site-footer .nav-link:hover {
color: var(--rp-ink) !important;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: var(--rp-ink);
}
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--rp-ink);
--bs-table-border-color: var(--rp-soft-line);
--bs-table-hover-bg: var(--rp-field);
margin-bottom: 0;
}
th,
td {
border-bottom: 1px solid var(--rp-soft-line);
padding: 14px 10px;
text-align: left;
white-space: nowrap;
}
td {
max-width: 38rem;
overflow: hidden;
text-overflow: ellipsis;
}
.status-table {
min-width: min(100%, 420px);
}
.status-table th,
.status-table td {
white-space: normal;
overflow-wrap: anywhere;
}
tr:last-child th,
tr:last-child td {
border-bottom: 0;
}
th {
color: var(--rp-muted);
font-size: 12px;
font-weight: 600;
background: var(--rp-surface-alt);
}
.empty {
color: var(--rp-muted);
text-align: center;
padding: 28px;
}
@media (max-width: 860px) {
.section,
.hero-band {
padding: 64px 0;
}
.hero-band {
min-height: auto;
}
h1 {
font-size: 32px;
}
}
@media (max-width: 560px) {
.container,
.container.narrow,
.hero-band .container {
width: 100%;
max-width: 100%;
}
.site-nav {
padding: 10px 0;
}
.site-nav .container {
align-items: flex-start;
}
.site-nav-actions {
max-width: calc(100% - 108px);
row-gap: 8px;
}
.brand {
font-size: 14px;
}
.brand img {
width: 24px;
height: 24px;
}
.navbar .nav-link,
.navbar-text {
font-size: 12px;
}
.nav-label {
max-width: 100%;
}
.hero-band .btn,
.card button,
.danger-section form,
.danger-section button {
width: 100%;
}
.install-pill {
width: 100%;
}
.hero-logo {
width: 68px;
height: 68px;
}
.copy-field {
display: grid;
grid-template-columns: 1fr;
}
.copy-field .input-group-text {
min-width: 0;
border-radius: 14px 14px 0 0;
white-space: normal;
}
.copy-field .copy-value,
.copy-field code {
width: 100%;
white-space: normal;
overflow-wrap: anywhere;
border-radius: 0;
}
.copy-field .btn {
width: 100%;
border-radius: 0;
}
.copy-field .btn:last-child {
border-radius: 0 0 14px 14px;
}
.admin-table-shell .form-control {
max-width: none;
}
.admin-table-shell [data-table-count] {
white-space: normal !important;
}
.pagination {
flex-wrap: wrap;
justify-content: flex-start !important;
}
th,
td {
padding: 12px 9px;
}
h1 {
font-size: 28px;
line-height: 1.15;
}
h2 {
font-size: 24px;
}
}
""".strip()
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from .html_utils import SafeHtml, attr, escape, join_html, trusted
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
if label:
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">Docs</a><a class="nav-link px-0" href="/redoc">API</a>')
account_html += '<a class="btn btn-sm btn-primary rounded-pill" href="/#create">Generate</a>'
theme_toggle = '<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch color theme">Light</button>'
return trusted(
f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo.png" alt="" width="28" height="28">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
{links}
{account_html}
{theme_toggle}
</div>
</div>
</nav>
"""
)
def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtml:
safe_label = escape(label)
safe_value = escape(value)
badge = ""
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
if sensitivity:
safe_sensitivity = escape(sensitivity)
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
value_field = (
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
if is_secret
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
)
reveal_button = (
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>'
if is_secret
else ""
)
return trusted(
f"""
<div class="input-group copy-field">
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
{value_field}
{reveal_button}
<button type="button" class="btn btn-outline-secondary" data-copy="{safe_value}" aria-label="Copy {safe_label}">Copy</button>
</div>
"""
)
def _copy_button(label: str, value: str) -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
return trusted(f'<button type="button" class="btn btn-outline-primary rounded-pill" data-copy="{safe_value}">{safe_label}</button>')
def _manual_update_commands(update_url: str) -> str:
update_base = update_url.split("?", 1)[0]
return "\n".join(
[
f"curl -sS '{update_base}?myip=203.0.113.10'",
f"curl -sS '{update_base}?myipv6=2001:db8::10'",
f"curl -sS '{update_base}'",
]
)
def _manual_update_panel(commands: str) -> SafeHtml:
safe_commands = escape(commands)
return trusted(
f"""
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box replaces <code>&lt;ipaddr&gt;</code> and <code>&lt;ip6addr&gt;</code>, then sends a GET request to the Update-URL. Scripts and other routers can do the same with curl. If no IP parameter is sent, KarlDNS uses the request source IP.</p>
</div>
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
{_copy_button("Copy curl examples", commands)}
</div>
"""
)
def _fritzbox_fields(
update_url: str,
hostname: str,
username: str,
password: str,
management_url: str | None = None,
) -> str:
lines = [
f"Update-URL: {update_url}",
f"Domainnamen: {hostname}",
f"CNAME target: {hostname}",
f"Benutzername: {username}",
f"Kennwort: {password}",
]
if management_url:
lines.append(f"Private status page: {management_url}")
return "\n".join(lines)
def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Domainnamen and CNAME target can be shared in DNS.
<strong>Secret:</strong> Update-URL, one-box update URL, optional Kennwort, private dashboard URL, and domain claim links control the hostname.
Secrets are hidden by default on screen; Copy still copies the real value.
</div>
"""
)
def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the router password and one-box update URL now. They are only shown when generated or rotated and are not recoverable from the dashboard later.
</div>
"""
)
def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
def _site_footer() -> SafeHtml:
return trusted(
"""
<footer class="site-footer border-top">
<div class="container d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row">
<div class="small text-secondary">Made by Karl</div>
<nav class="nav justify-content-center gap-3" aria-label="Footer links">
<a class="nav-link p-0 small text-secondary" href="https://github.com/StasonJatham/karldns.de" rel="noopener">GitHub</a>
<a class="nav-link p-0 small text-secondary" href="https://karl.fail" rel="noopener">karl.fail</a>
<a class="nav-link p-0 small text-secondary" href="https://karlcom.de" rel="noopener">karlcom.de</a>
<a class="nav-link p-0 small text-secondary" href="/docs">API docs</a>
<a class="nav-link p-0 small text-secondary" href="/redoc">ReDoc</a>
<a class="nav-link p-0 small text-secondary" href="/privacy">Legal & privacy</a>
</nav>
</div>
</footer>
"""
)
def _page(title: str, body: SafeHtml) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
return f"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<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">
<script src="/app.js" defer></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/app.css" rel="stylesheet">
</head>
<body>{body}{_site_footer()}</body>
</html>
"""
__all__ = [
"SafeHtml",
"_copy_button",
"_copy_row",
"_fritzbox_fields",
"_manual_update_commands",
"_manual_update_panel",
"_message_band",
"_one_time_secret_notice",
"_page",
"_secret_notice",
"_top_nav",
"join_html",
"trusted",
]
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
from urllib.parse import urlencode
from ..ddns_models import DdnsSettings
from ..ddns_security import admin_csrf_token
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import _page, _top_nav
from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_cleanup_row,
_domain_claim_row,
_event_row,
_scheduled_task_row,
)
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
return trusted(f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Admin action</p>
<h2>{escape(title)}</h2>
<p class="section-copy">Copy this now. Secret values are only shown in this response.</p>
<div class="d-grid gap-2">
{"".join(rows)}
</div>
</div>
</section>
""")
def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: list[dict[str, str | int | None]],
domain_challenges: list[dict[str, str | None]],
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
table_counts: dict[str, int],
table_offsets: dict[str, int],
table_limit: int,
notice_html: SafeHtml | None,
) -> str:
csrf = attr(admin_csrf_token(settings))
admin_path = settings.admin_path
safe_admin_path = attr(admin_path)
safe_records_path = attr(f"{admin_path}/records")
safe_events_path = attr(f"{admin_path}/events")
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
"""
domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """
<tr class="empty-row"><td colspan="5" class="empty">No domain claims yet.</td></tr>
"""
cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """
<tr class="empty-row"><td colspan="3" class="empty">No cleanup runs yet.</td></tr>
"""
scheduled_task_rows = "\n".join(_scheduled_task_row(task) for task in scheduled_tasks) or """
<tr class="empty-row"><td colspan="6" class="empty">No scheduled tasks yet.</td></tr>
"""
event_rows = "\n".join(_event_row(event) for event in update_events) or """
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
"""
accounts_count = escape(table_counts.get("accounts", len(accounts)))
domain_count = escape(table_counts.get("domain_challenges", len(domain_challenges)))
cleanup_count = escape(table_counts.get("cleanup_runs", len(cleanup_runs)))
event_count = escape(table_counts.get("update_events", len(update_events)))
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
domain_pager = _server_page_links(
admin_path,
"domain_claims_offset",
table_offsets.get("domain_challenges", 0),
table_limit,
table_counts.get("domain_challenges", 0),
)
cleanup_pager = _server_page_links(admin_path, "cleanup_runs_offset", table_offsets.get("cleanup_runs", 0), table_limit, table_counts.get("cleanup_runs", 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))
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator console", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
<section class="section admin-heading">
<div class="container">
<p class="eyebrow">KarlDNS aktiv</p>
<h1>Operator console</h1>
<p class="lead">Inspect hostnames, verify domain claims, rotate links, run cleanup, and review router update events.</p>
</div>
</section>
{notice_html or ""}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<p class="eyebrow">Service health</p>
<h2>Scheduled cleanup</h2>
<p class="section-copy">Cleanup runs every {escape(settings.cleanup_interval_seconds)} seconds. It removes unverified domain claims after {escape(settings.cleanup_challenge_hours)} hours and never-used generated hostnames after {escape(settings.cleanup_unused_account_hours)} hours.</p>
<p class="section-copy">The scheduler state is stored in SQLite. Multiple app workers coordinate with a database lease, so only one worker runs a due cleanup task.</p>
</div>
<form method="post" action="{safe_admin_path}/cleanup" class="col-lg-5 card card-body gap-3" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run cleanup now</button>
</form>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Hostnames</p>
<h2>Active credentials</h2>
<p class="section-copy mb-0">Showing up to {escape(len(accounts))} of {accounts_count} hostnames.</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>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th>Links</th><th></th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{account_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Domain claims</p>
<h2>Verification lifecycle</h2>
<p class="section-copy mb-0">Showing up to {escape(len(domain_challenges))} of {domain_count} claims.</p>
</div>
</div>
{_admin_table_toolbar("domain-claims", "Search domain claims")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Domain</th><th>Owner dashboard</th><th>Status</th><th>Created</th><th>Verified</th></tr></thead>
<tbody>{domain_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{domain_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Scheduler state</h2>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll mb-4">
<table class="table mb-0">
<thead><tr><th>Task</th><th>Status</th><th>Next run</th><th>Lease expires</th><th>Last finished</th><th>Last error</th></tr></thead>
<tbody>{scheduled_task_rows}</tbody>
</table>
</div>
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Recent runs</h2>
<p class="section-copy mb-0">Showing up to {escape(len(cleanup_runs))} of {cleanup_count} runs.</p>
</div>
</div>
{_admin_table_toolbar("cleanup-runs", "Search cleanup runs")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain claims removed</th><th>Unused hostnames removed</th></tr></thead>
<tbody>{cleanup_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{cleanup_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Updates</p>
<h2>Recent events</h2>
<p class="section-copy mb-0">Showing up to {escape(len(update_events))} of {event_count} event rows. Repeated unchanged polls are aggregated.</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>Time</th><th>Domain</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{event_pager}
</div>
</section>
</main>
"""),
)
def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: int, total: int) -> SafeHtml:
if total <= limit:
return trusted("")
previous_offset = max(0, offset - limit)
next_offset = offset + limit
previous_disabled = " disabled" if offset <= 0 else ""
next_disabled = " disabled" if next_offset >= total else ""
previous_href = attr(f"{admin_path}?{urlencode({offset_name: previous_offset})}")
next_href = attr(f"{admin_path}?{urlencode({offset_name: next_offset})}")
start = min(offset + 1, total)
end = min(offset + limit, total)
return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-3" aria-label="Server-side table pages">
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous server page</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next server page</a>
</nav>
""")
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
hostname = escape(account["hostname"])
username = escape(account["username"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
safe_admin_path = attr(admin_path)
return f"""
<tr>
<td>{hostname}</td><td>{username}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/rotate" class="d-flex gap-2 flex-wrap">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Rotate update URL" aria-label="Rotate update URL for {hostname}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Rotate management link" aria-label="Rotate management link for {hostname}">Manage link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Rotate router password" aria-label="Rotate router password for {hostname}">Password</button>
</form>
</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button class="btn btn-sm btn-light px-2 text-danger" title="Delete hostname" aria-label="Delete {hostname}">x</button>
</form>
</td>
</tr>
"""
def _admin_table_toolbar(table_id: str, search_label: str) -> str:
safe_id = attr(table_id)
safe_label = attr(search_label)
return f"""
<div class="admin-table-shell mt-3" data-admin-table>
<div class="d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row mb-3">
<label class="w-100 mb-0" for="{safe_id}-search">
<span class="visually-hidden">{safe_label}</span>
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}" data-table-search autocomplete="off">
</label>
<div class="d-flex align-items-center gap-2 flex-shrink-0">
<span class="small text-secondary text-nowrap" data-table-count>0-0 of 0</span>
<label class="visually-hidden" for="{safe_id}-page-size">Rows per page</label>
<select id="{safe_id}-page-size" class="form-select form-select-sm table-page-size" data-table-page-size>
<option value="10">10</option>
<option value="25" selected>25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
"""
def _admin_table_pagination() -> str:
return """
<nav class="mt-3" aria-label="Table pagination">
<ul class="pagination pagination-sm justify-content-end mb-0" data-table-pagination></ul>
</nav>
</div>
"""
def _domain_claim_row(challenge: dict[str, str | None]) -> str:
domain = escape(challenge["domain"])
owner = escape(challenge.get("owner_hostname") or "-")
created = escape(challenge.get("created_at") or "-")
verified = escape(challenge.get("verified_at") or "-")
status = "Verified" if challenge.get("verified_at") else "Pending"
return f"<tr><td>{domain}</td><td>{owner}</td><td>{status}</td><td>{created}</td><td>{verified}</td></tr>"
def _cleanup_row(run: dict[str, str | int]) -> str:
created = escape(run.get("created_at") or "-")
domains = escape(run.get("domain_challenges_deleted") or 0)
accounts = escape(run.get("unused_accounts_deleted") or 0)
return f"<tr><td>{created}</td><td>{domains}</td><td>{accounts}</td></tr>"
def _scheduled_task_row(task: dict[str, str | int | None]) -> str:
name = escape(task.get("name") or "-")
status = escape(task.get("last_status") or "-")
next_run = escape(task.get("next_run_at") or "-")
lease_expires = escape(task.get("lease_expires_at") or "-")
last_finished = escape(task.get("last_finished_at") or "-")
last_error = escape(task.get("last_error") or "-")
return f"<tr><td>{name}</td><td>{status}</td><td>{next_run}</td><td>{lease_expires}</td><td>{last_finished}</td><td>{last_error}</td></tr>"
def _event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
hostname = escape(event.get("hostname") or "-")
status = escape(event.get("status") or "-")
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
return f"<tr><td>{created}</td><td>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
+377
View File
@@ -0,0 +1,377 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
_copy_button,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_page,
_secret_notice,
_top_nav,
)
from .templates_public_sections import _credentials_panel
def _render_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
challenge: dict[str, str | None] | None = None,
created_account: dict[str, str] | None = None,
) -> str:
account_for_url = {
"hostname": str(account["hostname"]),
"username": str(account["username"]),
"password": "",
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
hostname = escape(account["hostname"])
username = escape(account["username"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated")
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
manual_commands = _manual_update_commands(update_url)
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)) or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
message_html = _management_message(message)
rotated_credentials = _credentials_panel(service, created_account)
domain_claim = _management_domain_claim_panel(service, management_slug, csrf_token, challenge)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
return _page(
"KarlDNS management",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Magic management</p>
<h1>{hostname}</h1>
<p class="lead">Anyone with this link can manage or delete this hostname. Keep it private.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Back home</a>
</div>
</section>
{message_html}
{rotated_credentials}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<h2>Router update settings</h2>
<p class="section-copy">Use the Update-URL directly if your router only has one URL field. Separate Benutzername and Kennwort values are optional compatibility fields. If you use your own DNS name, point it at this hostname with a CNAME.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap mt-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy CNAME record", cname_record)}
</div>
</div>
<div class="col-lg-6 d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
</div>
</div>
{_manual_update_panel(manual_commands)}
<div class="card card-body gap-3 mt-4">
<div>
<p class="eyebrow">Actions</p>
<h3>Rotate dashboard secrets</h3>
<p class="section-copy">Rotate the URL used by routers, or issue a new router password for compatibility clients. Update your router after rotating.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate Update-URL</button>
</form>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="password">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate router credentials</button>
</form>
</div>
</div>
</div>
</section>
{domain_claim}
{dashboard_password_panel}
<section class="section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Current address</p>
<h2>Status</h2>
</div>
<div class="table-responsive status-table-shell">
<table class="table status-table mb-0">
<tbody>
<tr><th>IPv4</th><td>{ipv4}</td></tr>
<tr><th>IPv6</th><td>{ipv6}</td></tr>
<tr><th>Updated</th><td>{updated}</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">History</p>
<h2>Router updates</h2>
<p class="section-copy">Recent IP changes and failed DNS publishes for this hostname.</p>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{history_rows}</tbody>
</table>
</div>
</div>
</section>
<section class="section danger-section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Danger zone</p>
<h2>Delete hostname</h2>
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/delete">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
</div>
</section>
</main>
"""),
)
def _management_message(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
password_status = "Enabled" if password_enabled else "Not enabled"
password_action = "Change dashboard password" if password_enabled else "Set dashboard password"
remove_form = ""
if password_enabled:
remove_form = f"""
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password/remove">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label class="form-label" for="dashboard-remove-current-password">Current dashboard password</label>
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove dashboard password</button>
</form>
"""
current_password_field = ""
if password_enabled:
current_password_field = """
<div>
<label class="form-label" for="dashboard-current-password">Current dashboard password</label>
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
return trusted(
f"""
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2>
<p class="section-copy">Status: {escape(password_status)}. This protects this management dashboard only. Router update URLs continue to work normally.</p>
</div>
<div class="col-lg-7 d-grid gap-3">
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
{current_password_field}
<div>
<label class="form-label" for="dashboard-password">{escape(password_action)}</label>
<input class="form-control" id="dashboard-password" name="dashboard_password" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div>
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
<input class="form-control" id="dashboard-password-confirm" name="dashboard_password_confirm" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button>
</div>
</form>
{remove_form}
</div>
</div>
</div>
</section>
"""
)
def _render_management_unlock_page(
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
) -> str:
hostname = escape(account["hostname"])
message_html = ""
if message:
message_html = f'<div class="alert alert-warning" role="alert">{escape(message)}</div>'
return _page(
"Unlock KarlDNS management",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Dashboard locked</p>
<h1>{hostname}</h1>
<p class="lead">Enter the dashboard password to manage this hostname.</p>
</div>
</section>
<section class="section">
<div class="container narrow">
{message_html}
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/unlock">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<div>
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password" type="password" autocomplete="current-password" required autofocus>
</div>
<label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
<span class="form-check-label">Stay unlocked for 30 days on this browser</span>
</label>
<button class="btn btn-primary rounded-pill" type="submit">Unlock dashboard</button>
</form>
</div>
</section>
</main>
"""),
)
def _management_domain_claim_panel(
service: DdnsService,
management_slug: str,
csrf_token: str,
challenge: dict[str, str | None] | None,
) -> SafeHtml:
create_claim = trusted(
f"""
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Custom domain</p>
<h2>Claim a domain</h2>
<p class="section-copy">Create a TXT challenge, verify it from this dashboard, then create router credentials for a hostname under that verified domain.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/request" class="d-grid gap-3" aria-label="Create dashboard domain claim">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create domain claim</button>
</form>
</div>
"""
)
if not challenge:
return trusted(
f"""
<section class="section">
<div class="container narrow">{create_claim}</div>
</section>
"""
)
domain = str(challenge["domain"])
claim_secret = str(challenge.get("claim_secret") or "")
verification_name = service.verification_name(domain)
token = str(challenge["token"])
claim_url = service.domain_claim_url(challenge)
status = "Verified" if challenge.get("verified_at") else "Waiting for TXT verification"
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
create_hostname = _management_custom_hostname_form(management_slug, claim_secret, csrf_token) if challenge.get("verified_at") else trusted("")
return trusted(
f"""
<section class="section" id="custom-domain">
<div class="container narrow d-grid gap-4">
{create_claim}
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Domain claim</p>
<h2>{escape(domain)}</h2>
<p class="section-copy">Status: {escape(status)}</p>
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Another dashboard cannot verify or use it.</p>
</div>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The claim secret and private claim link are secrets.</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy TXT record", txt_bundle)}
</div>
<div class="d-grid gap-2">
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Claim secret", claim_secret, "Secret")}
{_copy_row("Private claim link", claim_url, "Secret")}
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/verify" class="d-flex gap-2 flex-wrap" aria-label="Verify dashboard domain claim">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="domain" value="{attr(domain)}">
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<button type="submit" class="btn btn-primary rounded-pill">Verify claim</button>
</form>
{create_hostname}
</div>
</div>
</section>
"""
)
def _management_custom_hostname_form(management_slug: str, claim_secret: str, csrf_token: str | None = None) -> SafeHtml:
csrf_field = f'<input type="hidden" name="csrf" value="{attr(csrf_token)}">' if csrf_token else ""
return trusted(
f"""
<form method="post" action="/m/{attr(management_slug)}/domains/hostnames" class="d-grid gap-3" aria-label="Create verified custom hostname">
{csrf_field}
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<label>Custom hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create custom hostname</button>
</form>
"""
)
def _user_event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
status = escape(event.get("status") or "-")
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
return f"<tr><td>{created}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import escape, trusted
from .templates import _message_band, _page, _top_nav
from .templates_public_sections import _credentials_panel, _custom_domain_flow, _linux_installer_section
def _render_public_home(
service: DdnsService,
settings: DdnsSettings,
created_account: dict[str, str] | None,
message: str | None,
challenge: dict[str, str | None] | None = None,
) -> str:
suffix = escape(settings.hostname_suffix or "your DynDNS domain")
demo_update_url = escape(f"{settings.public_base_url.rstrip('/')}/u/<secret>?myip=<ipaddr>")
created_html = _credentials_panel(service, created_account)
message_html = _message_band(message) if message and not challenge else ""
challenge_html = _custom_domain_flow(service, challenge, message)
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.png" alt="" width="80" height="80">
<h1>KarlDNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">Generate one secret update URL under {suffix}. Paste it into your router, script it with curl, or point your own subdomain at it with a CNAME.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<a class="btn btn-primary rounded-pill" href="#create">Generate hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">See curl flow</a>
</div>
</div>
</section>
{message_html}
{created_html}
<section class="section" id="create">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Choose your hostname</p>
<h2>No account. No login.</h2>
<p class="section-copy mx-auto">Use a generated KarlDNS hostname immediately, or verify your own domain with a DNS TXT record.</p>
</div>
<div class="row g-4 align-items-stretch">
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use our domain</p>
<h3>Generate a KarlDNS hostname</h3>
<p class="section-copy mb-0">KarlDNS generates the hostname, secret update URL, and optional router compatibility credentials automatically.</p>
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Generate hostname</button>
</form>
</div>
</div>
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use your own domain</p>
<h3>Verify DNS ownership</h3>
<p class="section-copy mb-0">Add one TXT record so KarlDNS can confirm you control the domain before it publishes A/AAAA records there.</p>
<form method="post" action="/request-domain" class="d-grid gap-3" aria-label="Request a custom domain TXT challenge">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create TXT challenge</button>
</form>
</div>
</div>
</div>
<div class="terminal-card mt-4" aria-hidden="true">
<code>$ curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
<code>good 203.0.113.10</code>
</div>
</div>
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from anything that can send GET.</h2>
<p class="lead mx-auto mt-3">Use FRITZ!Box placeholders, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, or your own automation.</p>
<div class="terminal-card text-start mt-4">
<code># explicit IPv4</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
<code># source IP fallback</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;'</code>
</div>
</div>
</section>
{_linux_installer_section()}
{challenge_html}
</main>
"""),
)
def _render_privacy_page() -> str:
return _page(
"Legal and Privacy",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Legal and privacy</p>
<h1>KarlDNS keeps accountless DynDNS simple.</h1>
<p class="lead">This page explains the basic data handled by this self-hosted service. Configure your own imprint, controller details, and jurisdiction-specific terms before offering it publicly.</p>
</div>
</section>
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores generated hostnames, public DNS target names, update slugs, hashed management tokens, hashed router passwords, DNS TXT verification challenges, IP update history, timestamps, and operational events needed to run Dynamic DNS.</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Private management links, update URLs, router passwords, and one-box update URLs are bearer secrets. Anyone with them can update or manage the related hostname. Passwords and management lookup values are not stored in recoverable plaintext.</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">The service keeps recent update events for troubleshooting and runs cleanup jobs for abandoned setup flows and unused hostnames. Server and reverse-proxy logs may also contain IP addresses and request metadata depending on your deployment.</p>
</div>
<div>
<h2>Contact</h2>
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
</div>
</div>
</section>
</main>
"""),
)
@@ -0,0 +1,191 @@
from __future__ import annotations
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
_copy_button,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_secret_notice,
)
def _linux_installer_section() -> SafeHtml:
system_install = "curl -fsSL https://karldns.de/install.sh | sudo sh"
system_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sudo sh"
user_install = "curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user"
clean_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sh -s -- --mode user --delete-config"
all_commands = "\n".join([system_install, system_uninstall, user_install, clean_uninstall])
return trusted(
f"""
<section class="section" id="linux-installer">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p>
<h2>Install and uninstall should be boring.</h2>
<p class="section-copy mx-auto">For a Linux server, Proxmox host, NAS, or home-lab box that is always on, the installer creates a hostname if needed, installs the updater, schedules cron, runs the first update, and prints the private dashboard URL.</p>
</div>
<div class="card card-body gap-3">
<div class="terminal-card">
<code>{escape(system_install)}</code>
<code>{escape(system_uninstall)}</code>
</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy install", system_install)}
{_copy_button("Copy uninstall", system_uninstall)}
{_copy_button("Copy all commands", all_commands)}
</div>
<div class="alert alert-info my-0 py-2 small" role="note">
Uninstall keeps the private config by default because it may contain your only local copy of the update and dashboard URLs. Use <code>--delete-config</code> for a clean wipe.
</div>
<div>
<h3>Without sudo</h3>
<p class="section-copy">User mode writes to <code>~/.local/bin</code>, <code>~/.config/karldns</code>, and your user crontab.</p>
</div>
<div class="terminal-card">
<code>{escape(user_install)}</code>
<code>{escape(clean_uninstall)}</code>
</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy user install", user_install)}
{_copy_button("Copy clean user uninstall", clean_uninstall)}
</div>
</div>
</div>
</section>
"""
)
def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> SafeHtml:
if not challenge:
return trusted("")
domain = attr(challenge["domain"])
token = str(challenge["token"])
claim_secret = attr(challenge.get("claim_secret") or "")
verification_name = service.verification_name(str(challenge["domain"]))
claim_url = service.domain_claim_url(challenge)
status = escape(message or "Add this TXT record at your DNS provider, then check when it has propagated.")
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
challenge_html = trusted(
f"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3">
<span class="badge text-bg-primary rounded-circle align-self-start">1</span>
<h3>Add the TXT record</h3>
<p class="small text-secondary mb-0 mt-2">{status}</p>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The private claim link is secret.</div>
{_copy_button("Copy TXT record", txt_bundle)}
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Private claim link", claim_url, "Secret")}
<form method="post" action="/verify-domain" class="mt-3" aria-label="Check DNS TXT verification">
<input type="hidden" name="domain" value="{domain}">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<button type="submit" class="btn btn-primary rounded-pill">Check DNS record</button>
</form>
</div>
</div>
"""
)
create_form = _custom_hostname_form(claim_secret) if challenge.get("verified_at") else _locked_custom_hostname_card()
return trusted(
f"""
<section class="section" id="custom-domain">
<div class="container narrow">
<div class="text-center">
<p class="eyebrow">Direct custom-domain publishing</p>
<h2>Verify DNS ownership</h2>
<p class="section-copy mx-auto">Use this only when KarlDNS should publish A/AAAA records directly inside your domain. If you just want your own subdomain, generate a hostname above and create a CNAME to it.</p>
</div>
<div class="row g-3 mt-4 justify-content-center">
{challenge_html}
{create_form}
</div>
</div>
</section>
"""
)
def _credentials_panel(service: DdnsService, created_account: dict[str, str] | None) -> SafeHtml:
if not created_account:
return trusted("")
hostname = created_account["hostname"]
username = created_account["username"]
password = created_account["password"]
update_url = service.fritz_update_url(created_account)
credentialed_update_url = service.credentialed_update_url(created_account)
management_url = service.magic_management_url(created_account)
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, password, management_url)
manual_commands = _manual_update_commands(update_url)
return trusted(
f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Hostname generated</p>
<h2>{escape(hostname)}</h2>
<p class="section-copy">Use the Update-URL directly. The separate Benutzername and Kennwort values are optional compatibility fields for routers that require them.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap my-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy one-box update URL", credentialed_update_url)}
{_copy_button("Copy CNAME record", cname_record)}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("One-box update URL:", credentialed_update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", password, "Secret")}
{_copy_row("Private status page:", management_url, "Secret")}
</div>
{_manual_update_panel(manual_commands)}
</div>
</section>
"""
)
def _custom_hostname_form(claim_secret: str) -> SafeHtml:
return trusted(
f"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create router hostname</h3>
<form method="post" action="/accounts" class="d-grid gap-3" aria-label="Create custom-domain router credentials">
<input type="hidden" name="mode" value="custom">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<label>Verified hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<p class="section-copy mb-0">Router compatibility credentials are generated automatically after creation.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create credentials</button>
</form>
</div>
</div>
"""
)
def _locked_custom_hostname_card() -> SafeHtml:
return trusted(
"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3 opacity-75">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create router hostname</h3>
<p class="small text-secondary mb-0 mt-2">This unlocks after the TXT record is visible in public DNS.</p>
</div>
</div>
"""
)